feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { apiUrl, requestJson } from "./api-client";
|
||||
|
||||
describe("api client", () => {
|
||||
it("normalizes relative API paths", () => {
|
||||
expect(apiUrl("health")).toBe("/api/health");
|
||||
expect(apiUrl("/health")).toBe("/api/health");
|
||||
});
|
||||
|
||||
it("returns typed JSON from successful responses", async () => {
|
||||
const transport = async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 });
|
||||
const result = await requestJson<{ status: string }>("/health", undefined, transport);
|
||||
expect(result).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("uses the API error detail when a request fails", async () => {
|
||||
const transport = async () => new Response(JSON.stringify({ detail: "Not available" }), { status: 409 });
|
||||
await expect(requestJson("/requests/1", undefined, transport)).rejects.toEqual(
|
||||
expect.objectContaining({ status: 409, message: "Not available" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { authFetchOrThrow, getApiBase } from "./auth";
|
||||
|
||||
export type ApiTransport = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = (payload: unknown, fallback: string) => {
|
||||
if (!payload || typeof payload !== "object") return fallback;
|
||||
const record = payload as Record<string, unknown>;
|
||||
for (const key of ["detail", "error", "message"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const apiUrl = (path: string) => {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${getApiBase()}${normalizedPath}`;
|
||||
};
|
||||
|
||||
export async function requestJson<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
transport: ApiTransport = authFetchOrThrow,
|
||||
): Promise<T> {
|
||||
const response = await transport(apiUrl(path), init);
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
const text = await response.text();
|
||||
let payload: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiClientError(response.status, errorMessage(payload, text || `Request failed: ${response.status}`));
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const AUTH_STATE_COOKIE = "magent_logged_in";
|
||||
|
||||
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? "/api";
|
||||
|
||||
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const clearCookie = (name: string) => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
|
||||
};
|
||||
|
||||
export const getToken = () => {
|
||||
if (typeof document === "undefined") return null;
|
||||
const cookies = document.cookie.split(";").map((entry) => entry.trim());
|
||||
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`));
|
||||
if (!marker) return null;
|
||||
const [, value] = marker.split("=", 2);
|
||||
return value || null;
|
||||
};
|
||||
|
||||
export const setToken = (_token: string) => {
|
||||
setCookie(AUTH_STATE_COOKIE, "1", 60 * 60 * 12);
|
||||
};
|
||||
|
||||
export const clearToken = () => {
|
||||
clearCookie(AUTH_STATE_COOKIE);
|
||||
if (typeof window === "undefined") return;
|
||||
const baseUrl = getApiBase();
|
||||
void fetch(`${baseUrl}/auth/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
const baseUrl = getApiBase();
|
||||
clearCookie(AUTH_STATE_COOKIE);
|
||||
await fetch(`${baseUrl}/auth/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
};
|
||||
|
||||
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
return fetch(input, { ...init, headers, credentials: "include" });
|
||||
};
|
||||
|
||||
export const getEventStreamToken = async () => {
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/auth/stream-token`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Stream token request failed: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const token = typeof data?.stream_token === "string" ? data.stream_token : "";
|
||||
if (!token) {
|
||||
throw new Error("Stream token not returned");
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor() {
|
||||
super("Forbidden");
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await authFetch(input, init);
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const readResponseText = async (response: Response) => {
|
||||
try {
|
||||
return (await response.text()).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
export const FEATURES = [
|
||||
{ key: "stats", label: "My Stats", description: "View personal viewing history, reports and request report emails." },
|
||||
{ key: "requests", label: "My Requests", description: "View existing requests, their progress and request actions." },
|
||||
{
|
||||
key: "new_requests",
|
||||
label: "New Requests",
|
||||
description: "Search for movies and TV shows and submit new requests.",
|
||||
},
|
||||
{
|
||||
key: "issues",
|
||||
label: "Issues",
|
||||
description: "Report problems, follow up on issues and use available repair tools.",
|
||||
},
|
||||
{ key: "invites", label: "Invites", description: "Create and manage invitations within the existing invite limits." },
|
||||
{
|
||||
key: "ignore_profile_limits",
|
||||
label: "Ignore profile limits",
|
||||
description:
|
||||
"Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.",
|
||||
},
|
||||
] as const;
|
||||
export type Feature = (typeof FEATURES)[number]["key"];
|
||||
export type FeatureAccess = Record<Feature, boolean>;
|
||||
export function featureForPath(path: string): Feature | undefined {
|
||||
if (path === "/insights" || path.startsWith("/insights/")) return "stats";
|
||||
if (path === "/" || path.startsWith("/requests/")) return "requests";
|
||||
if (path === "/new-requests") return "new_requests";
|
||||
if (path.startsWith("/issues/confirm/") || path.startsWith("/portal/issues")) return "issues";
|
||||
if (path.startsWith("/profile/invites")) return "invites";
|
||||
if (path === "/portal/requests") return "requests";
|
||||
}
|
||||
export function canAccess(
|
||||
user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null,
|
||||
feature?: Feature,
|
||||
) {
|
||||
if (!feature) return true;
|
||||
if (!user) return false;
|
||||
if (user.role === "admin") return true;
|
||||
return (
|
||||
user.features?.[feature] ??
|
||||
(feature === "invites" ? Boolean(user.invite_management_enabled) : feature !== "ignore_profile_limits")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loginErrorMessage } from "./login-errors";
|
||||
|
||||
const errorResponse = (status: number, payload: unknown) => new Response(JSON.stringify(payload), { status });
|
||||
|
||||
describe("login error messages", () => {
|
||||
it("identifies a site security rejection without blaming the account", async () => {
|
||||
expect(await loginErrorMessage(errorResponse(403, { detail: "Cross-origin state change rejected" }))).toBe(
|
||||
"Sign-in was blocked by the site's security configuration. Please contact an administrator.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["User is blocked", "User access has expired", "Unknown upstream error"])(
|
||||
"keeps a generic account message for %s",
|
||||
async (detail) => {
|
||||
expect(await loginErrorMessage(errorResponse(403, { detail }))).toBe(
|
||||
"This account cannot sign in. Please contact an administrator.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{ detail: ["Cross-origin state change rejected"] },
|
||||
{ detail: "Cross-origin state change rejected: private upstream detail" },
|
||||
{ detail: "<script>private upstream detail</script>" },
|
||||
])("does not render or loosely match unexpected response bodies: %j", async (payload) => {
|
||||
expect(await loginErrorMessage(errorResponse(403, payload))).toBe(
|
||||
"This account cannot sign in. Please contact an administrator.",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a non-JSON proxy denial safely", async () => {
|
||||
expect(await loginErrorMessage(new Response("<html>Forbidden</html>", { status: 403 }))).toBe(
|
||||
"This account cannot sign in. Please contact an administrator.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, "Check your username and password, then try again."],
|
||||
[400, "Check your username and password, then try again."],
|
||||
[429, "Too many attempts. Please wait a moment and try again."],
|
||||
[500, "Sign-in is temporarily unavailable. Please try again shortly."],
|
||||
[502, "Sign-in is temporarily unavailable. Please try again shortly."],
|
||||
])("preserves the existing message for HTTP %s", async (status, expected) => {
|
||||
expect(
|
||||
await loginErrorMessage(errorResponse(status as number, { detail: "Cross-origin state change rejected" })),
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export async function loginErrorMessage(response: Response): Promise<string> {
|
||||
if (response.status === 429) return "Too many attempts. Please wait a moment and try again.";
|
||||
if (response.status >= 500) return "Sign-in is temporarily unavailable. Please try again shortly.";
|
||||
if (response.status === 403) {
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (
|
||||
payload !== null &&
|
||||
typeof payload === "object" &&
|
||||
"detail" in payload &&
|
||||
payload.detail === "Cross-origin state change rejected"
|
||||
) {
|
||||
return "Sign-in was blocked by the site's security configuration. Please contact an administrator.";
|
||||
}
|
||||
return "This account cannot sign in. Please contact an administrator.";
|
||||
}
|
||||
return "Check your username and password, then try again.";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeRecentResults, normalizeSearchResults } from "./request-results";
|
||||
|
||||
describe("request result normalization", () => {
|
||||
it("replaces placeholder request titles", () => {
|
||||
expect(normalizeRecentResults([{ id: 42, title: "Request 42", year: 2024 }])).toEqual([
|
||||
expect.objectContaining({ id: 42, title: "Request #42", year: 2024 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed search results", () => {
|
||||
expect(normalizeSearchResults([null, { title: "" }, { title: "Drive", requestId: 3991 }])).toEqual([
|
||||
expect.objectContaining({ title: "Drive", requestId: 3991 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface RecentRequest {
|
||||
id: number;
|
||||
title: string;
|
||||
year?: number;
|
||||
type?: string;
|
||||
statusLabel?: string;
|
||||
artwork?: { poster_url?: string; backdrop_url?: string };
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface RequestSearchResult {
|
||||
title: string;
|
||||
year?: number;
|
||||
type?: string;
|
||||
requestId?: number;
|
||||
statusLabel?: string;
|
||||
requestedBy?: string | null;
|
||||
accessible?: boolean;
|
||||
}
|
||||
|
||||
const recordValue = (value: unknown): Record<string, unknown> | null =>
|
||||
value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
|
||||
const optionalString = (value: unknown) => (typeof value === "string" ? value : undefined);
|
||||
const optionalNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
|
||||
|
||||
export const normalizeRecentResults = (items: unknown): RecentRequest[] => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.flatMap((value) => {
|
||||
const item = recordValue(value);
|
||||
const id = optionalNumber(item?.id);
|
||||
if (!item || id === undefined) return [];
|
||||
const rawTitle = optionalString(item.title);
|
||||
const placeholder = rawTitle?.trim().toLowerCase() === `request ${id}`;
|
||||
const rawArtwork = recordValue(item.artwork);
|
||||
const artwork = rawArtwork
|
||||
? {
|
||||
poster_url: optionalString(rawArtwork.poster_url),
|
||||
backdrop_url: optionalString(rawArtwork.backdrop_url),
|
||||
}
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||
year: optionalNumber(item.year),
|
||||
type: optionalString(item.type),
|
||||
statusLabel: optionalString(item.statusLabel),
|
||||
artwork,
|
||||
createdAt: item.createdAt === null ? null : optionalString(item.createdAt),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizeSearchResults = (items: unknown): RequestSearchResult[] => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.flatMap((value) => {
|
||||
const item = recordValue(value);
|
||||
const title = optionalString(item?.title);
|
||||
if (!item || !title) return [];
|
||||
return [
|
||||
{
|
||||
title,
|
||||
year: optionalNumber(item.year),
|
||||
type: optionalString(item.type),
|
||||
requestId: optionalNumber(item.requestId),
|
||||
statusLabel: optionalString(item.statusLabel),
|
||||
requestedBy: item.requestedBy === null ? null : optionalString(item.requestedBy),
|
||||
accessible: Boolean(item.accessible),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
let locks = 0;
|
||||
let previous = "";
|
||||
|
||||
export function lockBodyScroll() {
|
||||
if (locks++ === 0) {
|
||||
previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (--locks === 0) document.body.style.overflow = previous;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEffectiveRole, isAdminPage } from "./user-view-policy";
|
||||
|
||||
describe("user view preview policy", () => {
|
||||
it("downgrades only the displayed administrator role during preview", () => {
|
||||
expect(getEffectiveRole("admin", true)).toBe("user");
|
||||
expect(getEffectiveRole("admin", false)).toBe("admin");
|
||||
for (const role of ["user", null, undefined]) {
|
||||
expect(getEffectiveRole(role, true)).toBe(role);
|
||||
expect(getEffectiveRole(role, false)).toBe(role);
|
||||
}
|
||||
});
|
||||
it("covers configuration, nested admin pages, user management and setup", () => {
|
||||
for (const path of [
|
||||
"/admin",
|
||||
"/admin/",
|
||||
"/admin/backups",
|
||||
"/admin/recaps",
|
||||
"/users",
|
||||
"/users/42",
|
||||
"/setup",
|
||||
"/admin?section=site",
|
||||
"/%61dmin/diagnostics",
|
||||
]) {
|
||||
expect(isAdminPage(path), path).toBe(true);
|
||||
}
|
||||
});
|
||||
it("does not restrict normal member pages or similarly named paths", () => {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/profile",
|
||||
"/profile/invites",
|
||||
"/portal/issues",
|
||||
"/requests/3580",
|
||||
"/insights",
|
||||
"/administrator",
|
||||
"/users-guide",
|
||||
]) {
|
||||
expect(isAdminPage(path), path).toBe(false);
|
||||
}
|
||||
});
|
||||
it("keeps public first-install setup separate from admin authentication", () => {
|
||||
expect(isAdminPage("/setup", false)).toBe(false);
|
||||
expect(isAdminPage("/admin/backups", false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Preview never promotes a user or changes server-side account permissions.
|
||||
export function getEffectiveRole(role: string | null | undefined, preview: boolean) {
|
||||
return preview && role === "admin" ? "user" : role;
|
||||
}
|
||||
|
||||
export function isAdminPage(pathname: string, includeSetup = true): boolean {
|
||||
let path = pathname.split(/[?#]/, 1)[0];
|
||||
try {
|
||||
path = decodeURIComponent(path);
|
||||
} catch {
|
||||
// Let the router handle malformed URLs; never infer a more privileged role.
|
||||
}
|
||||
path = path.replace(/\/{2,}/g, "/");
|
||||
const roots = includeSetup ? ["/admin", "/users", "/setup"] : ["/admin", "/users"];
|
||||
return roots.some((root) => path === root || path.startsWith(`${root}/`));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { getEffectiveRole } from "./user-view-policy";
|
||||
|
||||
const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
|
||||
const USER_VIEW_EVENT = "magent:user-view-change";
|
||||
let fallbackPreview = false;
|
||||
|
||||
const readUserViewPreview = () => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return fallbackPreview;
|
||||
}
|
||||
};
|
||||
|
||||
const applyDocumentMode = (enabled: boolean) => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.dataset.userView = enabled ? "true" : "false";
|
||||
};
|
||||
|
||||
export const setUserViewPreview = (enabled: boolean) => {
|
||||
if (typeof window === "undefined") return;
|
||||
fallbackPreview = enabled;
|
||||
try {
|
||||
if (enabled) {
|
||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
|
||||
} else {
|
||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Preview still works for this document when browser storage is unavailable.
|
||||
}
|
||||
applyDocumentMode(enabled);
|
||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
|
||||
};
|
||||
|
||||
const subscribe = (notify: () => void) => {
|
||||
window.addEventListener(USER_VIEW_EVENT, notify);
|
||||
window.addEventListener("storage", notify);
|
||||
return () => {
|
||||
window.removeEventListener(USER_VIEW_EVENT, notify);
|
||||
window.removeEventListener("storage", notify);
|
||||
};
|
||||
};
|
||||
|
||||
// Unknown during server rendering/initial hydration: admin pages must not mount
|
||||
// and fetch privileged data before the saved per-tab preview mode is known.
|
||||
const serverSnapshot = (): boolean | null => null;
|
||||
|
||||
export const useUserViewState = () => {
|
||||
const value = useSyncExternalStore(subscribe, readUserViewPreview, serverSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== null) applyDocumentMode(value);
|
||||
}, [value]);
|
||||
|
||||
return { enabled: value === true, ready: value !== null };
|
||||
};
|
||||
|
||||
export const useUserViewPreview = () => useUserViewState().enabled;
|
||||
|
||||
export const useEffectiveRole = (role?: string | null) => {
|
||||
const { enabled, ready } = useUserViewState();
|
||||
return getEffectiveRole(role, !ready || enabled);
|
||||
};
|
||||
Reference in New Issue
Block a user