misc / bugfix: session refresh

This commit is contained in:
2025-08-28 11:15:17 -04:00
parent 315919186b
commit 1d0b310228
7 changed files with 172 additions and 153 deletions

View File

@@ -9,11 +9,6 @@ const YEAR = new Date().getFullYear();
--- ---
<div class="footer"> <div class="footer">
<small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text">
<time>© {YEAR}</time>{" "}
{metaData.owner}
</a>
</small>
<RandomMsg client:only="react" /> <RandomMsg client:only="react" />
<div style="margin-top: 15px; bottom: 0%"> <div style="margin-top: 15px; bottom: 0%">
<small>Build# {buildNumber} <small>Build# {buildNumber}

View File

@@ -22,11 +22,21 @@ export default function LoginPage() {
try { try {
if (!username) { if (!username) {
setLoading(false); setLoading(false);
return toast.error("Username is required"); if (!toast.isActive("login-username-required-toast")) {
return toast.error("Username and password are required",
{
toastId: "login-missing-data-toast",
});
}
} }
if (!password) { if (!password) {
setLoading(false); setLoading(false);
return toast.error("Password is required"); if (!toast.isActive("login-password-required-toast")) {
return toast.error("Username and password are required",
{
toastId: "login-missing-data-toast",
});
}
} }
const formData = new URLSearchParams(); const formData = new URLSearchParams();
@@ -45,28 +55,52 @@ export default function LoginPage() {
}); });
if (resp.status === 401) { if (resp.status === 401) {
toast.error("Invalid username or password"); if (!toast.isActive("login-error-invalid-toast")) {
toast.error("Invalid username or password", {
toastId: "login-error-invalid-toast",
});
}
setLoading(false); setLoading(false);
return; return;
} }
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
toast.error(data.detail ? `Login failed: ${data.detail}` : "Login failed"); if (!toast.isActive("login-error-failed-toast")) {
toast.error(data.detail ? `Login failed: ${data.detail}` : "Login failed",
{
toastId: "login-error-failed-toast",
});
}
setLoading(false); setLoading(false);
return; return;
} }
const data = await resp.json(); const data = await resp.json();
if (data.access_token) { if (data.access_token) {
toast.success("Login successful!"); if (!toast.isActive("login-success-toast")) {
toast.success("Login successful!",
{
toastId: "login-success-toast",
});
}
window.location.href = "/TRip"; // TODO: fix, hardcoded window.location.href = "/TRip"; // TODO: fix, hardcoded
} else { } else {
toast.error("Login failed: no access token received"); if (!toast.isActive("login-error-no-token-toast")) {
toast.error("Login failed: no access token received",
{
toastId: "login-error-no-token-toast",
});
setLoading(false); setLoading(false);
} }
}
} catch (error) { } catch (error) {
toast.error("Network error during login"); if (!toast.isActive("login-error-network-toast")) {
toast.error("Network error during login",
{
toastId: "login-error-network-toast",
});
}
console.error("Login error:", error); console.error("Login error:", error);
setLoading(false); setLoading(false);
} }

View File

