misc / bugfix: session refresh
This commit is contained in:
@@ -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}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
};
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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 don’t 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;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user