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">
<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" />
<div style="margin-top: 15px; bottom: 0%">
<small>Build# {buildNumber}

View File

@@ -22,11 +22,21 @@ export default function LoginPage() {
try {
if (!username) {
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) {
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();
@@ -45,28 +55,52 @@ export default function LoginPage() {
});
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);
return;
}
if (!resp.ok) {
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);
return;
}
const data = await resp.json();
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
} 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);
}
}
} 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);
setLoading(false);
}

View File

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

View File

@@ -1,58 +1,49 @@
export const requireAuthHook = async () => {
const token = Astro.cookies.get("access_token")?.value;
let user = null;
// requireAuthHook.js
import { API_URL } from "@/config";
export const requireAuthHook = async (Astro) => {
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
user = verifyToken(token);
if (res.status === 401) {
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) {
console.log("Access token check failed:", err.message);
// 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");
}
console.error("[SSR] requireAuthHook error:", err);
return null;
}
};

View File

@@ -2,29 +2,15 @@
import MediaRequestForm from "@/components/TRip/MediaRequestForm"
import Base from "@/layouts/Base.astro";
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";
const token = Astro.cookies.get("access_token")?.value;
let user = null;
try {
if (token) {
user = verifyToken(token);
if (user) {
console.log("Verified!", user);
} else {
throw Error("Authentication required");
}
} else {
throw Error("Authentication required");
}
} catch {
return Astro.redirect('/login'
);
const user = await requireAuthHook(Astro);
if (!user) {
return Astro.redirect('/login');
}
---
<Base>
<section>

View File

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

View File

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