110 lines
3.6 KiB
TypeScript
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;
|
|
}
|