This commit is contained in:
Sven laptop
2026-07-27 21:53:16 +02:00
parent beb8bb287e
commit a1b6226fbe
4 changed files with 278 additions and 43 deletions
+26
View File
@@ -785,3 +785,29 @@ textarea {
padding: 0.55rem 0.65rem; 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);
}
}
+97 -39
View File
@@ -8,12 +8,9 @@ import { LandingPage } from "./pages/LandingPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { ProfilePage } from "./pages/ProfilePage"; import { ProfilePage } from "./pages/ProfilePage";
import { RegisterPage } from "./pages/RegisterPage"; import { RegisterPage } from "./pages/RegisterPage";
import { clearAuthSession, getAuthSession, saveAuthSession } from "./utils/cookie";
import "./App.css"; 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) { function hasExpired(expiresAt) {
if (!expiresAt) { if (!expiresAt) {
return false; return false;
@@ -28,30 +25,102 @@ function hasExpired(expiresAt) {
} }
function App() { function App() {
const [session, setSession] = useState(() => { const [initializing, setInitializing] = useState(true);
const token = sessionStorage.getItem(TOKEN_KEY); const [session, setSession] = useState({
const userId = sessionStorage.getItem(USER_ID_KEY); token: null,
const expiresAt = sessionStorage.getItem(EXPIRES_AT_KEY); user: null,
const valid = token && userId && !hasExpired(expiresAt); expiresAt: null,
return {
token: valid ? token : null,
user: valid ? { id: userId } : null,
expiresAt: valid ? expiresAt : null,
};
}); });
const [userProfile, setUserProfile] = useState(null); const [userProfile, setUserProfile] = useState(null);
const isAuthenticated = Boolean(session.token); const isAuthenticated = Boolean(session.token);
// Restore session on initial visit using /auth/me or stored token fallback
useEffect(() => { useEffect(() => {
let active = true; 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 apiClient
.myProfile(session.token) .authMe(session.token)
.catch(() => apiClient.myProfile(session.token))
.then((profile) => { .then((profile) => {
if (active) { if (active && profile) {
setUserProfile(profile); setUserProfile(profile);
if (profile?.id && session.user?.id !== profile.id) {
setSession((prev) => ({ ...prev, user: { id: profile.id } }));
}
} }
}) })
.catch(() => { .catch(() => {
@@ -59,31 +128,11 @@ function App() {
setUserProfile(null); setUserProfile(null);
} }
}); });
} else {
setUserProfile(null);
} }
return () => { return () => {
active = false; active = false;
}; };
}, [session.token]); }, [session.token, session.user?.id, userProfile]);
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);
}, []);
useEffect(() => { useEffect(() => {
if (session.token && hasExpired(session.expiresAt)) { if (session.token && hasExpired(session.expiresAt)) {
@@ -100,6 +149,15 @@ function App() {
onRegister: (data) => apiClient.register(data), onRegister: (data) => apiClient.register(data),
}; };
if (initializing) {
return (
<div className="app-loading-screen">
<div className="loading-spinner" />
<p>Sessie controleren...</p>
</div>
);
}
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<Routes> <Routes>
+11 -4
View File
@@ -2,9 +2,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
async function request(path, { method = "GET", token, body, formData } = {}) { async function request(path, { method = "GET", token, body, formData } = {}) {
if (!API_BASE_URL) { if (!API_BASE_URL) {
throw new Error( throw new Error("VITE_API_BASE_URL ontbreekt.");
"VITE_API_BASE_URL ontbreekt.",
);
} }
const headers = {}; const headers = {};
@@ -22,6 +20,7 @@ async function request(path, { method = "GET", token, body, formData } = {}) {
response = await fetch(`${API_BASE_URL}${path}`, { response = await fetch(`${API_BASE_URL}${path}`, {
method, method,
headers, headers,
credentials: "include",
body: formData || (body ? JSON.stringify(body) : undefined), body: formData || (body ? JSON.stringify(body) : undefined),
}); });
} catch { } catch {
@@ -48,6 +47,9 @@ async function request(path, { method = "GET", token, body, formData } = {}) {
export const apiClient = { export const apiClient = {
login: (credentials) => request("/auth/login", { method: "POST", body: credentials }), login: (credentials) => request("/auth/login", { method: "POST", body: credentials }),
register: (data) => request("/auth/register", { method: "POST", body: data }), 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 }), myProfile: (token) => request("/profiles/me", { token }),
getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }), getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }),
posts: (token) => request("/posts", { token }), posts: (token) => request("/posts", { token }),
@@ -84,8 +86,13 @@ export const apiClient = {
token, token,
}), }),
postImage: async (token, filename) => { postImage: async (token, filename) => {
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/posts/image/${encodeURIComponent(filename)}`, { const response = await fetch(`${API_BASE_URL}/posts/image/${encodeURIComponent(filename)}`, {
headers: { Authorization: `Bearer ${token}` }, headers,
credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Unable to load image."); throw new Error("Unable to load image.");
+144
View File
@@ -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
}
}