| 1 | "use client"; |
| 2 | |
| 3 | import { createContext, useContext, useEffect, useState, useCallback } from "react"; |
| 4 | import type { User } from "./api"; |
| 5 | |
| 6 | |
| 7 | interface AuthContextValue { |
| 8 | user: User | null; |
| 9 | token: string | null; |
| 10 | loading: boolean; |
| 11 | login: (token: string, user: User) => void; |
| 12 | logout: () => void; |
| 13 | } |
| 14 | |
| 15 | const AuthContext = createContext<AuthContextValue>({ |
| 16 | user: null, |
| 17 | token: null, |
| 18 | loading: true, |
| 19 | login: () => {}, |
| 20 | logout: () => {}, |
| 21 | }); |
| 22 | |
| 23 | /** Get cookie domain candidates for cross-subdomain sharing. */ |
| 24 | function getCookieDomains(): string[] { |
| 25 | const hostname = window.location.hostname; |
| 26 | // IPs can't use domain cookies. |
| 27 | if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return []; |
| 28 | // In local dev, browsers differ on localhost domain-cookie handling. |
| 29 | if (hostname === "localhost" || hostname.endsWith(".localhost")) { |
| 30 | return ["localhost", ".localhost"]; |
| 31 | } |
| 32 | // e.g. "canopy.grove.host" → ".grove.host", "grove.host" → ".grove.host" |
| 33 | const parts = hostname.split("."); |
| 34 | if (parts.length <= 2) return [`.${hostname}`]; |
| 35 | return [`.${parts.slice(-2).join(".")}`]; |
| 36 | } |
| 37 | |
| 38 | function setCookie(name: string, value: string) { |
| 39 | const domains = getCookieDomains(); |
| 40 | const secure = window.location.protocol === "https:" ? "; Secure" : ""; |
| 41 | const encoded = encodeURIComponent(value); |
| 42 | const base = `${name}=${encoded}; path=/; SameSite=Lax; max-age=${60 * 60 * 24 * 30}${secure}`; |
| 43 | // Write a host cookie for current origin. |
| 44 | document.cookie = base; |
| 45 | // Also write domain cookies for cross-subdomain sharing when possible. |
| 46 | for (const domain of domains) { |
| 47 | document.cookie = `${base}; domain=${domain}`; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | function deleteCookie(name: string) { |
| 52 | const domains = getCookieDomains(); |
| 53 | // Remove host cookie. |
| 54 | document.cookie = `${name}=; path=/; max-age=0`; |
| 55 | // Remove domain cookies. |
| 56 | for (const domain of domains) { |
| 57 | document.cookie = `${name}=; path=/; domain=${domain}; max-age=0`; |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | function getCookie(name: string): string | null { |
| 62 | const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); |
| 63 | return match ? decodeURIComponent(match[1]) : null; |
| 64 | } |
| 65 | |
| 66 | export function AuthProvider({ children }: { children: React.ReactNode }) { |
| 67 | const [user, setUser] = useState<User | null>(null); |
| 68 | const [token, setToken] = useState<string | null>(null); |
| 69 | const [loading, setLoading] = useState(true); |
| 70 | |
| 71 | useEffect(() => { |
| 72 | // Try localStorage first, then fall back to shared cookie |
| 73 | let storedToken = localStorage.getItem("grove_hub_token"); |
| 74 | let storedUser: User | null = null; |
| 75 | |
| 76 | if (storedToken) { |
| 77 | try { |
| 78 | storedUser = JSON.parse(localStorage.getItem("grove_hub_user") ?? "null"); |
| 79 | // Migrate: sync existing localStorage sessions to shared cookie |
| 80 | if (!getCookie("grove_hub_token")) { |
| 81 | setCookie("grove_hub_token", storedToken); |
| 82 | setCookie("grove_hub_user", localStorage.getItem("grove_hub_user") ?? ""); |
| 83 | } |
| 84 | } catch { |
| 85 | localStorage.removeItem("grove_hub_token"); |
| 86 | localStorage.removeItem("grove_hub_user"); |
| 87 | storedToken = null; |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | if (!storedToken) { |
| 92 | storedToken = getCookie("grove_hub_token"); |
| 93 | const cookieUser = getCookie("grove_hub_user"); |
| 94 | if (storedToken && cookieUser) { |
| 95 | try { |
| 96 | storedUser = JSON.parse(cookieUser); |
| 97 | // Sync to localStorage for this origin |
| 98 | localStorage.setItem("grove_hub_token", storedToken); |
| 99 | localStorage.setItem("grove_hub_user", cookieUser); |
| 100 | } catch { |
| 101 | storedToken = null; |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | if (storedToken && storedUser) { |
| 107 | setToken(storedToken); |
| 108 | setUser(storedUser); |
| 109 | } |
| 110 | setLoading(false); |
| 111 | }, []); |
| 112 | |
| 113 | // Sync React state when api.ts reactive refresh updates the token |
| 114 | useEffect(() => { |
| 115 | function handleRefresh(e: Event) { |
| 116 | const { token: newToken, user: newUser } = (e as CustomEvent).detail; |
| 117 | setToken(newToken); |
| 118 | setUser(newUser); |
| 119 | } |
| 120 | window.addEventListener("grove:token-refreshed", handleRefresh); |
| 121 | return () => window.removeEventListener("grove:token-refreshed", handleRefresh); |
| 122 | }, []); |
| 123 | |
| 124 | // Cross-tab sync via storage events |
| 125 | useEffect(() => { |
| 126 | function handleStorage(e: StorageEvent) { |
| 127 | if (e.key === "grove_hub_token") { |
| 128 | if (e.newValue) { |
| 129 | setToken(e.newValue); |
| 130 | try { |
| 131 | const u = localStorage.getItem("grove_hub_user"); |
| 132 | if (u) setUser(JSON.parse(u)); |
| 133 | } catch {} |
| 134 | } else { |
| 135 | setToken(null); |
| 136 | setUser(null); |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | window.addEventListener("storage", handleStorage); |
| 141 | return () => window.removeEventListener("storage", handleStorage); |
| 142 | }, []); |
| 143 | |
| 144 | const login = useCallback((newToken: string, newUser: User) => { |
| 145 | localStorage.setItem("grove_hub_token", newToken); |
| 146 | localStorage.setItem("grove_hub_user", JSON.stringify(newUser)); |
| 147 | setCookie("grove_hub_token", newToken); |
| 148 | setCookie("grove_hub_user", JSON.stringify(newUser)); |
| 149 | setToken(newToken); |
| 150 | setUser(newUser); |
| 151 | }, []); |
| 152 | |
| 153 | const logout = useCallback(() => { |
| 154 | localStorage.removeItem("grove_hub_token"); |
| 155 | localStorage.removeItem("grove_hub_user"); |
| 156 | deleteCookie("grove_hub_token"); |
| 157 | deleteCookie("grove_hub_user"); |
| 158 | setToken(null); |
| 159 | setUser(null); |
| 160 | }, []); |
| 161 | |
| 162 | return ( |
| 163 | <AuthContext.Provider value={{ user, token, loading, login, logout }}> |
| 164 | {children} |
| 165 | </AuthContext.Provider> |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | export function useAuth() { |
| 170 | return useContext(AuthContext); |
| 171 | } |
| 172 | |