diff --git a/src/App.css b/src/App.css index 12dc5aa..46613f4 100644 --- a/src/App.css +++ b/src/App.css @@ -785,3 +785,29 @@ textarea { padding: 0.55rem 0.65rem; } } + +.app-loading-screen { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + color: var(--muted); +} + +.loading-spinner { + width: 36px; + height: 36px; + border: 3px solid rgba(0, 0, 0, 0.1); + border-top-color: var(--ink, #1f2a2b); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + diff --git a/src/App.jsx b/src/App.jsx index 16b5e2d..6675fa0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,12 +8,9 @@ import { LandingPage } from "./pages/LandingPage"; import { LoginPage } from "./pages/LoginPage"; import { ProfilePage } from "./pages/ProfilePage"; import { RegisterPage } from "./pages/RegisterPage"; +import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie"; import "./App.css"; -const TOKEN_KEY = "fc_session_token"; -const USER_ID_KEY = "fc_session_user_id"; -const EXPIRES_AT_KEY = "fc_session_expires_at"; - function hasExpired(expiresAt) { if (!expiresAt) { return false; @@ -28,30 +25,102 @@ function hasExpired(expiresAt) { } function App() { - const [session, setSession] = useState(() => { - const token = sessionStorage.getItem(TOKEN_KEY); - const userId = sessionStorage.getItem(USER_ID_KEY); - const expiresAt = sessionStorage.getItem(EXPIRES_AT_KEY); - const valid = token && userId && !hasExpired(expiresAt); - return { - token: valid ? token : null, - user: valid ? { id: userId } : null, - expiresAt: valid ? expiresAt : null, - }; + const [initializing, setInitializing] = useState(true); + const [session, setSession] = useState({ + token: null, + user: null, + expiresAt: null, }); - const [userProfile, setUserProfile] = useState(null); const isAuthenticated = Boolean(session.token); + // Restore session on initial visit using /auth/me or stored token fallback useEffect(() => { let active = true; - if (session.token) { + const { token, userId, expiresAt } = getAuthSession(); + + async function checkSession() { + // 1. Try server HttpOnly cookie session verification via /auth/me first + try { + const profile = await apiClient.authMe(token); + if (!active) return; + setSession({ + token: token || "active-session", + user: { id: profile?.id || userId }, + expiresAt, + }); + setUserProfile(profile); + setInitializing(false); + return; + } catch { + // Continue to fallback if /auth/me isn't authenticated + } + + // 2. Fallback to /profiles/me using stored token if available + if (token && !hasExpired(expiresAt)) { + try { + const profile = await apiClient.myProfile(token); + if (!active) return; + setSession({ + token, + user: { id: profile?.id || userId }, + expiresAt, + }); + setUserProfile(profile); + } catch { + if (!active) return; + clearAuthSession(); + setSession({ token: null, user: null, expiresAt: null }); + setUserProfile(null); + } + } else { + if (token) { + clearAuthSession(); + } + setSession({ token: null, user: null, expiresAt: null }); + setUserProfile(null); + } + + if (active) { + setInitializing(false); + } + } + + checkSession(); + + return () => { + active = false; + }; + }, []); + + const persistSession = useCallback((id, token, expiresAt) => { + if (token) { + saveAuthSession(id, token, expiresAt); + } + setSession({ token: token || "active-session", user: { id }, expiresAt }); + }, []); + + const logout = useCallback(async () => { + await apiClient.logoutApi(); + clearAuthSession(); + setSession({ token: null, user: null, expiresAt: null }); + setUserProfile(null); + }, []); + + // Fetch user profile whenever logged-in token changes (e.g. after fresh login) + useEffect(() => { + let active = true; + if (session.token && !userProfile) { apiClient - .myProfile(session.token) + .authMe(session.token) + .catch(() => apiClient.myProfile(session.token)) .then((profile) => { - if (active) { + if (active && profile) { setUserProfile(profile); + if (profile?.id && session.user?.id !== profile.id) { + setSession((prev) => ({ ...prev, user: { id: profile.id } })); + } } }) .catch(() => { @@ -59,31 +128,11 @@ function App() { setUserProfile(null); } }); - } else { - setUserProfile(null); } return () => { active = false; }; - }, [session.token]); - - const persistSession = useCallback((id, token, expiresAt) => { - sessionStorage.setItem(TOKEN_KEY, token); - sessionStorage.setItem(USER_ID_KEY, id); - sessionStorage.removeItem(EXPIRES_AT_KEY); - if (expiresAt) { - sessionStorage.setItem(EXPIRES_AT_KEY, expiresAt); - } - setSession({ token, user: { id }, expiresAt }); - }, []); - - const logout = useCallback(() => { - sessionStorage.removeItem(TOKEN_KEY); - sessionStorage.removeItem(USER_ID_KEY); - sessionStorage.removeItem(EXPIRES_AT_KEY); - setSession({ token: null, user: null, expiresAt: null }); - setUserProfile(null); - }, []); + }, [session.token, session.user?.id, userProfile]); useEffect(() => { if (session.token && hasExpired(session.expiresAt)) { @@ -100,6 +149,15 @@ function App() { onRegister: (data) => apiClient.register(data), }; + if (initializing) { + return ( +
+
+

Sessie controleren...

+
+ ); + } + if (!isAuthenticated) { return ( diff --git a/src/api/client.js b/src/api/client.js index 7517b4f..1e8e9b6 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -2,9 +2,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; async function request(path, { method = "GET", token, body, formData } = {}) { if (!API_BASE_URL) { - throw new Error( - "VITE_API_BASE_URL ontbreekt.", - ); + throw new Error("VITE_API_BASE_URL ontbreekt."); } const headers = {}; @@ -22,6 +20,7 @@ async function request(path, { method = "GET", token, body, formData } = {}) { response = await fetch(`${API_BASE_URL}${path}`, { method, headers, + credentials: "include", body: formData || (body ? JSON.stringify(body) : undefined), }); } catch { @@ -48,6 +47,9 @@ async function request(path, { method = "GET", token, body, formData } = {}) { export const apiClient = { login: (credentials) => request("/auth/login", { method: "POST", body: credentials }), register: (data) => request("/auth/register", { method: "POST", body: data }), + authMe: (token) => request("/auth/me", { token }), + authVerify: (token) => request("/auth/verify", { token }), + logoutApi: () => request("/auth/logout", { method: "POST" }).catch(() => ({})), myProfile: (token) => request("/profiles/me", { token }), getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }), posts: (token) => request("/posts", { token }), @@ -84,8 +86,13 @@ export const apiClient = { token, }), postImage: async (token, filename) => { + const headers = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } const response = await fetch(`${API_BASE_URL}/posts/image/${encodeURIComponent(filename)}`, { - headers: { Authorization: `Bearer ${token}` }, + headers, + credentials: "include", }); if (!response.ok) { throw new Error("Unable to load image."); diff --git a/src/utils/cookie.js b/src/utils/cookie.js new file mode 100644 index 0000000..1906ec9 --- /dev/null +++ b/src/utils/cookie.js @@ -0,0 +1,144 @@ +const TOKEN_KEY = "fc_session_token"; +const USER_ID_KEY = "fc_session_user_id"; +const EXPIRES_AT_KEY = "fc_session_expires_at"; + +/** + * Set a cookie with secure defaults + */ +export function setCookie(name, value, options = {}) { + const { + expires, + days = 7, + path = "/", + sameSite = "Strict", + secure = true, + } = options; + + let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; + + if (expires) { + const expDate = expires instanceof Date ? expires : new Date(expires); + if (!Number.isNaN(expDate.getTime())) { + cookieString += `; expires=${expDate.toUTCString()}`; + } + } else if (days) { + const expDate = new Date(); + expDate.setDate(expDate.getDate() + days); + cookieString += `; expires=${expDate.toUTCString()}`; + } + + if (path) { + cookieString += `; path=${path}`; + } + + if (sameSite) { + cookieString += `; SameSite=${sameSite}`; + } + + if (secure) { + cookieString += "; Secure"; + } + + document.cookie = cookieString; +} + +/** + * Read a cookie by name + */ +export function getCookie(name) { + const nameEQ = `${encodeURIComponent(name)}=`; + const ca = document.cookie.split(";"); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === " ") { + c = c.substring(1, c.length); + } + if (c.indexOf(nameEQ) === 0) { + return decodeURIComponent(c.substring(nameEQ.length, c.length)); + } + } + return null; +} + +/** + * Remove a cookie by name + */ +export function removeCookie(name, path = "/") { + document.cookie = `${encodeURIComponent(name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=${path}; SameSite=Strict; Secure`; +} + +/** + * Save auth session into secure cookies + */ +export function saveAuthSession(id, token, expiresAt) { + let expiresDate; + if (expiresAt) { + const numericValue = Number(expiresAt); + const timestamp = Number.isNaN(numericValue) + ? Date.parse(expiresAt) + : numericValue < 100000000000 + ? numericValue * 1000 + : numericValue; + if (Number.isFinite(timestamp)) { + expiresDate = new Date(timestamp); + } + } + + const cookieOptions = { expires: expiresDate, days: expiresDate ? undefined : 7 }; + + setCookie(TOKEN_KEY, token, cookieOptions); + if (id) { + setCookie(USER_ID_KEY, String(id), cookieOptions); + } + if (expiresAt) { + setCookie(EXPIRES_AT_KEY, String(expiresAt), cookieOptions); + } + + // Clear legacy sessionStorage if present + try { + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(USER_ID_KEY); + sessionStorage.removeItem(EXPIRES_AT_KEY); + } catch { + // ignore + } +} + +/** + * Get stored auth session from cookies + */ +export function getAuthSession() { + // First try cookies + let token = getCookie(TOKEN_KEY); + let userId = getCookie(USER_ID_KEY); + let expiresAt = getCookie(EXPIRES_AT_KEY); + + // Fallback to sessionStorage for backward compatibility if cookie not set yet + if (!token) { + try { + token = sessionStorage.getItem(TOKEN_KEY); + userId = sessionStorage.getItem(USER_ID_KEY); + expiresAt = sessionStorage.getItem(EXPIRES_AT_KEY); + } catch { + // ignore + } + } + + return { token, userId, expiresAt }; +} + +/** + * Clear stored auth session from cookies and sessionStorage + */ +export function clearAuthSession() { + removeCookie(TOKEN_KEY); + removeCookie(USER_ID_KEY); + removeCookie(EXPIRES_AT_KEY); + try { + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(USER_ID_KEY); + sessionStorage.removeItem(EXPIRES_AT_KEY); + } catch { + // ignore + } +}