Fix login route collision after SPA cutover

Move the browser sign-in page off the backend /auth namespace so direct
navigation works, update Rust and frontend redirects to /login, and add
tests that lock the split between SPA login UI and backend OAuth endpoints.
This commit is contained in:
Bryan Helmkamp 2026-04-01 21:55:30 -07:00
parent eb91f2bcab
commit e8a7bd757e
No known key found for this signature in database
11 changed files with 71 additions and 18 deletions

View file

@ -12,7 +12,7 @@ export async function apiFetch(path: string, options?: ApiOptions): Promise<Resp
});
if (response.status === 401) {
window.location.href = "/auth/login";
window.location.href = "/login";
throw new Error("Unauthorized");
}

View file

@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import type { RouteObject } from "react-router";
import { routes } from "./router";
function collectPaths(routeObjects: RouteObject[], prefix = ""): string[] {
return routeObjects.flatMap((route) => {
const path = route.index
? prefix || "/"
: route.path
? route.path.startsWith("/")
? route.path
: `${prefix}/${route.path}`.replace(/\/+/g, "/")
: prefix;
const childPaths = route.children ? collectPaths(route.children, path) : [];
return route.path || route.index ? [path, ...childPaths] : childPaths;
});
}
describe("browser router", () => {
test("uses /login for the sign-in page instead of the backend auth namespace", () => {
const paths = collectPaths(routes);
expect(paths).toContain("/login");
expect(paths).not.toContain("/auth/login");
});
});

View file

@ -90,7 +90,7 @@ export const routes: RouteObject[] = [
indexRoute(RedirectHome),
route("setup", Setup),
route("setup/complete", SetupComplete),
route("auth/login", AuthLogin),
route("login", AuthLogin),
{
loader: appShellLoader,
Component: withRouteModule({

View file

@ -11,7 +11,7 @@ export async function loader() {
await getAuthMe();
} catch (error) {
if (error instanceof Response && error.status === 401) {
return redirect("/auth/login");
return redirect("/login");
}
throw error;
}

View file

@ -107,7 +107,7 @@ export default function SetupComplete() {
Authentication is ready.
</p>
<a
href="/auth/login"
href="/login"
className="mt-6 flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
Continue to sign in

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -61,7 +61,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-qx60vskn.js"></script>
<script type="module" src="/assets/entry-acc7dzjg.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -3,7 +3,7 @@
"app/**/*",
"scripts/**/*"
],
"exclude": ["**/*.test.ts"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node"],

View file

@ -1758,6 +1758,31 @@ mod tests {
assert!(location.starts_with("https://github.com/login/oauth/authorize?"));
}
#[tokio::test]
async fn logout_redirects_to_login_page() {
let app = test_app_with(test_db().await);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/logout")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/login")
);
}
#[tokio::test]
async fn static_favicon_is_served() {
let app = test_app_with(test_db().await);

View file

@ -211,7 +211,7 @@ async fn callback_github(
let cookie_jar = parse_cookie_header(&headers);
let stored_state = cookie_jar.get(OAUTH_STATE_COOKIE_NAME).map(|cookie| cookie.value());
if stored_state != Some(params.state.as_str()) {
return Redirect::to("/auth/login").into_response();
return Redirect::to("/login").into_response();
}
let Some(client_id) = settings.client_id().map(str::to_string) else {
@ -322,7 +322,7 @@ async fn callback_github(
.map(|web| web.auth.allowed_usernames.clone())
.unwrap_or_default();
if !allowed_usernames.is_empty() && !allowed_usernames.iter().any(|user| user == &profile.login) {
return Redirect::to("/auth/login?error=unauthorized").into_response();
return Redirect::to("/login?error=unauthorized").into_response();
}
let primary_email = emails
@ -374,7 +374,7 @@ async fn logout(State(state): State<Arc<AppState>>) -> Response {
.build(),
);
}
let mut response = Redirect::to("/auth/login").into_response();
let mut response = Redirect::to("/login").into_response();
append_jar_delta(response.headers_mut(), &jar);
response
}