@@ -10,7 +10,7 @@ import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
import BreadcrumbNav from "./BreadcrumbNav"; import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL } from "@/config"; import { API_URL } from "@/config";
const STATUS_OPTIONS = ["queued", "started", "compressing", "finished", "failed"]; const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
export default function RequestManagement() { export default function RequestManagement() {
@@ -83,7 +83,7 @@ export default function RequestManagement() {
} }
}, [isDialogVisible, selectedRequest?.id]); }, [isDialogVisible, selectedRequest?.id]);
useEffect(() => { useEffect(() => {
const hasActive = requests.some((j) => ["queued", "started", "compressing"].includes(j.status)); const hasActive = requests.some((j) => ["Queued", "Started", "Compressing"].includes(j.status));
if (hasActive && !pollingRef.current) pollingRef.current = setInterval(fetchJobs, 1500); if (hasActive && !pollingRef.current) pollingRef.current = setInterval(fetchJobs, 1500);
else if (!hasActive && pollingRef.current) { else if (!hasActive && pollingRef.current) {
clearInterval(pollingRef.current); clearInterval(pollingRef.current);
@@ -104,20 +104,20 @@ export default function RequestManagement() {
const getStatusColorClass = (status) => { const getStatusColorClass = (status) => {
switch (status) { switch (status) {
case "queued": return "bg-yellow-500 text-black"; case "Queued": return "bg-yellow-700 text-white";
case "started": return "bg-blue-500 text-white"; case "Started": return "bg-blue-700 text-white";
case "compressing": return "bg-orange-500 text-white"; case "Compressing": return "bg-orange-700 text-white";
case "finished": return "bg-green-500 text-white"; case "Finished": return "bg-green-700 text-white";
case "failed": return "bg-red-500 text-white"; case "Failed": return "bg-red-700 text-white";
default: return "bg-gray-500 text-white"; default: return "bg-gray-700 text-white";
} }
}; };
const getQualityColorClass = (quality) => { const getQualityColorClass = (quality) => {
switch (quality) { switch (quality) {
case "FLAC": return "bg-green-500 text-white"; case "FLAC": return "bg-green-700 text-white";
case "Lossy": return "bg-yellow-500 text-white"; case "Lossy": return "bg-yellow-700 text-white";
default: return "bg-gray-500 text-white"; default: return "bg-gray-700 text-white";
} }
}; };
@@ -306,12 +306,11 @@ export default function RequestManagement() {
removableSort removableSort
sortMode="multiple" sortMode="multiple"
emptyMessage="No requests found." emptyMessage="No requests found."
responsiveLayout="scroll"
onRowClick={handleRowClick} onRowClick={handleRowClick}
> >
<Column field="id" header="ID" sortable style={{ width: "8rem" }} body={(row) => textWithEllipsis(row.id, "6rem")} /> <Column field="id" header="ID" style={{ width: "8rem" }} body={(row) => textWithEllipsis(row.id, "6rem")} />
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} /> <Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
<Column field="tracks" header="# Tracks" sortable style={{ width: "8rem" }} body={(row) => row.tracks} /> <Column field="tracks" header="# Tracks" style={{ width: "8rem" }} body={(row) => row.tracks} />
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable /> <Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable /> <Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
<Column <Column
@@ -358,57 +357,63 @@ export default function RequestManagement() {
> >
{selectedRequest ? ( {selectedRequest ? (
<div className="space-y-4 text-sm"> <div className="space-y-4 text-sm">
{selectedRequest.id && (
<p><strong>ID:</strong> {selectedRequest.id}</p> {/* --- Metadata Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {selectedRequest.id}</p>}
{selectedRequest.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
{selectedRequest.quality && (
<p>
<strong>Quality:</strong>{" "}
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${getQualityColorClass(selectedRequest.quality)}`}>
{selectedRequest.quality}
</span>
</p>
)} )}
{selectedRequest.target && ( </div>
<p><strong>Target:</strong> {selectedRequest.target}</p>
)} {/* --- Status / Progress Card --- */}
{selectedRequest.tracks && ( <div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
<p><strong># Tracks:</strong> {selectedRequest.tracks}</p> {selectedRequest.status && (
)} <p>
{selectedRequest.status && (<p>
<strong>Status:</strong>{" "} <strong>Status:</strong>{" "}
<span <span className={`px-2 py-0.5 rounded-full text-xs font-bold ${getStatusColorClass(selectedRequest.status)}`}>
className={`px-2 py-0.5 rounded-full text-xs font-bold ${getStatusColorClass(selectedRequest.status)}`}
>
{selectedRequest.status} {selectedRequest.status}
</span> </span>
</p>)} </p>
)}
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && ( {selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p> <p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
)} )}
</div>
{selectedRequest.enqueued_at && ( {/* --- Timestamps Card --- */}
<p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p> <div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 gap-2">
)} {selectedRequest.enqueued_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p>}
{selectedRequest.started_at && ( {selectedRequest.started_at && <p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p>}
<p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p> {selectedRequest.ended_at && <p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>}
)} </div>
{selectedRequest.ended_at && (
<p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>
)}
{selectedRequest.tarball && ( {/* --- Tarball Card --- */}
{
selectedRequest.tarball && (
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
<p> <p>
<strong>Tarball:</strong>{" "} <strong>Tarball:</strong>{" "}
<a <a
href={tarballUrl(selectedRequest.tarball)} href={tarballUrl(selectedRequest.tarball, selectedRequest.quality)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
> >
{tarballUrl(selectedRequest.tarball).split("/").pop()} {tarballUrl(selectedRequest.tarball, selectedRequest.quality).split("/").pop()}
</a> </a>
</p> </p>
)} </div>
{selectedRequest.quality && ( )
<p><strong>Quality:</strong>{" "} }
<span
className={`px-2 py-0.5 rounded-full text-xs font-bold ${getQualityColorClass(selectedRequest.quality)}`}
>{selectedRequest.quality}</span></p>
)}
</div > </div >
) : ( ) : (
<p>Loading...</p> <p>Loading...</p>
@@ -416,6 +421,7 @@ export default function RequestManagement() {
} }
</Dialog > </Dialog >
</div > </div >
); );
} }

View File

@@ -1,58 +1,49 @@
export const requireAuthHook = async () => { // requireAuthHook.js
const token = Astro.cookies.get("access_token")?.value; import { API_URL } from "@/config";
let user = null;
export const requireAuthHook = async (Astro) => {
try { try {
if (!token) throw Error("No access token"); const cookieHeader = Astro.request.headers.get("cookie") ?? "";
let res = await fetch(`${API_URL}/auth/id`, {
headers: { Cookie: cookieHeader },
credentials: "include",
});
// Step 1: verify current access token if (res.status === 401) {
user = verifyToken(token); const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
headers: { Cookie: cookieHeader },
credentials: "include",
});
if (!user) throw Error("Invalid access token"); if (!refreshRes.ok) {
return null;
}
console.log("Verified!", user); const setCookieHeader = refreshRes.headers.get("set-cookie");
let newCookieHeader = cookieHeader;
if (setCookieHeader) {
const cookiesArray = setCookieHeader.split(/,(?=\s*\w+=)/);
cookiesArray.forEach((c) => Astro.response.headers.append("set-cookie", c));
newCookieHeader = cookiesArray.map(c => c.split(";")[0]).join("; ");
}
res = await fetch(`${API_URL}/auth/id`, {
headers: { Cookie: newCookieHeader },
credentials: "include",
});
}
if (!res.ok) {
return null;
}
const user = await res.json();
return user;
} catch (err) { } catch (err) {
console.log("Access token check failed:", err.message); console.error("[SSR] requireAuthHook error:", err);
return null;
// Step 2: attempt refresh if refresh_token exists
const refreshToken = Astro.cookies.get("refresh_token")?.value;
if (refreshToken) {
try {
const newTokens = await refreshAccessToken(refreshToken);
if (newTokens?.accessToken) {
// store new access token
Astro.cookies.set("access_token", newTokens.accessToken, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: true,
});
// Optionally replace refresh_token too
if (newTokens.refreshToken) {
Astro.cookies.set("refresh_token", newTokens.refreshToken, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: true,
});
}
// re-verify user with new token
user = verifyToken(newTokens.accessToken);
if (user) {
console.log("Refreshed + verified!", user);
return; // ✅ authenticated now
}
}
} catch (refreshErr) {
console.error("Refresh failed:", refreshErr.message);
}
}
// Step 3: if still no user, redirect
return Astro.redirect("/login");
}
} }
};

View File

@@ -2,29 +2,15 @@
import MediaRequestForm from "@/components/TRip/MediaRequestForm" import MediaRequestForm from "@/components/TRip/MediaRequestForm"
import Base from "@/layouts/Base.astro"; import Base from "@/layouts/Base.astro";
import Root from "@/components/AppLayout.jsx"; import Root from "@/components/AppLayout.jsx";
import { verifyToken } from "@/utils/jwt";
import { refreshAccessToken } from "@/utils/authFetch";
import { ENVIRONMENT } from "@/config";
import { requireAuthHook } from "@/hooks/requireAuthHook"; import { requireAuthHook } from "@/hooks/requireAuthHook";
const token = Astro.cookies.get("access_token")?.value;
let user = null;
try { const user = await requireAuthHook(Astro);
if (token) {
user = verifyToken(token); if (!user) {
if (user) { return Astro.redirect('/login');
console.log("Verified!", user);
} else {
throw Error("Authentication required");
}
} else {
throw Error("Authentication required");
}
} catch {
return Astro.redirect('/login'
);
} }
--- ---
<Base> <Base>
<section> <section>

View File

@@ -6,6 +6,12 @@ import { verifyToken } from "@/utils/jwt";
import { requireAuthHook } from "@/hooks/requireAuthHook"; import { requireAuthHook } from "@/hooks/requireAuthHook";
import { ENVIRONMENT } from "@/config"; import { ENVIRONMENT } from "@/config";
const user = await requireAuthHook(Astro);
if (!user) {
return Astro.redirect('/login');
}
--- ---
<Base> <Base>
<section> <section>

View File

@@ -11,7 +11,7 @@ export const authFetch = async (url, options = {}, retry = true) => {
if (res.status === 401 && retry) { if (res.status === 401 && retry) {
// attempt refresh // attempt refresh
try { try {
const refreshRes = await fetch(`${API_URL}/refresh`, { const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
}); });
@@ -30,22 +30,23 @@ export const authFetch = async (url, options = {}, retry = true) => {
}; };
// Refresh token function (HttpOnly cookie flow) // Refresh token function (HttpOnly cookie flow)
export async function refreshAccessToken() { export async function refreshAccessToken(cookieHeader) {
try { try {
const res = await fetch(`${API_URL}/refresh`, { const res = await fetch(`${API_URL}/auth/refresh`, {
method: "POST", method: "POST",
credentials: "include", // send HttpOnly cookies headers: {
cookie: cookieHeader || "", // forward cookies from the request
},
}); });
if (!res.ok) { if (!res.ok) {
throw new Error("Failed to refresh token"); throw new Error("Failed to refresh token");
} }
// Typically the server just updates the cookie // assume backend responds with new tokens in JSON
// It may return a new access token too, but we dont store it client-side. return await res.json();
return true;
} catch (err) { } catch (err) {
console.error("Refresh token failed:", err); console.error("Refresh token failed:", err);
return false; return null;
} }
} }