| @@ -15,6 +15,79 @@ |
| 15 | 15 | : null; |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | // ── Token refresh infrastructure ────────────────────────────────── |
| 19 | |
| 20 | let refreshInFlight: Promise<string | null> | null = null; |
| 21 | |
| 22 | /** Write token + user to localStorage and cross-subdomain cookies. */ |
| 23 | function persistToken(token: string, user: { id: number; username: string; display_name: string }) { |
| 24 | localStorage.setItem("grove_hub_token", token); |
| 25 | localStorage.setItem("grove_hub_user", JSON.stringify(user)); |
| 26 | |
| 27 | const hostname = window.location.hostname; |
| 28 | const secure = window.location.protocol === "https:" ? "; Secure" : ""; |
| 29 | const maxAge = 60 * 60 * 24 * 30; |
| 30 | |
| 31 | const domains: string[] = []; |
| 32 | if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { |
| 33 | // no domain cookies for IPs |
| 34 | } else if (hostname === "localhost" || hostname.endsWith(".localhost")) { |
| 35 | domains.push("localhost", ".localhost"); |
| 36 | } else { |
| 37 | const parts = hostname.split("."); |
| 38 | if (parts.length <= 2) domains.push(`.${hostname}`); |
| 39 | else domains.push(`.${parts.slice(-2).join(".")}`); |
| 40 | } |
| 41 | |
| 42 | const base = (name: string, val: string) => |
| 43 | `${name}=${encodeURIComponent(val)}; path=/; SameSite=Lax; max-age=${maxAge}${secure}`; |
| 44 | |
| 45 | document.cookie = base("grove_hub_token", token); |
| 46 | document.cookie = base("grove_hub_user", JSON.stringify(user)); |
| 47 | for (const domain of domains) { |
| 48 | document.cookie = `${base("grove_hub_token", token)}; domain=${domain}`; |
| 49 | document.cookie = `${base("grove_hub_user", JSON.stringify(user))}; domain=${domain}`; |
| 50 | } |
| 51 | |
| 52 | window.dispatchEvent(new CustomEvent("grove:token-refreshed", { detail: { token, user } })); |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Refresh the current session token. |
| 57 | * Deduplicates concurrent calls so only one /auth/refresh request is in-flight. |
| 58 | */ |
| 59 | export function refreshToken(): Promise<string | null> { |
| 60 | if (refreshInFlight) return refreshInFlight; |
| 61 | |
| 62 | refreshInFlight = (async () => { |
| 63 | const currentToken = getToken(); |
| 64 | if (!currentToken) return null; |
| 65 | |
| 66 | try { |
| 67 | const res = await fetch(`${API_BASE}/auth/refresh`, { |
| 68 | method: "POST", |
| 69 | headers: { Authorization: `Bearer ${currentToken}` }, |
| 70 | }); |
| 71 | |
| 72 | if (!res.ok) return null; |
| 73 | |
| 74 | const data = await res.json(); |
| 75 | if (!data.token) return null; |
| 76 | |
| 77 | persistToken(data.token, data.user); |
| 78 | return data.token as string; |
| 79 | } catch { |
| 80 | return null; |
| 81 | } finally { |
| 82 | refreshInFlight = null; |
| 83 | } |
| 84 | })(); |
| 85 | |
| 86 | return refreshInFlight; |
| 87 | } |
| 88 | |
| 89 | // ── Core fetch ──────────────────────────────────────────────────── |
| 90 | |
| 18 | 91 | async function fetchApi<T>( |
| 19 | 92 | base: string, |
| 20 | 93 | path: string, |
| @@ -38,6 +111,35 @@ |
| 38 | 111 | }); |
| 39 | 112 | |
| 40 | 113 | if (!res.ok) { |
| 114 | // On 401, attempt a single token refresh and retry |
| 115 | if (res.status === 401 && token && !path.startsWith("/auth/")) { |
| 116 | const newToken = await refreshToken(); |
| 117 | if (newToken) { |
| 118 | const retryHeaders: Record<string, string> = { |
| 119 | Authorization: `Bearer ${newToken}`, |
| 120 | }; |
| 121 | if (options.body) { |
| 122 | retryHeaders["Content-Type"] = "application/json"; |
| 123 | } |
| 124 | |
| 125 | const retryRes = await fetch(`${base}${path}`, { |
| 126 | ...options, |
| 127 | headers: { |
| 128 | ...retryHeaders, |
| 129 | ...options.headers, |
| 130 | }, |
| 131 | }); |
| 132 | |
| 133 | if (!retryRes.ok) { |
| 134 | const body = await retryRes.json().catch(() => ({})); |
| 135 | throw new ApiError(retryRes.status, body.error ?? "Request failed"); |
| 136 | } |
| 137 | |
| 138 | if (retryRes.status === 204) return null as T; |
| 139 | return retryRes.json(); |
| 140 | } |
| 141 | } |
| 142 | |
| 41 | 143 | const body = await res.json().catch(() => ({})); |
| 42 | 144 | throw new ApiError(res.status, body.error ?? "Request failed"); |
| 43 | 145 | } |
| 44 | 146 | |