Files
spsays-training/components/auth/auth-gate.tsx
T

110 lines
3.6 KiB
TypeScript

"use client";
/**
* AuthGate — a global "require login before continuing" prompt.
*
* Why a gate at all? When an unauthenticated user clicks something that
* needs them to be logged in (Bookmark, Save to collection, etc.) we want to
*
* 1. Show a login/signup modal *on the same page* (no redirect).
* 2. Let them finish the original action right after they log in.
* 3. Keep their URL — bookmarks, scroll position, selection — untouched.
*
* The trick is the imperative `requireAuth()` call: it returns a Promise that
* resolves `true` once the user is authenticated, or `false` if they close
* the modal without doing so. Calling code reads like a sync check:
*
* if (!(await requireAuth())) return;
* // ... proceed with the protected API call
*
* Implementation sketch:
* - We hold a single "pending" promise resolver in a ref so a follow-on
* call to requireAuth (e.g. from another button) hooks onto the same
* modal session.
* - When AuthProvider reports a user (via useAuth) and the modal is open,
* we resolve the promise(s) with `true` and close.
* - When the modal closes without a user, we resolve with `false`.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useAuth } from "./auth-provider";
import { AuthModal } from "./auth-modal";
type AuthGateValue = {
/** Returns true if the user is (or becomes) authenticated; false if they dismiss. */
requireAuth: () => Promise<boolean>;
/** Force-open the modal regardless of auth state — for explicit "Log in" buttons. */
openModal: () => void;
};
const AuthGateContext = createContext<AuthGateValue | null>(null);
export function AuthGateProvider({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const [open, setOpen] = useState(false);
const resolversRef = useRef<((v: boolean) => void)[]>([]);
const requireAuth = useCallback(async () => {
// Fast path: already logged in.
if (user) return true;
setOpen(true);
return new Promise<boolean>((resolve) => {
resolversRef.current.push(resolve);
});
}, [user]);
const openModal = useCallback(() => {
if (!user) setOpen(true);
}, [user]);
// Auto-resolve pending promises the moment the user becomes authenticated.
// This is what flips us from "modal open, waiting" to "modal closed, action
// continues" without any extra wiring at the call site.
useEffect(() => {
if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(true));
}
}, [user, open]);
const onDismiss = useCallback(() => {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(false));
}, []);
const value = useMemo<AuthGateValue>(
() => ({ requireAuth, openModal }),
[requireAuth, openModal],
);
return (
<AuthGateContext.Provider value={value}>
{children}
{/* Render the modal only when we genuinely need it. `!user` guards
against the brief window during which the AuthProvider hasn't
finished hydrating; we'd rather wait than flash the modal. */}
{open && !user && !loading ? <AuthModal onDismiss={onDismiss} /> : null}
</AuthGateContext.Provider>
);
}
export function useAuthGate(): AuthGateValue {
const ctx = useContext(AuthGateContext);
if (!ctx) {
throw new Error("useAuthGate must be used inside <AuthGateProvider>");
}
return ctx;
}