@appocus/auth-react-router 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENCE.md ADDED
@@ -0,0 +1,11 @@
1
+ # Released under MIT License
2
+
3
+ Copyright (c) 2013 Mark Otto.
4
+
5
+ Copyright (c) 2017 Andrew Fong.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
8
+
9
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @appocus/auth-react-router
2
+
3
+ OAuth login plumbing for Appocus apps built on React Router 7 + `@zauru-sdk/webapp-services`.
4
+
5
+ ## What's inside
6
+
7
+ - `authMiddleware`, `loginMiddleware`, `sessionRefreshMiddleware`
8
+ - `buildOAuthAuthorizeURL(requestUrl, { state })`
9
+ - `authenticatedFetch(input, init)` — `credentials: "include"` fetch that opens a popup on `401 { code: "UNAUTHORIZED" }` and retries once
10
+ - `requestReauthInPopup()` — promise-based popup orchestrator
11
+ - `createReauthConstants(appKey)` + `configureAuthRuntime({ reauthConstants, popupWindowName })`
12
+ - `AuthShellLayout`, `RedirectIfAuthenticated`
13
+ - Route module factories: `createLoginRoute`, `createLoginPopupRoute`, `createLogoutRoute`, `createCallbackRoute`, `createReauthPopupDoneRoute`
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @appocus/auth-react-router
19
+ ```
20
+
21
+ Peer deps: `react`, `react-router`, `sonner`, `@zauru-sdk/webapp-services`, `@zauru-sdk/webapp-config`.
22
+
23
+ ## Configure once
24
+
25
+ ```ts
26
+ // app/root.tsx (or any client entry that runs early)
27
+ import {
28
+ configureAuthRuntime,
29
+ createReauthConstants,
30
+ } from "@appocus/auth-react-router";
31
+ import { appocusConfig } from "@/appocus.config";
32
+
33
+ configureAuthRuntime({
34
+ reauthConstants: createReauthConstants("4pinos-agrocreditos"),
35
+ popupWindowName: appocusConfig.oauth.reauthPopupWindowName,
36
+ });
37
+ ```
38
+
39
+ ## Wire routes
40
+
41
+ ```tsx
42
+ // app/pages/login.tsx
43
+ import { createLoginRoute } from "@appocus/auth-react-router";
44
+ import { LoginForm } from "@appocus/ui";
45
+
46
+ const route = createLoginRoute({ LoginForm });
47
+
48
+ export const middleware = route.middleware;
49
+ export const action = route.action;
50
+ export default route.default;
51
+ ```
52
+
53
+ For `/callback`, supply the `onSuccess` callback so domain-specific persistence
54
+ (employees, agencies, …) stays in your app:
55
+
56
+ ```tsx
57
+ // app/pages/callback.tsx
58
+ import { createCallbackRoute } from "@appocus/auth-react-router";
59
+ import { LoginForm } from "@appocus/ui";
60
+ import { EmployeesRepository } from "@/db/employees/repository.server";
61
+ import { UsersRepository } from "@/db/users/repository.server";
62
+
63
+ const route = createCallbackRoute({
64
+ LoginForm,
65
+ async onSuccess({ session }) {
66
+ const employeeId = Number(session.get("employee_id"));
67
+ const selectedEntity = Number(session.get("selected_entity"));
68
+ const userName = String(session.get("name") ?? "Usuario");
69
+ const userEmail = String(session.get("username") ?? "usuario@example.com");
70
+
71
+ const employee = await EmployeesRepository.getById(employeeId);
72
+ const agencyId = employee?.agency_id ?? null;
73
+ if (!agencyId) {
74
+ return {
75
+ error:
76
+ "No se pudo determinar la agencia del empleado. Contacta al administrador.",
77
+ };
78
+ }
79
+
80
+ await UsersRepository.upsertFromLogin({
81
+ employee_id: employeeId,
82
+ agency_id: agencyId,
83
+ selected_entity: selectedEntity,
84
+ name: userName,
85
+ email: userEmail,
86
+ });
87
+
88
+ return {
89
+ queryParams: new URLSearchParams({
90
+ name: userName,
91
+ email: userEmail,
92
+ employee_id: String(employeeId),
93
+ agency_id: String(agencyId),
94
+ selected_entity: String(selectedEntity),
95
+ }),
96
+ };
97
+ },
98
+ });
99
+
100
+ export const middleware = route.middleware;
101
+ export const action = route.action;
102
+ export default route.default;
103
+ ```
104
+
105
+ ## Authenticated client fetch
106
+
107
+ ```ts
108
+ import { authenticatedFetch } from "@appocus/auth-react-router";
109
+
110
+ const res = await authenticatedFetch("/api/things");
111
+ ```
@@ -0,0 +1,10 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Standard wrapper used by the auth pages: muted-background, vertically centered,
4
+ * responsive max-width column. Pure layout — does not depend on any specific
5
+ * design-system primitives.
6
+ */
7
+ export declare function AuthShellLayout({ children, className, }: {
8
+ children: React.ReactNode;
9
+ className?: string;
10
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Like `fetch` with `credentials: "include"`, but on a 401 carrying
3
+ * `{ code: "UNAUTHORIZED" }` opens the reauth popup and retries **once**.
4
+ */
5
+ export declare function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Standard wrapper used by the auth pages: muted-background, vertically centered,
4
+ * responsive max-width column. Pure layout — does not depend on any specific
5
+ * design-system primitives.
6
+ */
7
+ export function AuthShellLayout({ children, className, }) {
8
+ return (_jsx("div", { className: "bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10", children: _jsx("div", { className: className ?? "w-full max-w-sm md:max-w-4xl", children: children }) }));
9
+ }
@@ -0,0 +1,37 @@
1
+ function isUnauthorizedPayload(body) {
2
+ return (typeof body === "object" &&
3
+ body !== null &&
4
+ body.code === "UNAUTHORIZED");
5
+ }
6
+ /**
7
+ * Like `fetch` with `credentials: "include"`, but on a 401 carrying
8
+ * `{ code: "UNAUTHORIZED" }` opens the reauth popup and retries **once**.
9
+ */
10
+ export async function authenticatedFetch(input, init) {
11
+ const merged = {
12
+ ...init,
13
+ credentials: init?.credentials ?? "include",
14
+ };
15
+ let res = await fetch(input, merged);
16
+ if (res.status !== 401 || typeof window === "undefined") {
17
+ return res;
18
+ }
19
+ let body;
20
+ try {
21
+ body = await res.clone().json();
22
+ }
23
+ catch {
24
+ return res;
25
+ }
26
+ if (!isUnauthorizedPayload(body)) {
27
+ return res;
28
+ }
29
+ try {
30
+ const { requestReauthInPopup } = await import("./reauth-popup.client.js");
31
+ await requestReauthInPopup();
32
+ }
33
+ catch {
34
+ return res;
35
+ }
36
+ return fetch(input, merged);
37
+ }
@@ -0,0 +1,14 @@
1
+ export * from "./middleware.js";
2
+ export * from "./oauth-authorize-url.js";
3
+ export * from "./authenticated-fetch.js";
4
+ export * from "./reauth-constants.js";
5
+ export * from "./runtime-config.js";
6
+ export * from "./reauth-popup.client.js";
7
+ export * from "./auth-shell-layout.js";
8
+ export * from "./redirect-if-authenticated.js";
9
+ export * from "./route-types.js";
10
+ export * from "./routes/login.js";
11
+ export * from "./routes/login-popup.js";
12
+ export * from "./routes/logout.js";
13
+ export * from "./routes/callback.js";
14
+ export * from "./routes/reauth-popup-done.js";
@@ -0,0 +1,55 @@
1
+ import { redirect } from "react-router";
2
+ import { getSession, getRefreshSession } from "@zauru-sdk/webapp-services";
3
+ /**
4
+ * Throws a redirect to `/login` if the request has no OAuth session cookie.
5
+ * Attach as a route middleware on authenticated routes.
6
+ */
7
+ export const authMiddleware = async ({ request }) => {
8
+ const cookie = request.headers.get("Cookie") ?? "";
9
+ const session = await getSession(cookie);
10
+ if (!session.has("code")) {
11
+ console.log("[appocus/auth] User is not authenticated, redirecting to login.");
12
+ throw redirect("/login");
13
+ }
14
+ };
15
+ /**
16
+ * Throws a redirect to `/` if the request already has an OAuth session cookie.
17
+ * Attach to `/login`, `/callback` etc. to bounce already-logged-in users home.
18
+ */
19
+ export const loginMiddleware = async ({ request }) => {
20
+ const cookie = request.headers.get("Cookie") ?? "";
21
+ const session = await getSession(cookie);
22
+ if (session.has("code")) {
23
+ console.log("[appocus/auth] User is already authenticated, redirecting to home.");
24
+ throw redirect("/");
25
+ }
26
+ };
27
+ /**
28
+ * After-handler middleware that bumps the session cookie / Redis TTL when a
29
+ * `code` cookie is present. Errors are swallowed so a failed refresh never breaks
30
+ * the underlying response.
31
+ */
32
+ export async function sessionRefreshMiddleware({ request }, next) {
33
+ const response = await next();
34
+ try {
35
+ const cookie = request.headers.get("Cookie") ?? "";
36
+ const session = await getSession(cookie);
37
+ if (!session.has("code")) {
38
+ return response;
39
+ }
40
+ const setCookie = await getRefreshSession(request);
41
+ if (!setCookie || typeof setCookie !== "string") {
42
+ return response;
43
+ }
44
+ const headers = new Headers(response.headers);
45
+ headers.append("Set-Cookie", setCookie);
46
+ return new Response(response.body, {
47
+ status: response.status,
48
+ statusText: response.statusText,
49
+ headers,
50
+ });
51
+ }
52
+ catch {
53
+ return response;
54
+ }
55
+ }
@@ -0,0 +1,19 @@
1
+ import { config } from "@zauru-sdk/webapp-config";
2
+ /**
3
+ * Build the Zauru OAuth `dialog/authorize` URL using the host's `request.url` to
4
+ * derive the `redirect_uri`. Pass `options.state` to mark this authorization as
5
+ * coming from a reauth popup.
6
+ */
7
+ export function buildOAuthAuthorizeURL(requestUrl, options) {
8
+ const origin = new URL(requestUrl).origin;
9
+ const params = new URLSearchParams({
10
+ client_id: String(config.oauthClientID ?? ""),
11
+ response_type: "code",
12
+ redirect_uri: `${origin}/callback`,
13
+ });
14
+ if (options?.state) {
15
+ params.set("state", options.state);
16
+ }
17
+ const base = config.oauthBaseURL?.replace(/\/$/, "") ?? "";
18
+ return `${base}/dialog/authorize?${params.toString()}`;
19
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Build per-app reauth constants with a deployment-unique prefix so multiple
3
+ * Appocus apps on the same origin can't interfere with each other's popups.
4
+ *
5
+ * ```ts
6
+ * const reauthConstants = createReauthConstants("4pinos-agrocreditos");
7
+ * ```
8
+ */
9
+ export function createReauthConstants(appKey) {
10
+ return {
11
+ REAUTH_POPUP_OAUTH_STATE: `${appKey}_reauth_popup`,
12
+ ZAURU_REAUTH_MESSAGE_TYPE: `${appKey}:reauth-success`,
13
+ ZAURU_REAUTH_FAILURE_TYPE: `${appKey}:reauth-failure`,
14
+ };
15
+ }
16
+ /** Sensible defaults used when the host app never calls `configureAuthRuntime`. */
17
+ export const DEFAULT_REAUTH_CONSTANTS = createReauthConstants("appocus");
@@ -0,0 +1,91 @@
1
+ "use client";
2
+ import { toast } from "sonner";
3
+ import { getAuthRuntime } from "./runtime-config.js";
4
+ const POPUP_FEATURES = "popup=yes,width=520,height=720,scrollbars=yes,resizable=yes";
5
+ const REAUTH_TIMEOUT_MS = 5 * 60 * 1000;
6
+ const CLOSED_POLL_MS = 400;
7
+ let reauthPromise = null;
8
+ function waitForReauthPopup(origin) {
9
+ const { reauthConstants, popupWindowName } = getAuthRuntime();
10
+ return new Promise((resolve, reject) => {
11
+ const popupUrl = `${origin}/login-popup`;
12
+ const w = window.open(popupUrl, popupWindowName, POPUP_FEATURES);
13
+ if (!w) {
14
+ reject(new Error("El navegador bloqueó la ventana emergente. Permite ventanas para este sitio e intenta de nuevo."));
15
+ return;
16
+ }
17
+ let settled = false;
18
+ const started = Date.now();
19
+ const cleanup = () => {
20
+ window.removeEventListener("message", onMessage);
21
+ clearInterval(closedPoll);
22
+ clearTimeout(timeoutId);
23
+ };
24
+ const finishError = (err) => {
25
+ if (settled)
26
+ return;
27
+ settled = true;
28
+ cleanup();
29
+ try {
30
+ if (!w.closed)
31
+ w.close();
32
+ }
33
+ catch {
34
+ /* ignore */
35
+ }
36
+ reject(err);
37
+ };
38
+ const finishOk = () => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ cleanup();
43
+ resolve();
44
+ };
45
+ const onMessage = (ev) => {
46
+ if (ev.origin !== origin)
47
+ return;
48
+ const d = ev.data;
49
+ if (d?.type === reauthConstants.ZAURU_REAUTH_MESSAGE_TYPE) {
50
+ finishOk();
51
+ }
52
+ if (d?.type === reauthConstants.ZAURU_REAUTH_FAILURE_TYPE) {
53
+ finishError(new Error(d.error ?? "No se pudo renovar la sesión."));
54
+ }
55
+ };
56
+ window.addEventListener("message", onMessage);
57
+ const closedPoll = setInterval(() => {
58
+ if (Date.now() - started > REAUTH_TIMEOUT_MS) {
59
+ finishError(new Error("Tiempo de espera del inicio de sesión agotado."));
60
+ return;
61
+ }
62
+ if (w.closed) {
63
+ finishError(new Error("Inicio de sesión cancelado."));
64
+ }
65
+ }, CLOSED_POLL_MS);
66
+ const timeoutId = setTimeout(() => {
67
+ finishError(new Error("Tiempo de espera del inicio de sesión agotado."));
68
+ }, REAUTH_TIMEOUT_MS);
69
+ });
70
+ }
71
+ /**
72
+ * Open the OAuth popup and resolve once the session cookie has been refreshed.
73
+ * Concurrent callers share a single in-flight promise.
74
+ */
75
+ export function requestReauthInPopup() {
76
+ if (typeof window === "undefined") {
77
+ return Promise.reject(new Error("Reauth solo disponible en el navegador."));
78
+ }
79
+ if (reauthPromise) {
80
+ return reauthPromise;
81
+ }
82
+ const origin = window.location.origin;
83
+ toast.message("Sesión expirada", {
84
+ description: "Completa el inicio de sesión en la ventana emergente para continuar.",
85
+ duration: 12_000,
86
+ });
87
+ reauthPromise = waitForReauthPopup(origin).finally(() => {
88
+ reauthPromise = null;
89
+ });
90
+ return reauthPromise;
91
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ import { useNavigate } from "react-router";
4
+ /**
5
+ * Probes `/api/auth/me` and, on success, navigates to `/`.
6
+ * Reinforces the server-side redirect for SPA flows where the URL isn't updated
7
+ * after the route module's middleware runs.
8
+ */
9
+ export function RedirectIfAuthenticated({ meEndpoint = "/api/auth/me", redirectTo = "/", } = {}) {
10
+ const navigate = useNavigate();
11
+ useEffect(() => {
12
+ let cancelled = false;
13
+ fetch(meEndpoint, { credentials: "include" })
14
+ .then((res) => {
15
+ if (!cancelled && res.ok) {
16
+ navigate(redirectTo, { replace: true });
17
+ }
18
+ })
19
+ .catch(() => { });
20
+ return () => {
21
+ cancelled = true;
22
+ };
23
+ }, [navigate, meEndpoint, redirectTo]);
24
+ return null;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import { redirect, useNavigate, useSearchParams, useSubmit, } from "react-router";
5
+ import { toast } from "sonner";
6
+ import { commitSession, getSession, nativeLogin, } from "@zauru-sdk/webapp-services";
7
+ import { config } from "@zauru-sdk/webapp-config";
8
+ import { loginMiddleware } from "../middleware.js";
9
+ import { AuthShellLayout } from "../auth-shell-layout.js";
10
+ import { getAuthRuntime } from "../runtime-config.js";
11
+ export function createCallbackRoute({ LoginForm, onSuccess, onLoginError, isMobile: isMobileFn, }) {
12
+ const action = async ({ request, }) => {
13
+ const formData = await request.formData();
14
+ const isPopupReauth = formData.get("popup") === "1";
15
+ const rawCode = formData.get("code");
16
+ const codeValue = rawCode === "null" || rawCode === "undefined" || rawCode == null
17
+ ? null
18
+ : String(rawCode);
19
+ const cookie = request.headers.get("Cookie") ?? null;
20
+ const session = (await getSession(cookie));
21
+ if (session.has("code")) {
22
+ console.log("[appocus/auth] User is already logged in, redirecting to home.");
23
+ if (isPopupReauth) {
24
+ const reqUrl = new URL(request.url);
25
+ return redirect(`${reqUrl.origin}/auth/reauth-popup-done`);
26
+ }
27
+ return redirect("/");
28
+ }
29
+ if (!codeValue) {
30
+ console.error("[appocus/auth] Authorization code is missing in the callback URL.");
31
+ return {
32
+ error: "Authorization code is missing.",
33
+ };
34
+ }
35
+ const loginResponse = await nativeLogin(session, codeValue);
36
+ if (loginResponse.error) {
37
+ console.error("[appocus/auth] Login failed:", loginResponse.userMsg);
38
+ return { error: String(loginResponse.userMsg ?? "Login failed.") };
39
+ }
40
+ let onSuccessResult;
41
+ try {
42
+ onSuccessResult = await onSuccess({
43
+ session,
44
+ codeValue,
45
+ request,
46
+ });
47
+ }
48
+ catch (e) {
49
+ console.error("[appocus/auth] onSuccess threw:", e);
50
+ return {
51
+ error: e instanceof Error
52
+ ? e.message
53
+ : "Error completando el inicio de sesión.",
54
+ };
55
+ }
56
+ if ("error" in onSuccessResult) {
57
+ return { error: onSuccessResult.error };
58
+ }
59
+ const expires = new Date();
60
+ expires.setSeconds(expires.getSeconds() + Number(config.expirationDurationInSeconds));
61
+ const setCookie = await commitSession(session, { expires });
62
+ const reqUrl = new URL(request.url);
63
+ if (isPopupReauth) {
64
+ return redirect(`${reqUrl.origin}/auth/reauth-popup-done`, {
65
+ headers: { "Set-Cookie": setCookie },
66
+ });
67
+ }
68
+ const params = onSuccessResult.queryParams?.toString();
69
+ return redirect(params ? `/?${params}` : `/`, {
70
+ headers: { "Set-Cookie": setCookie },
71
+ });
72
+ };
73
+ function CallbackPage({ actionData }) {
74
+ const [isClient, setIsClient] = useState(false);
75
+ const submit = useSubmit();
76
+ const [params] = useSearchParams();
77
+ const navigate = useNavigate();
78
+ const mobile = isMobileFn?.() ?? false;
79
+ const { reauthConstants } = getAuthRuntime();
80
+ const oauthCode = params.get("code");
81
+ const isReauthPopupFlow = params.get("state") === reauthConstants.REAUTH_POPUP_OAUTH_STATE;
82
+ useEffect(() => {
83
+ setIsClient(true);
84
+ }, []);
85
+ useEffect(() => {
86
+ if (!isClient || !oauthCode)
87
+ return;
88
+ submit({
89
+ code: oauthCode,
90
+ ...(isReauthPopupFlow ? { popup: "1" } : {}),
91
+ }, { method: "post", action: "/callback" });
92
+ }, [isClient, submit, oauthCode, isReauthPopupFlow]);
93
+ useEffect(() => {
94
+ if (!actionData ||
95
+ !("error" in actionData) ||
96
+ !actionData.error) {
97
+ return;
98
+ }
99
+ const description = actionData.error;
100
+ if (isReauthPopupFlow) {
101
+ window.opener?.postMessage({
102
+ type: reauthConstants.ZAURU_REAUTH_FAILURE_TYPE,
103
+ error: description,
104
+ }, window.location.origin);
105
+ window.close();
106
+ return;
107
+ }
108
+ if (onLoginError?.(description))
109
+ return;
110
+ toast.error("Error", {
111
+ description,
112
+ position: mobile ? "bottom-center" : "bottom-right",
113
+ duration: 4000,
114
+ });
115
+ navigate("/login");
116
+ }, [actionData, mobile, navigate, isReauthPopupFlow, reauthConstants]);
117
+ return (_jsx(AuthShellLayout, { children: _jsx(LoginForm, { isLoading: true }) }));
118
+ }
119
+ return {
120
+ middleware: [loginMiddleware],
121
+ action: action,
122
+ default: CallbackPage,
123
+ };
124
+ }
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { redirect } from "react-router";
3
+ import { buildOAuthAuthorizeURL } from "../oauth-authorize-url.js";
4
+ import { getAuthRuntime } from "../runtime-config.js";
5
+ export function createLoginPopupRoute(options = {}) {
6
+ const message = options.redirectingMessage ?? "Redirigiendo al inicio de sesión…";
7
+ const loader = async ({ request }) => {
8
+ const { reauthConstants } = getAuthRuntime();
9
+ throw redirect(buildOAuthAuthorizeURL(request.url, {
10
+ state: reauthConstants.REAUTH_POPUP_OAUTH_STATE,
11
+ }));
12
+ };
13
+ function LoginPopupPage() {
14
+ return (_jsx("div", { className: "bg-muted flex min-h-svh items-center justify-center p-6 text-center text-sm text-muted-foreground", children: message }));
15
+ }
16
+ return {
17
+ loader,
18
+ default: LoginPopupPage,
19
+ };
20
+ }
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { redirect } from "react-router";
3
+ import { loginMiddleware } from "../middleware.js";
4
+ import { buildOAuthAuthorizeURL } from "../oauth-authorize-url.js";
5
+ import { AuthShellLayout } from "../auth-shell-layout.js";
6
+ import { RedirectIfAuthenticated } from "../redirect-if-authenticated.js";
7
+ export function createLoginRoute({ LoginForm, onAction, }) {
8
+ const action = async ({ request }) => {
9
+ if (onAction)
10
+ return onAction(request);
11
+ return redirect(buildOAuthAuthorizeURL(request.url));
12
+ };
13
+ function LoginPage() {
14
+ return (_jsxs(AuthShellLayout, { children: [_jsx(RedirectIfAuthenticated, {}), _jsx(LoginForm, {})] }));
15
+ }
16
+ return {
17
+ middleware: [loginMiddleware],
18
+ action,
19
+ default: LoginPage,
20
+ };
21
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect } from "react";
4
+ import { redirect, useSubmit } from "react-router";
5
+ import { getSession, destroySession } from "@zauru-sdk/webapp-services";
6
+ import { config } from "@zauru-sdk/webapp-config";
7
+ import { AuthShellLayout } from "../auth-shell-layout.js";
8
+ /** Module-scope flag to avoid double-submission under React Strict Mode. */
9
+ let logoutAutoSubmitStarted = false;
10
+ export function createLogoutRoute({ LoginForm, clearUserData, copy, }) {
11
+ const action = async ({ request }) => {
12
+ const cookie = request.headers.get("Cookie");
13
+ const session = await getSession(cookie || "");
14
+ return redirect(`${config.oauthBaseURL}/logout`, {
15
+ headers: {
16
+ "Set-Cookie": await destroySession(session),
17
+ },
18
+ });
19
+ };
20
+ function LogoutPage() {
21
+ const submit = useSubmit();
22
+ useEffect(() => {
23
+ clearUserData?.();
24
+ if (logoutAutoSubmitStarted)
25
+ return;
26
+ logoutAutoSubmitStarted = true;
27
+ submit(null, { method: "post", action: "/logout" });
28
+ }, [submit]);
29
+ return (_jsx(AuthShellLayout, { children: _jsx(LoginForm, { isLoading: true, mode: "logout", title: copy?.title ?? "Cerrando sesión", loadingText: copy?.loadingText ?? "Cerrando sesión...", buttonText: copy?.buttonText ?? "Cerrar sesión" }) }));
30
+ }
31
+ return {
32
+ action,
33
+ default: LogoutPage,
34
+ };
35
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect } from "react";
4
+ import { getAuthRuntime } from "../runtime-config.js";
5
+ export function createReauthPopupDoneRoute(options = {}) {
6
+ const message = options.message ?? "Sesión renovada. Esta ventana se cerrará sola.";
7
+ function ReauthPopupDonePage() {
8
+ useEffect(() => {
9
+ const origin = window.location.origin;
10
+ const { reauthConstants } = getAuthRuntime();
11
+ try {
12
+ window.opener?.postMessage({ type: reauthConstants.ZAURU_REAUTH_MESSAGE_TYPE }, origin);
13
+ }
14
+ finally {
15
+ window.close();
16
+ }
17
+ }, []);
18
+ return (_jsx("div", { className: "bg-muted flex min-h-svh items-center justify-center p-6 text-center text-sm text-muted-foreground", children: message }));
19
+ }
20
+ return { default: ReauthPopupDonePage };
21
+ }
@@ -0,0 +1,20 @@
1
+ import { DEFAULT_REAUTH_CONSTANTS, } from "./reauth-constants.js";
2
+ let runtime = {
3
+ reauthConstants: DEFAULT_REAUTH_CONSTANTS,
4
+ popupWindowName: "appocus-reauth",
5
+ };
6
+ /**
7
+ * Configure the runtime singletons used by `authenticatedFetch`,
8
+ * `requestReauthInPopup`, and the bundled route factories. Call this once at
9
+ * client startup (e.g. from `root.tsx`).
10
+ */
11
+ export function configureAuthRuntime(opts) {
12
+ runtime = {
13
+ reauthConstants: opts.reauthConstants ?? runtime.reauthConstants,
14
+ popupWindowName: opts.popupWindowName ?? runtime.popupWindowName,
15
+ };
16
+ return runtime;
17
+ }
18
+ export function getAuthRuntime() {
19
+ return runtime;
20
+ }
@@ -0,0 +1,14 @@
1
+ export * from "./middleware.js";
2
+ export * from "./oauth-authorize-url.js";
3
+ export * from "./authenticated-fetch.js";
4
+ export * from "./reauth-constants.js";
5
+ export * from "./runtime-config.js";
6
+ export * from "./reauth-popup.client.js";
7
+ export * from "./auth-shell-layout.js";
8
+ export * from "./redirect-if-authenticated.js";
9
+ export * from "./route-types.js";
10
+ export * from "./routes/login.js";
11
+ export * from "./routes/login-popup.js";
12
+ export * from "./routes/logout.js";
13
+ export * from "./routes/callback.js";
14
+ export * from "./routes/reauth-popup-done.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Throws a redirect to `/login` if the request has no OAuth session cookie.
3
+ * Attach as a route middleware on authenticated routes.
4
+ */
5
+ export declare const authMiddleware: ({ request }: {
6
+ request: Request;
7
+ }) => Promise<void>;
8
+ /**
9
+ * Throws a redirect to `/` if the request already has an OAuth session cookie.
10
+ * Attach to `/login`, `/callback` etc. to bounce already-logged-in users home.
11
+ */
12
+ export declare const loginMiddleware: ({ request }: {
13
+ request: Request;
14
+ }) => Promise<void>;
15
+ /**
16
+ * After-handler middleware that bumps the session cookie / Redis TTL when a
17
+ * `code` cookie is present. Errors are swallowed so a failed refresh never breaks
18
+ * the underlying response.
19
+ */
20
+ export declare function sessionRefreshMiddleware({ request }: {
21
+ request: Request;
22
+ }, next: () => Promise<Response>): Promise<Response>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Build the Zauru OAuth `dialog/authorize` URL using the host's `request.url` to
3
+ * derive the `redirect_uri`. Pass `options.state` to mark this authorization as
4
+ * coming from a reauth popup.
5
+ */
6
+ export declare function buildOAuthAuthorizeURL(requestUrl: string, options?: {
7
+ state?: string;
8
+ }): string;
@@ -0,0 +1,19 @@
1
+ export type ReauthConstants = {
2
+ /** OAuth `state` value used when the login is happening inside a popup. */
3
+ REAUTH_POPUP_OAUTH_STATE: string;
4
+ /** `postMessage` type emitted by the popup after a successful re-login. */
5
+ ZAURU_REAUTH_MESSAGE_TYPE: string;
6
+ /** `postMessage` type emitted by the popup when re-login fails. */
7
+ ZAURU_REAUTH_FAILURE_TYPE: string;
8
+ };
9
+ /**
10
+ * Build per-app reauth constants with a deployment-unique prefix so multiple
11
+ * Appocus apps on the same origin can't interfere with each other's popups.
12
+ *
13
+ * ```ts
14
+ * const reauthConstants = createReauthConstants("4pinos-agrocreditos");
15
+ * ```
16
+ */
17
+ export declare function createReauthConstants(appKey: string): ReauthConstants;
18
+ /** Sensible defaults used when the host app never calls `configureAuthRuntime`. */
19
+ export declare const DEFAULT_REAUTH_CONSTANTS: ReauthConstants;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Open the OAuth popup and resolve once the session cookie has been refreshed.
3
+ * Concurrent callers share a single in-flight promise.
4
+ */
5
+ export declare function requestReauthInPopup(): Promise<void>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Probes `/api/auth/me` and, on success, navigates to `/`.
3
+ * Reinforces the server-side redirect for SPA flows where the URL isn't updated
4
+ * after the route module's middleware runs.
5
+ */
6
+ export declare function RedirectIfAuthenticated({ meEndpoint, redirectTo, }?: {
7
+ meEndpoint?: string;
8
+ redirectTo?: string;
9
+ }): null;
@@ -0,0 +1,30 @@
1
+ import type * as React from "react";
2
+ /**
3
+ * Loose component contract every Appocus app's `LoginForm` is expected to
4
+ * implement. Keeps the auth route factories independent from `@appocus/ui`.
5
+ */
6
+ export type LoginFormProps = {
7
+ isLoading?: boolean;
8
+ mode?: "login" | "logout";
9
+ title?: string;
10
+ loadingText?: string;
11
+ buttonText?: string;
12
+ };
13
+ export type LoginFormComponent = React.ComponentType<LoginFormProps>;
14
+ export type RouteMiddlewareFn = (args: {
15
+ request: Request;
16
+ }) => Promise<unknown> | unknown;
17
+ export type RouteActionFn<T = unknown> = (args: {
18
+ request: Request;
19
+ }) => Promise<T>;
20
+ export type RouteLoaderFn<T = unknown> = (args: {
21
+ request: Request;
22
+ }) => Promise<T>;
23
+ export type RouteModule<TActionData = unknown> = {
24
+ middleware?: RouteMiddlewareFn[];
25
+ action?: RouteActionFn<TActionData>;
26
+ loader?: RouteLoaderFn;
27
+ default: React.ComponentType<{
28
+ actionData?: TActionData;
29
+ }>;
30
+ };
@@ -0,0 +1,46 @@
1
+ import type { LoginFormComponent, RouteModule } from "../route-types.js";
2
+ /**
3
+ * Minimal session contract this route depends on. Re-typed locally so we don't
4
+ * pull `@zauru-sdk/webapp-services` types into the consumer surface.
5
+ */
6
+ type ZauruSession = {
7
+ get: (key: string) => unknown;
8
+ has: (key: string) => boolean;
9
+ };
10
+ export type CallbackSuccessArgs = {
11
+ session: ZauruSession;
12
+ codeValue: string;
13
+ request: Request;
14
+ };
15
+ export type CallbackSuccessResult = {
16
+ /**
17
+ * Query params appended to the post-login redirect (`/?<queryParams>`). Used
18
+ * by apps to seed their local user context with names, ids, etc.
19
+ */
20
+ queryParams?: URLSearchParams;
21
+ };
22
+ export type CallbackErrorResult = {
23
+ error: string;
24
+ };
25
+ export type CallbackActionData = CallbackErrorResult | {
26
+ ok: true;
27
+ };
28
+ export type CreateCallbackRouteOptions = {
29
+ /** Your app's `LoginForm` (used as the splash while the action runs). */
30
+ LoginForm: LoginFormComponent;
31
+ /**
32
+ * Called after `nativeLogin` succeeds. Use it to persist domain-specific user
33
+ * records (employees, agencies, etc.) and to assemble redirect query params.
34
+ * Throw or return `{ error }` to surface an error to the page.
35
+ */
36
+ onSuccess: (args: CallbackSuccessArgs) => Promise<CallbackSuccessResult | CallbackErrorResult>;
37
+ /**
38
+ * Optional hook for handling client-side login errors before the default toast
39
+ * + navigate fallback runs. Return `true` to skip the default behavior.
40
+ */
41
+ onLoginError?: (description: string) => boolean | void;
42
+ /** Optional override for the position of the error toast. */
43
+ isMobile?: () => boolean;
44
+ };
45
+ export declare function createCallbackRoute({ LoginForm, onSuccess, onLoginError, isMobile: isMobileFn, }: CreateCallbackRouteOptions): RouteModule<CallbackActionData>;
46
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { RouteModule } from "../route-types.js";
2
+ export type CreateLoginPopupRouteOptions = {
3
+ /** Message shown while the page is redirecting to the OAuth dialog. */
4
+ redirectingMessage?: string;
5
+ };
6
+ export declare function createLoginPopupRoute(options?: CreateLoginPopupRouteOptions): RouteModule;
@@ -0,0 +1,11 @@
1
+ import type { LoginFormComponent, RouteModule } from "../route-types.js";
2
+ export type CreateLoginRouteOptions = {
3
+ /** Your app's `LoginForm` (e.g. from `@appocus/ui`). */
4
+ LoginForm: LoginFormComponent;
5
+ /**
6
+ * Override the default action (e.g. to log analytics). Defaults to redirecting
7
+ * to the OAuth authorize URL derived from `request.url`.
8
+ */
9
+ onAction?: (request: Request) => Promise<Response> | Response;
10
+ };
11
+ export declare function createLoginRoute({ LoginForm, onAction, }: CreateLoginRouteOptions): RouteModule;
@@ -0,0 +1,14 @@
1
+ import type { LoginFormComponent, RouteModule } from "../route-types.js";
2
+ export type CreateLogoutRouteOptions = {
3
+ /** Your app's `LoginForm` (used in the "Cerrando sesión" splash). */
4
+ LoginForm: LoginFormComponent;
5
+ /** Clear app-local user state on the client (e.g. context cache). */
6
+ clearUserData?: () => void;
7
+ /** Copy override for the splash (defaults are Spanish to match the rest of the suite). */
8
+ copy?: {
9
+ title?: string;
10
+ loadingText?: string;
11
+ buttonText?: string;
12
+ };
13
+ };
14
+ export declare function createLogoutRoute({ LoginForm, clearUserData, copy, }: CreateLogoutRouteOptions): RouteModule;
@@ -0,0 +1,5 @@
1
+ import type { RouteModule } from "../route-types.js";
2
+ export type CreateReauthPopupDoneRouteOptions = {
3
+ message?: string;
4
+ };
5
+ export declare function createReauthPopupDoneRoute(options?: CreateReauthPopupDoneRouteOptions): RouteModule;
@@ -0,0 +1,16 @@
1
+ import { type ReauthConstants } from "./reauth-constants.js";
2
+ export type AuthRuntimeConfig = {
3
+ reauthConstants: ReauthConstants;
4
+ /**
5
+ * Window name passed to `window.open` for the reauth popup. Setting this to a
6
+ * stable per-app value lets users reuse an already-open popup.
7
+ */
8
+ popupWindowName: string;
9
+ };
10
+ /**
11
+ * Configure the runtime singletons used by `authenticatedFetch`,
12
+ * `requestReauthInPopup`, and the bundled route factories. Call this once at
13
+ * client startup (e.g. from `root.tsx`).
14
+ */
15
+ export declare function configureAuthRuntime(opts: Partial<AuthRuntimeConfig>): AuthRuntimeConfig;
16
+ export declare function getAuthRuntime(): AuthRuntimeConfig;
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@appocus/auth-react-router",
3
+ "version": "0.2.0",
4
+ "description": "OAuth login flow building blocks for Appocus React Router apps: middlewares, OAuth URL builder, authenticated fetch with reauth popup, and route factories for /login, /logout, /login-popup, /callback and the reauth-popup-done page.",
5
+ "main": "./dist/esm/index.js",
6
+ "module": "./dist/esm/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "type": "module",
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "registry": "https://registry.npmjs.org/"
12
+ },
13
+ "scripts": {
14
+ "build": "npm run build:esm",
15
+ "build:esm": "tsc -p tsconfig.esm.json"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "author": {
21
+ "name": "Oscar Cuellar",
22
+ "email": "usackoka@gmail.com"
23
+ },
24
+ "license": "MIT",
25
+ "peerDependencies": {
26
+ "@zauru-sdk/webapp-config": "*",
27
+ "@zauru-sdk/webapp-services": "*",
28
+ "react": "^18.2.0 || ^19.0.0",
29
+ "react-router": "^7.0.0",
30
+ "sonner": "^1.0.0 || ^2.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^19.0.0",
34
+ "@zauru-sdk/webapp-config": "^5.0.3",
35
+ "@zauru-sdk/webapp-services": "^5.0.3",
36
+ "react": "^19.0.0",
37
+ "react-router": "^7.10.1",
38
+ "sonner": "^2.0.7",
39
+ "typescript": "^5.1.6"
40
+ },
41
+ "gitHead": "51b05d54842786418df070ccbcc9b190d29c88bb"
42
+ }