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:
ljlongwing
2026-10-11 17:59:13 -06:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 135a95d8f2
commit e7164882f5
5 changed files with 125 additions and 8 deletions
+70 -2
View File
@@ -6,9 +6,9 @@
* viewer nav restrictions; all-routes smoke. * 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 { 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.describe("Auth — admin access @high", () => {
test("admin /system renders general tab", async ({ frigateApp }) => { 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("/");
});
}
});
+45
View File
@@ -1,3 +1,5 @@
import { baseUrl } from "./baseUrl";
// Module-level flag to prevent multiple simultaneous redirects // Module-level flag to prevent multiple simultaneous redirects
// (eg, when multiple SWR queries fail with 401 at once, or when // (eg, when multiple SWR queries fail with 401 at once, or when
// both ApiProvider and ProtectedRoute try to redirect) // both ApiProvider and ProtectedRoute try to redirect)
@@ -10,3 +12,46 @@ export function isRedirectingToLogin(): boolean {
export function setRedirectingToLogin(value: boolean): void { export function setRedirectingToLogin(value: boolean): void {
_isRedirectingToLogin = value; _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;
}
}
+6 -2
View File
@@ -3,7 +3,11 @@ import { SWRConfig } from "swr";
import { WsProvider } from "./WsProvider"; import { WsProvider } from "./WsProvider";
import axios from "axios"; import axios from "axios";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect"; import {
getLoginUrl,
isRedirectingToLogin,
setRedirectingToLogin,
} from "./auth-redirect";
axios.defaults.baseURL = `${baseUrl}api/`; axios.defaults.baseURL = `${baseUrl}api/`;
@@ -34,7 +38,7 @@ export function ApiProvider({ children, options }: ApiProviderType) {
const loginPage = error.response.headers.get("location") ?? "login"; const loginPage = error.response.headers.get("location") ?? "login";
if (window.location.href !== loginPage && !isRedirectingToLogin()) { if (window.location.href !== loginPage && !isRedirectingToLogin()) {
setRedirectingToLogin(true); setRedirectingToLogin(true);
window.location.href = loginPage; window.location.href = getLoginUrl(loginPage);
} }
} }
}, },
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { baseUrl } from "../../api/baseUrl"; import { getPostLoginUrl } from "@/api/auth-redirect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -69,7 +69,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
username: profileRes.data.username, username: profileRes.data.username,
role: profileRes.data.role || "viewer", role: profileRes.data.role || "viewer",
}); });
window.location.href = baseUrl; window.location.href = getPostLoginUrl();
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; const err = error as AxiosError;
+2 -2
View File
@@ -3,10 +3,10 @@ import { Navigate, Outlet } from "react-router-dom";
import { AuthContext } from "@/context/auth-context"; import { AuthContext } from "@/context/auth-context";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { import {
getLoginUrl,
isRedirectingToLogin, isRedirectingToLogin,
setRedirectingToLogin, setRedirectingToLogin,
} from "@/api/auth-redirect"; } from "@/api/auth-redirect";
import { baseUrl } from "@/api/baseUrl";
export default function ProtectedRoute({ export default function ProtectedRoute({
requiredRoles, requiredRoles,
@@ -25,7 +25,7 @@ export default function ProtectedRoute({
!isRedirectingToLogin() !isRedirectingToLogin()
) { ) {
setRedirectingToLogin(true); setRedirectingToLogin(true);
window.location.href = `${baseUrl}login`; window.location.href = getLoginUrl();
} }
}, [auth.isLoading, auth.isAuthenticated, auth.user]); }, [auth.isLoading, auth.isAuthenticated, auth.user]);