web/lib/auth.tsxblame
View source
4a006da1"use client";
4a006da2
4a006da3import { createContext, useContext, useEffect, useState, useCallback } from "react";
4a006da4import type { User } from "./api";
4a006da5
a9b28606
4a006da7interface AuthContextValue {
4a006da8 user: User | null;
4a006da9 token: string | null;
4a006da10 loading: boolean;
4a006da11 login: (token: string, user: User) => void;
4a006da12 logout: () => void;
4a006da13}
4a006da14
4a006da15const AuthContext = createContext<AuthContextValue>({
4a006da16 user: null,
4a006da17 token: null,
4a006da18 loading: true,
4a006da19 login: () => {},
4a006da20 logout: () => {},
4a006da21});
4a006da22
a33b2b623/** Get cookie domain candidates for cross-subdomain sharing. */
a33b2b624function getCookieDomains(): string[] {
da0f65125 const hostname = window.location.hostname;
a33b2b626 // IPs can't use domain cookies.
a33b2b627 if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return [];
a33b2b628 // In local dev, browsers differ on localhost domain-cookie handling.
a33b2b629 if (hostname === "localhost" || hostname.endsWith(".localhost")) {
a33b2b630 return ["localhost", ".localhost"];
a33b2b631 }
da0f65132 // e.g. "canopy.grove.host" → ".grove.host", "grove.host" → ".grove.host"
da0f65133 const parts = hostname.split(".");
a33b2b634 if (parts.length <= 2) return [`.${hostname}`];
a33b2b635 return [`.${parts.slice(-2).join(".")}`];
da0f65136}
da0f65137
da0f65138function setCookie(name: string, value: string) {
a33b2b639 const domains = getCookieDomains();
da0f65140 const secure = window.location.protocol === "https:" ? "; Secure" : "";
a33b2b641 const encoded = encodeURIComponent(value);
a33b2b642 const base = `${name}=${encoded}; path=/; SameSite=Lax; max-age=${60 * 60 * 24 * 30}${secure}`;
a33b2b643 // Write a host cookie for current origin.
a33b2b644 document.cookie = base;
a33b2b645 // Also write domain cookies for cross-subdomain sharing when possible.
a33b2b646 for (const domain of domains) {
a33b2b647 document.cookie = `${base}; domain=${domain}`;
a33b2b648 }
da0f65149}
da0f65150
da0f65151function deleteCookie(name: string) {
a33b2b652 const domains = getCookieDomains();
a33b2b653 // Remove host cookie.
a33b2b654 document.cookie = `${name}=; path=/; max-age=0`;
a33b2b655 // Remove domain cookies.
a33b2b656 for (const domain of domains) {
a33b2b657 document.cookie = `${name}=; path=/; domain=${domain}; max-age=0`;
a33b2b658 }
da0f65159}
da0f65160
da0f65161function getCookie(name: string): string | null {
da0f65162 const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
da0f65163 return match ? decodeURIComponent(match[1]) : null;
da0f65164}
da0f65165
4a006da66export function AuthProvider({ children }: { children: React.ReactNode }) {
4a006da67 const [user, setUser] = useState<User | null>(null);
4a006da68 const [token, setToken] = useState<string | null>(null);
4a006da69 const [loading, setLoading] = useState(true);
4a006da70
4a006da71 useEffect(() => {
da0f65172 // Try localStorage first, then fall back to shared cookie
da0f65173 let storedToken = localStorage.getItem("grove_hub_token");
da0f65174 let storedUser: User | null = null;
da0f65175
4a006da76 if (storedToken) {
4a006da77 try {
da0f65178 storedUser = JSON.parse(localStorage.getItem("grove_hub_user") ?? "null");
da0f65179 // Migrate: sync existing localStorage sessions to shared cookie
da0f65180 if (!getCookie("grove_hub_token")) {
da0f65181 setCookie("grove_hub_token", storedToken);
da0f65182 setCookie("grove_hub_user", localStorage.getItem("grove_hub_user") ?? "");
da0f65183 }
4a006da84 } catch {
4a006da85 localStorage.removeItem("grove_hub_token");
4a006da86 localStorage.removeItem("grove_hub_user");
da0f65187 storedToken = null;
4a006da88 }
4a006da89 }
da0f65190
da0f65191 if (!storedToken) {
da0f65192 storedToken = getCookie("grove_hub_token");
da0f65193 const cookieUser = getCookie("grove_hub_user");
da0f65194 if (storedToken && cookieUser) {
da0f65195 try {
da0f65196 storedUser = JSON.parse(cookieUser);
da0f65197 // Sync to localStorage for this origin
da0f65198 localStorage.setItem("grove_hub_token", storedToken);
da0f65199 localStorage.setItem("grove_hub_user", cookieUser);
da0f651100 } catch {
da0f651101 storedToken = null;
da0f651102 }
da0f651103 }
da0f651104 }
da0f651105
da0f651106 if (storedToken && storedUser) {
da0f651107 setToken(storedToken);
da0f651108 setUser(storedUser);
da0f651109 }
4a006da110 setLoading(false);
4a006da111 }, []);
4a006da112
a9b2860113 // Sync React state when api.ts reactive refresh updates the token
a9b2860114 useEffect(() => {
a9b2860115 function handleRefresh(e: Event) {
a9b2860116 const { token: newToken, user: newUser } = (e as CustomEvent).detail;
a9b2860117 setToken(newToken);
a9b2860118 setUser(newUser);
a9b2860119 }
a9b2860120 window.addEventListener("grove:token-refreshed", handleRefresh);
a9b2860121 return () => window.removeEventListener("grove:token-refreshed", handleRefresh);
a9b2860122 }, []);
a9b2860123
a9b2860124 // Cross-tab sync via storage events
a9b2860125 useEffect(() => {
a9b2860126 function handleStorage(e: StorageEvent) {
a9b2860127 if (e.key === "grove_hub_token") {
a9b2860128 if (e.newValue) {
a9b2860129 setToken(e.newValue);
a9b2860130 try {
a9b2860131 const u = localStorage.getItem("grove_hub_user");
a9b2860132 if (u) setUser(JSON.parse(u));
a9b2860133 } catch {}
a9b2860134 } else {
a9b2860135 setToken(null);
a9b2860136 setUser(null);
a9b2860137 }
a9b2860138 }
a9b2860139 }
a9b2860140 window.addEventListener("storage", handleStorage);
a9b2860141 return () => window.removeEventListener("storage", handleStorage);
a9b2860142 }, []);
a9b2860143
4a006da144 const login = useCallback((newToken: string, newUser: User) => {
4a006da145 localStorage.setItem("grove_hub_token", newToken);
4a006da146 localStorage.setItem("grove_hub_user", JSON.stringify(newUser));
da0f651147 setCookie("grove_hub_token", newToken);
da0f651148 setCookie("grove_hub_user", JSON.stringify(newUser));
4a006da149 setToken(newToken);
4a006da150 setUser(newUser);
4a006da151 }, []);
4a006da152
4a006da153 const logout = useCallback(() => {
4a006da154 localStorage.removeItem("grove_hub_token");
4a006da155 localStorage.removeItem("grove_hub_user");
da0f651156 deleteCookie("grove_hub_token");
da0f651157 deleteCookie("grove_hub_user");
4a006da158 setToken(null);
4a006da159 setUser(null);
4a006da160 }, []);
4a006da161
4a006da162 return (
4a006da163 <AuthContext.Provider value={{ user, token, loading, login, logout }}>
4a006da164 {children}
4a006da165 </AuthContext.Provider>
4a006da166 );
4a006da167}
4a006da168
4a006da169export function useAuth() {
4a006da170 return useContext(AuthContext);
4a006da171}