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
+ }
+}