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