diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 717fa95..883d3a6 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -1,343 +1,244 @@ -import React, { createContext, useContext, useEffect, useRef, useState } from "react"; +import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage"; -const AuthContext = createContext(null); -const API_BASE = "/api"; +const AuthCtx = createContext(null); -const TOKEN_KEY = "sociowire:access_token"; -const REFRESH_KEY = "sociowire:refresh_token"; -const USERNAME_KEY = "sociowire:username"; - -function decodeJwtPayload(token) { - try { - const parts = String(token || "").split("."); - if (parts.length < 2) return null; - const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")); - return JSON.parse(json); - } catch { - return null; - } +function apiBase() { + // même domaine par défaut + return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); } -function willExpireSoon(token, withinSeconds = 90) { - const p = decodeJwtPayload(token); - if (!p || !p.exp) return true; - const now = Math.floor(Date.now() / 1000); - return p.exp - now <= withinSeconds; +async function apiFetch(path, opts = {}) { + const url = apiBase() + path; + const res = await fetch(url, { + ...opts, + headers: { + "Content-Type": "application/json", + ...(opts.headers || {}), + }, + }); + return res; } -async function apiRefresh(refreshToken) { - const res = await fetch(`${API_BASE}/refresh`, { +async function whoami(accessToken) { + const res = await apiFetch("/api/whoami", { + method: "GET", + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, + }); + const j = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, json: j }; +} + +async function refreshTokens(refreshToken) { + const res = await apiFetch("/api/refresh", { method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", body: JSON.stringify({ refresh_token: refreshToken }), }); - - const text = await res.text().catch(() => ""); - if (!res.ok) throw new Error(`${res.status} ${text}`); - - const data = JSON.parse(text || "{}"); - if (!data.access_token) throw new Error("No access_token in refresh response"); - - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || refreshToken, - }; + const j = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, json: j }; } -async function apiMe(accessToken) { - const res = await fetch(`${API_BASE}/me`, { - method: "GET", - headers: { Authorization: `Bearer ${accessToken}` }, - credentials: "include", - }); - - const text = await res.text().catch(() => ""); - if (!res.ok) throw new Error(`${res.status} ${text || "me failed"}`); - - const data = JSON.parse(text || "{}"); - return data; -} - -async function apiResendVerify(accessToken) { - const res = await fetch(`${API_BASE}/resend-verification`, { - method: "POST", - headers: { Authorization: `Bearer ${accessToken}` }, - credentials: "include", - }); - - const text = await res.text().catch(() => ""); - if (!res.ok) throw new Error(`${res.status} ${text || "resend failed"}`); - - return text ? JSON.parse(text) : { ok: true }; +function nowSec() { + return Math.floor(Date.now() / 1000); } export function AuthProvider({ children }) { - const [booting, setBooting] = useState(true); - const [loading, setLoading] = useState(false); + const [status, setStatus] = useState("loading"); // loading | anon | auth + const [user, setUser] = useState(null); // { username } + const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } + const [error, setError] = useState(""); + const refreshTimer = useRef(null); - const [authenticated, setAuthenticated] = useState(false); - const [username, setUsername] = useState(""); - const [token, setToken] = useState(null); - - const [emailVerified, setEmailVerified] = useState(true); - const [needsEmailVerification, setNeedsEmailVerification] = useState(false); - - const [lastError, setLastError] = useState(""); - const [lastInfo, setLastInfo] = useState(""); - - const usernameRef = useRef(""); - - function persistAuth({ accessToken, refreshToken, user }) { - setToken(accessToken); - setAuthenticated(true); - setUsername(user || ""); - usernameRef.current = user || ""; - - try { - localStorage.setItem(TOKEN_KEY, accessToken); - if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken); - localStorage.setItem(USERNAME_KEY, user || ""); - } catch {} + function setAnon(msg = "") { + setStatus("anon"); + setUser(null); + setTokens(null); + setError(msg || ""); + clearAuth(); } - function clearAuth(msg = "") { - setAuthenticated(false); - setToken(null); - setUsername(""); - usernameRef.current = ""; - - setEmailVerified(true); - setNeedsEmailVerification(false); - - setLastError(msg || ""); - try { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_KEY); - localStorage.removeItem(USERNAME_KEY); - } catch {} + function setAuthFromTokens(t, usernameGuess = "") { + setTokens(t); + saveAuth({ ...t, obtained_at: Date.now() }); + const u = usernameGuess || guessUsernameFromToken(t?.access_token) || ""; + setUser(u ? { username: u } : null); + setStatus("auth"); + setError(""); } - async function syncWithBackend(accessToken, fallbackUser = "") { - // Confirme que le backend accepte vraiment le token - const me = await apiMe(accessToken); + async function ensureFreshSession(existing) { + if (!existing?.access_token) return { ok: false, reason: "no_access_token" }; - const u = - me.username || - me.preferred_username || - me.user || - fallbackUser || - localStorage.getItem(USERNAME_KEY) || - ""; + const exp = jwtExpSeconds(existing.access_token); + const skew = 60; // seconds + const isExpiredOrSoon = !exp || exp <= (nowSec() + skew); - const verified = me.email_verified !== false; // default true si absent - setEmailVerified(verified); - setNeedsEmailVerification(!verified); + // 1) refresh si nécessaire + if (isExpiredOrSoon) { + if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" }; - setAuthenticated(true); - setToken(accessToken); - setUsername(u); - usernameRef.current = u; + const rr = await refreshTokens(existing.refresh_token); + if (!rr.ok || !rr.json?.access_token) { + return { ok: false, reason: `refresh_failed_${rr.status}` }; + } - // garde username en storage pour UI - try { localStorage.setItem(USERNAME_KEY, u); } catch {} + const merged = { + ...existing, + ...rr.json, + }; - if (!verified) { - setLastInfo("Check your inbox and verify your email to unlock posting."); + // certains serveurs renvoient pas refresh_token à chaque fois + if (!merged.refresh_token) merged.refresh_token = existing.refresh_token; + + setAuthFromTokens(merged); + existing = merged; } else { - setLastInfo(""); + // access token encore bon + setAuthFromTokens(existing); } - return { me, verified }; + // 2) confirmer avec backend (whoami) + const w = await whoami(existing.access_token); + if (w.ok && w.json?.authenticated) { + const uname = w.json.username || guessUsernameFromToken(existing.access_token) || ""; + setUser(uname ? { username: uname } : null); + setStatus("auth"); + setError(""); + return { ok: true }; + } + + // Si whoami fail, on considère désync (token invalide pour backend) + return { ok: false, reason: `whoami_${w.status}` }; } - async function bootRestore() { - const storedToken = (() => { - try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } - })(); - const storedRefresh = (() => { - try { return localStorage.getItem(REFRESH_KEY); } catch { return null; } - })(); - const storedUser = (() => { - try { return localStorage.getItem(USERNAME_KEY) || ""; } catch { return ""; } - })(); - - if (!storedToken) return; - - // 1) essaie /me direct - try { - await syncWithBackend(storedToken, storedUser); - return; - } catch (e) { - // 2) si /me refuse, tente refresh si possible - const msg = String(e?.message || ""); - const is401 = msg.startsWith("401") || msg.includes(" 401 "); - if (!is401 || !storedRefresh) { - clearAuth("Session expired. Please login again."); - return; - } + function scheduleAutoRefresh(t) { + if (refreshTimer.current) { + clearInterval(refreshTimer.current); + refreshTimer.current = null; } + // check aux 30s, refresh quand exp < 90s + refreshTimer.current = setInterval(async () => { + const cur = loadAuth(); + if (!cur?.access_token) return; - try { - const { accessToken, refreshToken } = await apiRefresh(storedRefresh); - persistAuth({ accessToken, refreshToken, user: storedUser }); - await syncWithBackend(accessToken, storedUser); - } catch (e) { - console.warn("[AUTH] restore refresh failed:", e); - clearAuth("Session expired. Please login again."); - } - } + const exp = jwtExpSeconds(cur.access_token); + if (!exp) return; - useEffect(() => { - (async () => { - try { - await bootRestore(); - } finally { - setBooting(false); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Auto refresh (et resync backend) - useEffect(() => { - if (!authenticated) return; - - const interval = setInterval(async () => { - try { - const t = (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })(); - const rt = (() => { try { return localStorage.getItem(REFRESH_KEY); } catch { return null; } })(); - if (!t || !rt) return; - - if (!willExpireSoon(t, 90)) { - // même si pas proche d’expirer, on peut vérifier /me 1 fois de temps en temps + if (exp <= nowSec() + 90) { + if (!cur.refresh_token) { + setAnon("Session expirée (pas de refresh_token)."); return; } - - const { accessToken, refreshToken } = await apiRefresh(rt); - const u = (() => { - try { return localStorage.getItem(USERNAME_KEY) || usernameRef.current || ""; } catch { return usernameRef.current || ""; } - })(); - - persistAuth({ accessToken, refreshToken, user: u }); - await syncWithBackend(accessToken, u); - } catch (e) { - console.warn("[AUTH] auto refresh failed:", e); - clearAuth("Session expired. Please login again."); + const rr = await refreshTokens(cur.refresh_token); + if (!rr.ok || !rr.json?.access_token) { + setAnon("Session expirée. Please login again."); + return; + } + const merged = { ...cur, ...rr.json }; + if (!merged.refresh_token) merged.refresh_token = cur.refresh_token; + setAuthFromTokens(merged); } - }, 60_000); + }, 30000); + } - return () => clearInterval(interval); - }, [authenticated]); - - async function login(user, pass) { - setLastError(""); - setLastInfo(""); - const u = (user || "").trim(); - if (!u || !pass) { - setLastError("Username and password required."); + async function restore() { + setStatus("loading"); + setError(""); + const saved = loadAuth(); + if (!saved?.access_token) { + setAnon(""); return; } - try { - setLoading(true); - - const res = await fetch(`${API_BASE}/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ username: u, password: pass }), - }); - - const text = await res.text().catch(() => ""); - if (!res.ok) { - // backend peut renvoyer un JSON {error:"email_not_verified"} - try { - const obj = JSON.parse(text || "{}"); - if (res.status === 403 && obj?.error === "email_not_verified") { - setLastError("Email not verified. Check your inbox (spam) and verify, then login again."); - return; - } - } catch {} - setLastError(`${res.status} login failed`); - return; - } - - const data = JSON.parse(text || "{}"); - const accessToken = data.access_token; - const refreshToken = data.refresh_token; - - if (!accessToken) { - setLastError("No access token in response."); - return; - } - - persistAuth({ accessToken, refreshToken, user: u }); - - // IMPORTANT: resync with backend right after login - try { - await syncWithBackend(accessToken, u); - } catch (e) { - console.warn("[AUTH] /me failed after login:", e); - clearAuth("Login desynced. Please login again."); - return; - } - - setLastError(""); - } catch (e) { - console.error("[AUTH] network error", e); - clearAuth("Network error during login."); - setLastError("Network error during login."); - } finally { - setLoading(false); + const r = await ensureFreshSession(saved); + if (!r.ok) { + setAnon("Login desynced. Please login again."); + return; } + + scheduleAutoRefresh(saved); + } + + async function login(username, password) { + setError(""); + setStatus("loading"); + + const res = await apiFetch("/api/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + + const j = await res.json().catch(() => null); + if (!res.ok || !j?.access_token) { + setAnon(j?.error_description || j?.error || "Login failed"); + return { ok: false, status: res.status, json: j }; + } + + const t = { + ...j, + obtained_at: Date.now(), + }; + + setAuthFromTokens(t); + scheduleAutoRefresh(t); + + // confirm whoami (si ça fail -> desync) + const w = await whoami(t.access_token); + if (!w.ok || !w.json?.authenticated) { + setAnon("Login desynced. Please login again."); + return { ok: false, status: w.status, json: w.json }; + } + + const uname = w.json.username || guessUsernameFromToken(t.access_token) || ""; + setUser(uname ? { username: uname } : null); + setStatus("auth"); + return { ok: true }; } function logout() { - clearAuth(""); + if (refreshTimer.current) clearInterval(refreshTimer.current); + refreshTimer.current = null; + setAnon(""); } - async function resendVerificationEmail() { - setLastError(""); - setLastInfo(""); - const t = token || (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })(); - if (!t) { - setLastError("Not logged in."); - return; - } - try { - await apiResendVerify(t); - setLastInfo("Verification email sent. Check inbox/spam."); - } catch (e) { - console.error(e); - setLastError("Could not resend verification email."); - } - } + useEffect(() => { + restore(); - return ( - - {children} - - ); + const onStorage = (e) => { + if (e.key === "sociowire:auth") restore(); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (status === "auth" && tokens?.access_token) { + scheduleAutoRefresh(tokens); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]); + + const value = useMemo(() => { + return { + status, + user, + tokens, + error, + + isAuthed: status === "auth", + accessToken: tokens?.access_token || "", + + login, + logout, + restore, + setError, + }; + }, [status, user, tokens, error]); + + return {children}; } export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be used inside "); - return ctx; + return useContext(AuthCtx); } diff --git a/src/auth/AuthContext.jsx.bak.1766173632 b/src/auth/AuthContext.jsx.bak.1766173632 new file mode 100644 index 0000000..717fa95 --- /dev/null +++ b/src/auth/AuthContext.jsx.bak.1766173632 @@ -0,0 +1,343 @@ +import React, { createContext, useContext, useEffect, useRef, useState } from "react"; + +const AuthContext = createContext(null); +const API_BASE = "/api"; + +const TOKEN_KEY = "sociowire:access_token"; +const REFRESH_KEY = "sociowire:refresh_token"; +const USERNAME_KEY = "sociowire:username"; + +function decodeJwtPayload(token) { + try { + const parts = String(token || "").split("."); + if (parts.length < 2) return null; + const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json); + } catch { + return null; + } +} + +function willExpireSoon(token, withinSeconds = 90) { + const p = decodeJwtPayload(token); + if (!p || !p.exp) return true; + const now = Math.floor(Date.now() / 1000); + return p.exp - now <= withinSeconds; +} + +async function apiRefresh(refreshToken) { + const res = await fetch(`${API_BASE}/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + const text = await res.text().catch(() => ""); + if (!res.ok) throw new Error(`${res.status} ${text}`); + + const data = JSON.parse(text || "{}"); + if (!data.access_token) throw new Error("No access_token in refresh response"); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || refreshToken, + }; +} + +async function apiMe(accessToken) { + const res = await fetch(`${API_BASE}/me`, { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + credentials: "include", + }); + + const text = await res.text().catch(() => ""); + if (!res.ok) throw new Error(`${res.status} ${text || "me failed"}`); + + const data = JSON.parse(text || "{}"); + return data; +} + +async function apiResendVerify(accessToken) { + const res = await fetch(`${API_BASE}/resend-verification`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + credentials: "include", + }); + + const text = await res.text().catch(() => ""); + if (!res.ok) throw new Error(`${res.status} ${text || "resend failed"}`); + + return text ? JSON.parse(text) : { ok: true }; +} + +export function AuthProvider({ children }) { + const [booting, setBooting] = useState(true); + const [loading, setLoading] = useState(false); + + const [authenticated, setAuthenticated] = useState(false); + const [username, setUsername] = useState(""); + const [token, setToken] = useState(null); + + const [emailVerified, setEmailVerified] = useState(true); + const [needsEmailVerification, setNeedsEmailVerification] = useState(false); + + const [lastError, setLastError] = useState(""); + const [lastInfo, setLastInfo] = useState(""); + + const usernameRef = useRef(""); + + function persistAuth({ accessToken, refreshToken, user }) { + setToken(accessToken); + setAuthenticated(true); + setUsername(user || ""); + usernameRef.current = user || ""; + + try { + localStorage.setItem(TOKEN_KEY, accessToken); + if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken); + localStorage.setItem(USERNAME_KEY, user || ""); + } catch {} + } + + function clearAuth(msg = "") { + setAuthenticated(false); + setToken(null); + setUsername(""); + usernameRef.current = ""; + + setEmailVerified(true); + setNeedsEmailVerification(false); + + setLastError(msg || ""); + try { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem(USERNAME_KEY); + } catch {} + } + + async function syncWithBackend(accessToken, fallbackUser = "") { + // Confirme que le backend accepte vraiment le token + const me = await apiMe(accessToken); + + const u = + me.username || + me.preferred_username || + me.user || + fallbackUser || + localStorage.getItem(USERNAME_KEY) || + ""; + + const verified = me.email_verified !== false; // default true si absent + setEmailVerified(verified); + setNeedsEmailVerification(!verified); + + setAuthenticated(true); + setToken(accessToken); + setUsername(u); + usernameRef.current = u; + + // garde username en storage pour UI + try { localStorage.setItem(USERNAME_KEY, u); } catch {} + + if (!verified) { + setLastInfo("Check your inbox and verify your email to unlock posting."); + } else { + setLastInfo(""); + } + + return { me, verified }; + } + + async function bootRestore() { + const storedToken = (() => { + try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } + })(); + const storedRefresh = (() => { + try { return localStorage.getItem(REFRESH_KEY); } catch { return null; } + })(); + const storedUser = (() => { + try { return localStorage.getItem(USERNAME_KEY) || ""; } catch { return ""; } + })(); + + if (!storedToken) return; + + // 1) essaie /me direct + try { + await syncWithBackend(storedToken, storedUser); + return; + } catch (e) { + // 2) si /me refuse, tente refresh si possible + const msg = String(e?.message || ""); + const is401 = msg.startsWith("401") || msg.includes(" 401 "); + if (!is401 || !storedRefresh) { + clearAuth("Session expired. Please login again."); + return; + } + } + + try { + const { accessToken, refreshToken } = await apiRefresh(storedRefresh); + persistAuth({ accessToken, refreshToken, user: storedUser }); + await syncWithBackend(accessToken, storedUser); + } catch (e) { + console.warn("[AUTH] restore refresh failed:", e); + clearAuth("Session expired. Please login again."); + } + } + + useEffect(() => { + (async () => { + try { + await bootRestore(); + } finally { + setBooting(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Auto refresh (et resync backend) + useEffect(() => { + if (!authenticated) return; + + const interval = setInterval(async () => { + try { + const t = (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })(); + const rt = (() => { try { return localStorage.getItem(REFRESH_KEY); } catch { return null; } })(); + if (!t || !rt) return; + + if (!willExpireSoon(t, 90)) { + // même si pas proche d’expirer, on peut vérifier /me 1 fois de temps en temps + return; + } + + const { accessToken, refreshToken } = await apiRefresh(rt); + const u = (() => { + try { return localStorage.getItem(USERNAME_KEY) || usernameRef.current || ""; } catch { return usernameRef.current || ""; } + })(); + + persistAuth({ accessToken, refreshToken, user: u }); + await syncWithBackend(accessToken, u); + } catch (e) { + console.warn("[AUTH] auto refresh failed:", e); + clearAuth("Session expired. Please login again."); + } + }, 60_000); + + return () => clearInterval(interval); + }, [authenticated]); + + async function login(user, pass) { + setLastError(""); + setLastInfo(""); + const u = (user || "").trim(); + if (!u || !pass) { + setLastError("Username and password required."); + return; + } + + try { + setLoading(true); + + const res = await fetch(`${API_BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ username: u, password: pass }), + }); + + const text = await res.text().catch(() => ""); + if (!res.ok) { + // backend peut renvoyer un JSON {error:"email_not_verified"} + try { + const obj = JSON.parse(text || "{}"); + if (res.status === 403 && obj?.error === "email_not_verified") { + setLastError("Email not verified. Check your inbox (spam) and verify, then login again."); + return; + } + } catch {} + setLastError(`${res.status} login failed`); + return; + } + + const data = JSON.parse(text || "{}"); + const accessToken = data.access_token; + const refreshToken = data.refresh_token; + + if (!accessToken) { + setLastError("No access token in response."); + return; + } + + persistAuth({ accessToken, refreshToken, user: u }); + + // IMPORTANT: resync with backend right after login + try { + await syncWithBackend(accessToken, u); + } catch (e) { + console.warn("[AUTH] /me failed after login:", e); + clearAuth("Login desynced. Please login again."); + return; + } + + setLastError(""); + } catch (e) { + console.error("[AUTH] network error", e); + clearAuth("Network error during login."); + setLastError("Network error during login."); + } finally { + setLoading(false); + } + } + + function logout() { + clearAuth(""); + } + + async function resendVerificationEmail() { + setLastError(""); + setLastInfo(""); + const t = token || (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })(); + if (!t) { + setLastError("Not logged in."); + return; + } + try { + await apiResendVerify(t); + setLastInfo("Verification email sent. Check inbox/spam."); + } catch (e) { + console.error(e); + setLastError("Could not resend verification email."); + } + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside "); + return ctx; +} diff --git a/src/auth/authStorage.js b/src/auth/authStorage.js new file mode 100644 index 0000000..6f0af7e --- /dev/null +++ b/src/auth/authStorage.js @@ -0,0 +1,57 @@ +const KEY = "sociowire:auth"; + +export function loadAuth() { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return null; + const v = JSON.parse(raw); + if (!v || typeof v !== "object") return null; + return v; + } catch { + return null; + } +} + +export function saveAuth(v) { + try { + localStorage.setItem(KEY, JSON.stringify(v || {})); + } catch {} +} + +export function clearAuth() { + try { + localStorage.removeItem(KEY); + } catch {} +} + +export function decodeJwtPayload(token) { + try { + if (!token || typeof token !== "string") return null; + const parts = token.split("."); + if (parts.length < 2) return null; + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const pad = b64.length % 4 ? "=".repeat(4 - (b64.length % 4)) : ""; + const json = atob(b64 + pad); + return JSON.parse(json); + } catch { + return null; + } +} + +export function jwtExpSeconds(token) { + const p = decodeJwtPayload(token); + if (!p || !p.exp) return 0; + const exp = Number(p.exp); + return Number.isFinite(exp) ? exp : 0; +} + +export function guessUsernameFromToken(token) { + const p = decodeJwtPayload(token); + if (!p) return ""; + return ( + (typeof p.preferred_username === "string" && p.preferred_username) || + (typeof p.email === "string" && p.email) || + (typeof p.sub === "string" && p.sub) || + "" + ); +}