Added
This commit is contained in:
+26
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-41
@@ -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();
|
||||||
apiClient
|
|
||||||
.myProfile(session.token)
|
async function checkSession() {
|
||||||
.then((profile) => {
|
// 1. Try server HttpOnly cookie session verification via /auth/me first
|
||||||
if (active) {
|
try {
|
||||||
|
const profile = await apiClient.authMe(token);
|
||||||
|
if (!active) return;
|
||||||
|
setSession({
|
||||||
|
token: token || "active-session",
|
||||||
|
user: { id: profile?.id || userId },
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
setUserProfile(profile);
|
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
|
||||||
|
.authMe(session.token)
|
||||||
|
.catch(() => apiClient.myProfile(session.token))
|
||||||
|
.then((profile) => {
|
||||||
|
if (active && 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
@@ -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.");
|
||||||
|
|||||||
@@ -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