Added
This commit is contained in:
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+97
-39
@@ -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 (
|
||||
<div className="app-loading-screen">
|
||||
<div className="loading-spinner" />
|
||||
<p>Sessie controleren...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
|
||||
+11
-4
@@ -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.");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user