# Fix: Preserve Redirect Destination After Login

**Date:** 2026-03-13  
**PR:** [#267](https://github.com/tidbcloud/aiops/pull/267)

---

## Problem

When a user was not authenticated (or their session expired), the app redirected them to `/login`. After a successful login, they were always sent to the home page (`/`) instead of the page they originally tried to visit.

This was a poor experience for deep-linked workspace URLs (e.g. `/workspace/ws-xxx`): users had to manually navigate back after logging in.

Additionally, if a user tried to access another user's private workspace they would receive a 403 / empty response, but there was no redirect to the workspace list either.

---

## Root Cause

- `fetchWithAuth()` in `lib/auth.ts` did an unconditional `window.location.replace('/login')` on any 401 response, discarding the current URL.
- The `!isAuthenticated()` guard in `app/workspace/[id]/page.tsx` called `router.replace('/login')` with no context.
- The login page (`app/login/page.tsx`) always redirected to `/` after success, ignoring any intended destination.
- The Auth0 OAuth callback (`app/auth/callback/page.tsx`) also always redirected to `/` after exchanging the token.

---

## Fix

### 1. `frontend/lib/auth.ts` — encode current path on 401

`fetchWithAuth()` now appends the current `pathname + search` as a `redirect` query parameter when redirecting to `/login`:

```ts
const current = window.location.pathname + window.location.search;
const loginUrl = current !== '/login'
  ? `/login?redirect=${encodeURIComponent(current)}`
  : '/login';
window.location.replace(loginUrl);
```

### 2. `frontend/app/workspace/[id]/page.tsx` — same for the client-side auth guard

The synchronous `!isAuthenticated()` check now also passes the current path:

```ts
const current = window.location.pathname + window.location.search;
router.replace(`/login?redirect=${encodeURIComponent(current)}`);
```

### 3. `frontend/app/login/page.tsx` — use redirect after email/password login

After a successful email/password login the page reads the `?redirect=` param and navigates there. The target is validated to begin with `/` and not `//` to prevent open redirect attacks:

```ts
const redirectTo = searchParams.get('redirect');
const target =
  redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')
    ? redirectTo
    : '/';
router.replace(target);
```

For the **Google / Auth0 OAuth flow**, the redirect destination cannot be kept in the URL (Auth0 navigates to its own domain and back). It is therefore stashed in `sessionStorage` before the redirect:

```ts
const redirectTo = searchParams.get('redirect');
if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) {
  sessionStorage.setItem('investibook_login_redirect', redirectTo);
}
auth0.loginWithRedirect({ ... });
```

### 4. `frontend/app/auth/callback/page.tsx` — pop redirect from sessionStorage

After the Auth0 token exchange (and in the already-authenticated fast-path), the callback page pops the stored redirect and navigates there:

```ts
const stored = sessionStorage.getItem('investibook_login_redirect');
sessionStorage.removeItem('investibook_login_redirect');
const target =
  stored && stored.startsWith('/') && !stored.startsWith('//')
    ? stored
    : '/';
router.replace(target);
```

---

## Behaviour Table

| Scenario | Before | After |
|---|---|---|
| Unauthenticated visit to `/workspace/ws-xxx` | Redirect to `/login`, then always `/` | Redirect to `/login?redirect=%2Fworkspace%2Fws-xxx`, then back to `/workspace/ws-xxx` |
| Session expires (401 from API) | Redirect to `/login`, then always `/` | Same redirect-back behaviour |
| Access another user's private workspace (403) | Broken / stayed on workspace page | Redirected to `/` (workspace list) — unchanged, already working |
| Google SSO login with redirect param | Redirect destination lost during OAuth | Destination stashed in `sessionStorage`, restored after callback |

---

## Security Notes

- All redirect targets are validated to start with `/` and **not** `//` before use, preventing open redirect to arbitrary external URLs.
- `sessionStorage` is used (not `localStorage`) so the stashed redirect is scoped to the current browser tab and cleared automatically when the tab closes.
