mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-13 02:02:48 +03:00
Return to the requested URL after logging in (#24637)
When an unauthenticated user opens a deep link, carry the requested path through the login page in a redirect query param and return to it after a successful login instead of always going to the home page. Only same-origin targets are honored. Claude-Session: https://claude.ai/code/session_011CFGjgYURSRTpXBaxp1Dq7 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
135a95d8f2
commit
e7164882f5
@@ -6,9 +6,9 @@
|
||||
* viewer nav restrictions; all-routes smoke.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
import { adminProfile, viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
test.describe("Auth — admin access @high", () => {
|
||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||
@@ -109,3 +109,71 @@ test.describe("Auth — all routes smoke @high @mobile", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Auth: login redirect @high", () => {
|
||||
// 401 on /profile is how the app learns that a login is required
|
||||
test.use({ expectedErrors: [/401/] });
|
||||
|
||||
async function requireLogin(frigateApp: FrigateApp) {
|
||||
let loggedIn = false;
|
||||
await frigateApp.page.route("**/api/profile", (route) =>
|
||||
loggedIn
|
||||
? route.fulfill({ json: adminProfile() })
|
||||
: route.fulfill({ status: 401, body: "" }),
|
||||
);
|
||||
await frigateApp.page.route("**/api/login", (route) => {
|
||||
loggedIn = true;
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
await frigateApp.page.route("**/api/auth/first_time_login", (route) =>
|
||||
route.fulfill({ json: { admin_first_time_login: false } }),
|
||||
);
|
||||
}
|
||||
|
||||
async function logIn(frigateApp: FrigateApp) {
|
||||
await frigateApp.page.getByLabel("Username").fill("admin");
|
||||
await frigateApp.page.getByLabel("Password").fill("password");
|
||||
await frigateApp.page.getByRole("button", { name: "Login" }).click();
|
||||
}
|
||||
|
||||
test("returns to the requested url after logging in", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await requireLogin(frigateApp);
|
||||
await frigateApp.page.goto("/explore?labels=person");
|
||||
await expect(frigateApp.page).toHaveURL(
|
||||
`/login?redirect=${encodeURIComponent("/explore?labels=person")}`,
|
||||
);
|
||||
|
||||
await logIn(frigateApp);
|
||||
await expect(frigateApp.page).toHaveURL("/explore?labels=person");
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("goes home after logging in from the home page", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await requireLogin(frigateApp);
|
||||
await frigateApp.page.goto("/");
|
||||
await expect(frigateApp.page).toHaveURL("/login");
|
||||
|
||||
await logIn(frigateApp);
|
||||
await expect(frigateApp.page).toHaveURL("/");
|
||||
});
|
||||
|
||||
for (const target of ["https://example.com/", "//example.com/"]) {
|
||||
test(`ignores a redirect to another site (${target})`, async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await requireLogin(frigateApp);
|
||||
await frigateApp.page.goto(
|
||||
`/login?redirect=${encodeURIComponent(target)}`,
|
||||
);
|
||||
|
||||
await logIn(frigateApp);
|
||||
await expect(frigateApp.page).toHaveURL("/");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
|
||||
// Module-level flag to prevent multiple simultaneous redirects
|
||||
// (eg, when multiple SWR queries fail with 401 at once, or when
|
||||
// both ApiProvider and ProtectedRoute try to redirect)
|
||||
@@ -10,3 +12,46 @@ export function isRedirectingToLogin(): boolean {
|
||||
export function setRedirectingToLogin(value: boolean): void {
|
||||
_isRedirectingToLogin = value;
|
||||
}
|
||||
|
||||
const REDIRECT_PARAM = "redirect";
|
||||
|
||||
// Build the login page url, carrying the current location along
|
||||
// so the user can be returned to it after logging in
|
||||
export function getLoginUrl(loginPage: string = `${baseUrl}login`): string {
|
||||
const url = new URL(loginPage, window.location.href);
|
||||
|
||||
// an external login page (eg, an auth proxy) handles its own return url
|
||||
if (url.origin !== window.location.origin) {
|
||||
return loginPage;
|
||||
}
|
||||
|
||||
const { pathname, search, hash } = window.location;
|
||||
const current = `${pathname}${search}${hash}`;
|
||||
|
||||
// no need to carry the home page, it is the default after logging in
|
||||
if (current !== new URL(baseUrl).pathname) {
|
||||
url.searchParams.set(REDIRECT_PARAM, current);
|
||||
}
|
||||
|
||||
return url.href;
|
||||
}
|
||||
|
||||
// Resolve where to send the user after a successful login
|
||||
export function getPostLoginUrl(): string {
|
||||
const target = new URLSearchParams(window.location.search).get(
|
||||
REDIRECT_PARAM,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// only return to urls on this origin so the param can't be used
|
||||
// to send the user to another site
|
||||
try {
|
||||
const url = new URL(target, window.location.origin);
|
||||
return url.origin === window.location.origin ? url.href : baseUrl;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { SWRConfig } from "swr";
|
||||
import { WsProvider } from "./WsProvider";
|
||||
import axios from "axios";
|
||||
import { ReactNode } from "react";
|
||||
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
|
||||
import {
|
||||
getLoginUrl,
|
||||
isRedirectingToLogin,
|
||||
setRedirectingToLogin,
|
||||
} from "./auth-redirect";
|
||||
|
||||
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||
|
||||
@@ -34,7 +38,7 @@ export function ApiProvider({ children, options }: ApiProviderType) {
|
||||
const loginPage = error.response.headers.get("location") ?? "login";
|
||||
if (window.location.href !== loginPage && !isRedirectingToLogin()) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = loginPage;
|
||||
window.location.href = getLoginUrl(loginPage);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { baseUrl } from "../../api/baseUrl";
|
||||
import { getPostLoginUrl } from "@/api/auth-redirect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -69,7 +69,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
username: profileRes.data.username,
|
||||
role: profileRes.data.role || "viewer",
|
||||
});
|
||||
window.location.href = baseUrl;
|
||||
window.location.href = getPostLoginUrl();
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const err = error as AxiosError;
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Navigate, Outlet } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import {
|
||||
getLoginUrl,
|
||||
isRedirectingToLogin,
|
||||
setRedirectingToLogin,
|
||||
} from "@/api/auth-redirect";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
export default function ProtectedRoute({
|
||||
requiredRoles,
|
||||
@@ -25,7 +25,7 @@ export default function ProtectedRoute({
|
||||
!isRedirectingToLogin()
|
||||
) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = `${baseUrl}login`;
|
||||
window.location.href = getLoginUrl();
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user