feat: PKCE auth + CI/CD deploy
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s

- Frontend: PKCE flow (oauth.js, api.js centralized, cookie-based AuthContext)
- Backend: token introspection, cookies httpOnly, refresh endpoint
- Replaced localStorage JWT with httpOnly session cookies
- useSaveSync migrated to cookie auth
- cookie-parser added
- Gitea CI workflow (vps-runner pattern)
This commit is contained in:
2026-03-24 13:01:15 +01:00
parent 39f683a31e
commit 91d1616dd7
15 changed files with 548 additions and 393 deletions

View File

@@ -1,166 +1,79 @@
import React, {
createContext,
useContext,
useState,
useMemo,
useEffect,
} from "react";
import PropTypes from "prop-types";
const decodeJwtPayload = (token) =>
JSON.parse(atob(token.split(".")[1]));
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import { apiFetch } from "../lib/api";
const AuthContext = createContext();
const AuthContext = createContext();
const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
const jwtToken = localStorage.getItem("token");
if (jwtToken) {
try {
const decodedPayload = decodeJwtPayload(jwtToken);
const res = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/api/users/${decodedPayload.user}`,
{
headers: { "x-auth-token": jwtToken },
}
);
if (!res.ok) throw new Error("Failed to fetch user");
const data = await res.json();
setUser(data);
} catch (error) {
console.error("Error fetching user data:", error);
localStorage.removeItem("token");
} finally {
setLoading(false);
}
} else {
setLoading(false);
}
};
fetchData();
}, []);
const loginWithOAuth = async (token) => {
const res = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/api/auth/callback?code=${encodeURIComponent(token)}`
);
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "OAuth login failed");
}
localStorage.setItem("token", data.token);
setUser(data.user);
return data.user;
};
const logout = () => {
localStorage.removeItem("token");
const refresh = async () => {
try {
const data = await apiFetch("/auth/me");
setUser(data);
} catch {
setUser(null);
};
const editUser = async (updatedFields) => {
try {
const response = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/api/users/${user.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-auth-token": localStorage.getItem("token"),
},
body: JSON.stringify(updatedFields),
}
);
if (response.ok) {
const updatedUser = await response.json();
setUser((prevUser) => ({
...prevUser,
...updatedUser.user,
}));
return "User updated successfully";
}
if (response.status === 400) {
console.error("Bad Request:", response.statusText);
throw new Error("Bad Request");
} else if (response.status === 401) {
console.error("Unauthorized:", response.statusText);
throw new Error("Unauthorized");
} else {
console.error("Error updating user:", response.statusText);
throw new Error("Error updating user");
}
} catch (error) {
console.error("Error updating user:", error);
throw new Error("An error occurred during user update");
}
};
const sendPasswordResetEmail = async (email) => {
try {
const response = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/api/forgot-password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ mail: email }),
}
);
if (response.ok) {
return "Password reset email sent successfully";
}
const data = await response.json();
throw new Error(data.message || "Error sending password reset email");
} catch (error) {
console.error("Error sending password reset email:", error);
throw new Error(
"An error occurred while sending the password reset email"
);
}
};
const authContextValue = useMemo(() => {
return {
user,
loading,
logout,
loginWithOAuth,
editUser,
sendPasswordResetEmail,
setUser: (newUser) => {
setUser(newUser);
},
};
}, [user, loading, logout]);
return (
<AuthContext.Provider value={authContextValue}>
{children}
</AuthContext.Provider>
);
};
AuthProvider.propTypes = {
children: PropTypes.node.isRequired,
};
const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export { AuthProvider, useAuth };
useEffect(() => {
refresh().finally(() => setLoading(false));
}, []);
useEffect(() => {
const onExpired = () => setUser(null);
window.addEventListener("auth:expired", onExpired);
return () => window.removeEventListener("auth:expired", onExpired);
}, []);
const logout = async () => {
try {
await apiFetch("/auth/logout", { method: "POST" });
} catch {
// ignore
}
setUser(null);
};
const editUser = async (updatedFields) => {
const data = await apiFetch(`/users/${user.id}`, {
method: "PUT",
body: JSON.stringify(updatedFields),
});
setUser((prev) => ({ ...prev, ...data.user }));
return "User updated successfully";
};
const authContextValue = useMemo(
() => ({
user,
loading,
logout,
refresh,
editUser,
setUser: (newUser) => setUser(newUser),
}),
[user, loading]
);
return (
<AuthContext.Provider value={authContextValue}>
{children}
</AuthContext.Provider>
);
};
AuthProvider.propTypes = {
children: PropTypes.node.isRequired,
};
const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export { AuthProvider, useAuth };