Compare commits

..

2 Commits

Author SHA1 Message Date
136f41020f Deduplicate concurrent token refresh to fix login redirect loop
The dashboard fires several authenticated requests at once (summary,
cashflow, merchants, accounts, transactions, plus the app shell's own
profile fetch). Once the 60-second access-token cookie expires, all
of them 401 together, and apiFetch had each one independently call
/api/auth/refresh. Refresh tokens are single-use and rotate on the
backend, so only the first of these racing calls succeeded — the
rest sent an already-consumed refresh token, got rejected, cleared
auth cookies, and hard-redirected to /login. Symptom: land in the
app, then get bounced back to login almost immediately, repeatedly.

Fix: share one in-flight refresh promise across all callers so a
burst of concurrent 401s triggers exactly one refresh call.
2026-08-26 18:24:08 +05:30
081862e1d7 Clarify sign-up link on the login page
The only path to /register read as marketing copy ("Start your
14-day free trial") rather than an obvious sign-up action. Added a
clearer lead-in and a second, redundant link at the bottom of the
form to match the register page's existing "Sign in" link.
2026-08-26 18:24:08 +05:30
2 changed files with 42 additions and 18 deletions

View File

@ -211,6 +211,13 @@ function LoginForm() {
<p className={`text-sm font-medium text-center ${isError ? "text-red-600 dark:text-red-400" : "text-foreground"}`}>{status}</p> <p className={`text-sm font-medium text-center ${isError ? "text-red-600 dark:text-red-400" : "text-foreground"}`}>{status}</p>
</div> </div>
)} )}
<p className="text-center text-sm text-muted-foreground">
Don&rsquo;t have an account?{" "}
<Link href="/register" className="font-semibold text-primary hover:underline">
Sign up
</Link>
</p>
</form> </form>
); );
} }
@ -253,7 +260,8 @@ export default function LoginPage() {
Sign In Sign In
</h2> </h2>
<p className="mt-2 text-center text-sm text-muted-foreground"> <p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors"> New to Aarthalabs?{" "}
<Link href="/register" className="font-semibold text-primary hover:underline">
Start your 14-day free trial Start your 14-day free trial
</Link> </Link>
</p> </p>

View File

@ -34,25 +34,41 @@ export function clearAuth(): void {
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
} }
// Shared in-flight refresh promise so concurrent 401s (e.g. several widgets
// loading at once) all await the same refresh call instead of each firing
// their own — refresh tokens are single-use, so racing calls would otherwise
// invalidate each other and force a false session-expired logout.
let refreshInFlight: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> { async function tryRefresh(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
clearAuth();
return false;
}
const payload = (await res.json()) as ApiResponse<unknown>;
if (payload.error) {
clearAuth();
return false;
}
return true;
} catch {
clearAuth();
return false;
}
})();
try { try {
const res = await fetch("/api/auth/refresh", { return await refreshInFlight;
method: "POST", } finally {
headers: { "Content-Type": "application/json" }, refreshInFlight = null;
});
if (!res.ok) {
clearAuth();
return false;
}
const payload = (await res.json()) as ApiResponse<unknown>;
if (payload.error) {
clearAuth();
return false;
}
return true;
} catch {
clearAuth();
return false;
} }
} }