5.4 KB172 lines
Blame
1"use client";
2
3import { createContext, useContext, useEffect, useState, useCallback } from "react";
4import type { User } from "./api";
5
6
7interface AuthContextValue {
8 user: User | null;
9 token: string | null;
10 loading: boolean;
11 login: (token: string, user: User) => void;
12 logout: () => void;
13}
14
15const 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. */
24function 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
38function 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
51function 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
61function getCookie(name: string): string | null {
62 const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
63 return match ? decodeURIComponent(match[1]) : null;
64}
65
66export 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
169export function useAuth() {
170 return useContext(AuthContext);
171}
172