fix(auth): retain protected deep-link state through sign-in
This commit is contained in:
@@ -21,4 +21,10 @@ Before application changes, the Login component regression run has **17passing/1
|
||||
|
||||
Existing session/account invalidation intentionally disposes the previous page/providers and replaces with `/login?sessionChanged=1`; previous-account private context must remain discarded. Backend ownership and role checks remain authoritative. Synthetic accounts/disposable SQLite only; no real external identity provider or production data.
|
||||
|
||||
Current repair status: **IN PROGRESS**. Original AUTH-01 remains open until GREEN and full validation. P5-15-02 and production are not cleared. Broader human/device/font qualification, historical preference500 causes, the CV Unsaved intermittent observation and P5-13 deferral remain open.
|
||||
## Bounded implementation checkpoint
|
||||
|
||||
`App.Shell` now carries pathname/search/hash plus the existing navigation context through the same landing/sign-in state. `safeAuthReturnPath` rejects external/protocol-relative targets, controls/backslashes, malformed encoding, recursively encoded unsafe paths and normalized authentication-entry loops, including case variants. It returns valid internal strings unchanged. Login uses that validated destination across local, two-factor, Google and Microsoft completion, replaying only validated existing Jobs return context. Account-disposal entries explicitly abandon old destinations. No URL storage, Analytics aggregation, authentication API or provider changes.
|
||||
|
||||
Final application changes have fresh **1,033/1,033 backend**, **490/490 frontend**, **31/31 focused Login**, the unchanged original native AUTH-01 **1/1**, and new native return/security journeys **5/5** passing. TypeScript, lint and both builds pass. The case-insensitive login-loop gap also has separate RED evidence before correction: two failures, two selected passes and27 not selected; final31 cases pass. External identity callbacks/two-factor are component fixtures, not live provider acceptance.
|
||||
|
||||
The complete browser/security/fidelity reconciliation is still running. Current checkpoint status: **IN PROGRESS**, with the demonstrated AUTH-01 repaired but no final acceptance claim yet. P5-15-02 and production are not cleared. Broader human/device/font qualification, historical preference500 causes, the CV Unsaved intermittent observation and P5-13 deferral remain open.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { expect, test as base, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
|
||||
const test = base.extend<{ email: string }>({
|
||||
email: async ({ playwright, context }, provide) => {
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const headers = await login(admin, 'e2e@example.test');
|
||||
const email = `analytics-auth-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); });
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
|
||||
? route.continue() : route.abort();
|
||||
});
|
||||
await provide(email);
|
||||
} finally { await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
async function submitLogin(page: Page, email: string, language: 'en' | 'nb' = 'en', value = password) {
|
||||
const t = translations[language];
|
||||
if (new URL(page.url()).pathname !== '/login') await page.getByRole('button', { name: t.signInTitle, exact: true }).first().click();
|
||||
await page.getByLabel(t.usernameOrEmail, { exact: true }).fill(email);
|
||||
await page.getByLabel(t.profileCurrentPassword, { exact: true }).fill(value);
|
||||
await page.getByRole('button', { name: t.signInTitle, exact: true }).click();
|
||||
}
|
||||
|
||||
test('[P5-15-01R-03 AUTH-01] native sign-in restores the non-default Analytics period', async ({ page, email }, info) => {
|
||||
await page.goto('/analytics?months=6');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await submitLogin(page, email);
|
||||
await expect(page.getByRole('heading', { name: translations.en.analytics, exact: true, level: 1 })).toBeVisible();
|
||||
await info.attach('analytics-native-auth-return', { body: JSON.stringify({ requested: '/analytics?months=6', returned: new URL(page.url()).pathname + new URL(page.url()).search }), contentType: 'application/json' });
|
||||
await expect(page).toHaveURL(/\/analytics\?months=6$/);
|
||||
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
function periodLabel(language: 'en' | 'nb', months: number) {
|
||||
return translations[language].dashboardMonthsShort.replace('{count}', String(months));
|
||||
}
|
||||
|
||||
function analyticsResponse(page: Page, months: number) {
|
||||
return page.waitForResponse(response => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/jobapplications/analytics' && url.searchParams.get('months') === String(months);
|
||||
});
|
||||
}
|
||||
|
||||
test('[P5-15-01R-03] Analytics encoded period, fragment and existing defaults survive native sign-in across locales/themes', async ({ page, context, email }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const results: { requested: string; returned: string; effectiveMonths: number; language: string; mode: string; width: number }[] = [];
|
||||
for (const variant of [
|
||||
{ language: 'en', mode: 'light', width: 1440, path: '/analytics?months=24#activity', months: 24 },
|
||||
{ language: 'nb', mode: 'dark', width: 390, path: '/analytics?months=%36#rapport', months: 6 },
|
||||
{ language: 'en', mode: 'dark', width: 768, path: '/analytics?months=12', months: 12 },
|
||||
{ language: 'nb', mode: 'light', width: 320, path: '/analytics?months=3#rapport', months: 12 },
|
||||
] as const) {
|
||||
const headers = await login(page.request, email);
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: variant.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.goto('about:blank');
|
||||
await context.clearCookies();
|
||||
await page.setViewportSize({ width: variant.width, height: 900 });
|
||||
await page.goto('/');
|
||||
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, variant);
|
||||
await page.goto(variant.path);
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
const report = analyticsResponse(page, variant.months);
|
||||
await submitLogin(page, email, variant.language);
|
||||
await expect(page).toHaveURL(`http://localhost:3300${variant.path}`);
|
||||
expect((await report).status()).toBe(200);
|
||||
await expect(page.getByRole('heading', { name: translations[variant.language].analytics, exact: true, level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: periodLabel(variant.language, variant.months), exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', variant.mode);
|
||||
const returned = new URL(page.url());
|
||||
results.push({ requested: variant.path, returned: returned.pathname + returned.search + returned.hash, effectiveMonths: variant.months, language: variant.language, mode: variant.mode, width: variant.width });
|
||||
const path = info.outputPath(`analytics-auth-${variant.language}-${variant.mode}-${variant.width}.png`);
|
||||
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
|
||||
await info.attach(`analytics-auth-${variant.language}-${variant.mode}-${variant.width}`, { path, contentType: 'image/png' });
|
||||
}
|
||||
await info.attach('analytics-auth-period-matrix', { body: JSON.stringify(results), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P5-15-01R-03] failed local sign-in preserves the destination; default, history, refresh and authenticated links stay correct', async ({ page, email }, info) => {
|
||||
await page.goto('/analytics');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
const failedLogin = page.waitForResponse(response => response.url() === `${apiUrl}/auth/login` && response.request().method() === 'POST');
|
||||
await submitLogin(page, email, 'en', 'IncorrectPassword123!');
|
||||
expect((await failedLogin).status()).toBe(401);
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Unauthorized' })).toBeVisible();
|
||||
const report = analyticsResponse(page, 12);
|
||||
await submitLogin(page, email);
|
||||
await expect(page).toHaveURL(/\/analytics$/);
|
||||
expect((await report).status()).toBe(200);
|
||||
await expect(page.getByRole('button', { name: '12 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: '6 mo', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/analytics\?months=6$/);
|
||||
await page.getByRole('button', { name: '24 mo', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/analytics\?months=24$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/analytics\?months=6$/);
|
||||
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/analytics$/);
|
||||
await expect(page.getByRole('button', { name: '12 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/analytics\?months=6$/);
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.goto('/analytics?months=24#activity');
|
||||
await expect(page).toHaveURL(/\/analytics\?months=24#activity$/);
|
||||
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await info.attach('analytics-native-history-and-authentication', { body: JSON.stringify({ failedLogin: 401, retryDefault: 12, backPeriods: [6, 12], forwardAndRefresh: 6, authenticatedDirect: 24 }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P5-15-01R-03] expired browser cookie retains the report return path; deliberate logout clears it', async ({ page, context, email }, info) => {
|
||||
await login(page.request, email);
|
||||
await page.goto('/analytics?months=24#activity');
|
||||
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
const authCookie = (await context.cookies()).find(cookie => cookie.name === 'jobtracker_auth');
|
||||
expect(authCookie).toBeDefined();
|
||||
await context.addCookies([{ ...authCookie!, expires: 1 }]);
|
||||
expect((await context.cookies()).some(cookie => cookie.name === 'jobtracker_auth')).toBe(false);
|
||||
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await submitLogin(page, email);
|
||||
await expect(page).toHaveURL(/\/analytics\?months=24#activity$/);
|
||||
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
|
||||
const loggedOut = page.waitForResponse(response => response.url() === `${apiUrl}/auth/logout`);
|
||||
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
|
||||
expect((await loggedOut).status()).toBe(204);
|
||||
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
|
||||
await submitLogin(page, email);
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await info.attach('native-expiry-versus-deliberate-logout', { body: JSON.stringify({ expiredCookieMe: 401, expiryReturn: '/analytics?months=24#activity', logout: 204, logoutSubsequentLogin: '/dashboard' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P5-15-01R-03 PRES-E09] another protected section keeps its destination without bypassing ownership or unsafe-target validation', async ({ page, playwright, email }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const owner = await playwright.request.newContext();
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const ownerHeaders = await login(owner, email);
|
||||
const company = await owner.post(`${apiUrl}/companies`, { headers: ownerHeaders, data: { name: 'Private Analytics-auth employer' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const job = await owner.post(`${apiUrl}/jobapplications`, { headers: ownerHeaders, data: { companyId: (await company.json()).id, jobTitle: 'Private Analytics-auth role', status: 'Saved' } });
|
||||
expect(job.status()).toBe(201);
|
||||
const jobId = (await job.json()).id;
|
||||
const destination = `/jobs/${jobId}?section=documents#documents`;
|
||||
await page.goto(destination);
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await submitLogin(page, email);
|
||||
await expect(page).toHaveURL(`http://localhost:3300${destination}`);
|
||||
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments, exact: true })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments, exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
|
||||
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const emailB = `analytics-auth-other-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200);
|
||||
await page.goto(destination);
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
const denied = page.waitForResponse(response => new URL(response.url()).pathname === `/api/jobapplications/${jobId}/workspace`);
|
||||
await submitLogin(page, emailB);
|
||||
await expect(page).toHaveURL(`http://localhost:3300${destination}`);
|
||||
expect((await denied).status()).toBe(404);
|
||||
await expect(page.getByText(translations.en.workspaceLoadFailed, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Private Analytics-auth role', { exact: true })).toHaveCount(0);
|
||||
expect((await page.request.get(`${apiUrl}/jobapplications/${jobId}`)).status()).toBe(404);
|
||||
expect((await (await owner.get(`${apiUrl}/jobapplications/${jobId}`)).json()).jobTitle).toBe('Private Analytics-auth role');
|
||||
|
||||
const rejected: string[] = [];
|
||||
for (const target of ['//outside.example.test/analytics', '/%2f%2foutside.example.test/analytics']) {
|
||||
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
|
||||
await page.goto('/login');
|
||||
await page.evaluate(from => { history.replaceState({ ...history.state, usr: { from } }, '', location.href); }, target);
|
||||
await page.reload();
|
||||
await submitLogin(page, emailB);
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
rejected.push(target);
|
||||
}
|
||||
await info.attach('native-return-target-and-ownership', { body: JSON.stringify({ ownedDestination: destination, foreignResource: 404, foreignWorkspace: 404, ownerReadbackUnchanged: true, rejectedTargets: rejected, fallback: '/dashboard' }), contentType: 'application/json' });
|
||||
} finally { await owner.dispose(); await admin.dispose(); }
|
||||
});
|
||||
@@ -290,7 +290,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
}, []);
|
||||
|
||||
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">{t("loading")}</Typography></Box>;
|
||||
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
|
||||
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: `${path}${location.search}${location.hash}`, fromState: location.state }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
const pageSubtitle = subtitleFor(path, t);
|
||||
|
||||
@@ -6,6 +6,28 @@ export const AUTH_USER_CHANGED_EVENT = "auth-user-changed";
|
||||
|
||||
export type AuthPersistence = "local" | "session";
|
||||
|
||||
export function safeAuthReturnPath(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\u0000-\u001f\u007f\\]/.test(value)) return undefined;
|
||||
try {
|
||||
decodeURIComponent(value);
|
||||
const target = new URL(value, window.location.origin);
|
||||
let path = target.pathname;
|
||||
// Validate decoded path meaning without changing encoded query/fragment navigation.
|
||||
for (;;) {
|
||||
const decoded = decodeURIComponent(path);
|
||||
if (decoded === path) break;
|
||||
path = decoded;
|
||||
}
|
||||
if (path.startsWith("//") || /[\u0000-\u001f\u007f\\]/.test(path)) return undefined;
|
||||
const normalized = new URL(path, window.location.origin);
|
||||
if (target.origin !== window.location.origin || normalized.origin !== window.location.origin
|
||||
|| ["/", "/login", "/register"].includes(normalized.pathname.replace(/\/+$/, "").toLowerCase() || "/")) return undefined;
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function safeGet(storage: Storage, key: string): string | null {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
|
||||
@@ -145,11 +145,11 @@ describe('LoginPage', () => {
|
||||
mockMsalLoginPopup.mockResolvedValueOnce({ idToken: 'synthetic-microsoft-token' });
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'microsoft' } } as any);
|
||||
|
||||
renderLoginPage('login', '/jobs');
|
||||
renderLoginPage('login', '/jobs?status=Applied#rows');
|
||||
await click(await screen.findByRole('button', { name: 'Continue with Microsoft' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/microsoft/exchange', { token: 'synthetic-microsoft-token', rememberMe: true }));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs', { replace: true }));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs?status=Applied#rows', { replace: true }));
|
||||
});
|
||||
|
||||
it('completes a Google credential return without account-linking copy', async () => {
|
||||
@@ -231,7 +231,7 @@ describe('LoginPage', () => {
|
||||
'https://evil.example/analytics', '//evil.example/analytics', '/\t/evil.example',
|
||||
'/%2f%2fevil.example', '/%252f%252fevil.example', '/%5cevil.example',
|
||||
'/analytics?months=%ZZ', '/jobs/../login', '/%6cogin', '/register',
|
||||
'/analytics%0a', '/analytics?months=6\n',
|
||||
'/analytics%0a', '/analytics?months=6\n', '/LOGIN', '/%4cOGIN',
|
||||
])('uses the safe fallback for the untrusted return target %j', async (from) => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
|
||||
renderLoginPage('login', from);
|
||||
@@ -302,7 +302,7 @@ describe('LoginPage', () => {
|
||||
});
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
renderLoginPage('login', '/analytics?months=24#activity');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
|
||||
@@ -317,7 +317,7 @@ describe('LoginPage', () => {
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456', trustDevice: false }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/analytics?months=24#activity', { replace: true }));
|
||||
});
|
||||
|
||||
it('shows a clear message when the 2FA challenge is rate-limited', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Paper, TextFie
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
||||
import { getRememberMePref, safeAuthReturnPath, setAuthPersistencePreference } from "../auth";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import TwoFactorChallenge from "../components/TwoFactorChallenge";
|
||||
@@ -47,13 +47,14 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const requestedNextPath = location?.state?.from;
|
||||
const nextPath = typeof requestedNextPath === "string"
|
||||
&& requestedNextPath.startsWith("/")
|
||||
&& !requestedNextPath.startsWith("//")
|
||||
&& !requestedNextPath.includes("\\")
|
||||
? requestedNextPath
|
||||
: "/dashboard";
|
||||
const returnPath = new URLSearchParams(location.search).has("sessionChanged") ? undefined : safeAuthReturnPath(location.state?.from);
|
||||
const nextPath = returnPath ?? "/dashboard";
|
||||
const fromState = location.state?.fromState;
|
||||
const contextPath = returnPath && safeAuthReturnPath(fromState?.from);
|
||||
const nextOptions = contextPath ? {
|
||||
replace: true,
|
||||
state: { from: contextPath, ...(Number.isSafeInteger(fromState.focusJobId) && fromState.focusJobId > 0 ? { focusJobId: fromState.focusJobId } : {}) },
|
||||
} : { replace: true };
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@@ -66,7 +67,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
setAuthPersistencePreference(rememberMe ? "local" : "session");
|
||||
await api.get("/auth/me");
|
||||
toast(t("signedIn"), "success");
|
||||
navigate(nextPath, { replace: true });
|
||||
navigate(nextPath, nextOptions);
|
||||
}
|
||||
|
||||
function validate(mode: "login" | "register") {
|
||||
@@ -265,8 +266,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
<Box sx={{ mt: 2.5 }}>
|
||||
<Divider sx={{ mb: 2 }}>{t("or")}</Divider>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{cfg?.googleEnabled ? <GoogleAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, { replace: true }); }} /> : null}
|
||||
{cfg?.microsoftEnabled ? <MicrosoftAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, { replace: true }); }} /> : null}
|
||||
{cfg?.googleEnabled ? <GoogleAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, nextOptions); }} /> : null}
|
||||
{cfg?.microsoftEnabled ? <MicrosoftAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, nextOptions); }} /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user