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.
|
* 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("/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user