bugfix: organization not selected problem fixed. being logged out too often fixed.
This commit is contained in:
@@ -60,9 +60,12 @@ export const authApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Refresh token
|
||||
refreshToken: async (refreshToken: string): Promise<{ accessToken: string }> => {
|
||||
const response = await apiClient.post('/auth/refresh', { refreshToken });
|
||||
// Refresh access token using httpOnly cookies (same as the axios interceptor).
|
||||
refreshSessionFromCookies: async (): Promise<{
|
||||
success: boolean;
|
||||
data?: { accessToken?: string; accessTokenExpiresAt?: string };
|
||||
}> => {
|
||||
const response = await apiClient.post('/auth/refresh', {});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ function isPublicInvitationRequest(url: string | undefined): boolean {
|
||||
function shouldSkipRefreshRetry(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
return (
|
||||
url.includes('/auth/profile') ||
|
||||
url.includes('/auth/refresh') ||
|
||||
url.includes('/auth/login') ||
|
||||
url.includes('/auth/register') ||
|
||||
@@ -57,13 +56,29 @@ apiClient.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// ✅ refresh via cookie (no body needed ideally)
|
||||
// Refresh access token, then restore selected organization context on the new JWT.
|
||||
await axios.post(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
{ withCredentials: true },
|
||||
);
|
||||
|
||||
const orgId =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('currentOrganizationId')
|
||||
: null;
|
||||
if (orgId) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/auth/select-organization`,
|
||||
{ organizationId: orgId },
|
||||
{ withCredentials: true },
|
||||
);
|
||||
} catch {
|
||||
/* original retry may still succeed if refresh preserved organizationId */
|
||||
}
|
||||
}
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
37
frontend/src/lib/auth/accessToken.ts
Normal file
37
frontend/src/lib/auth/accessToken.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Decode JWT payload without verification — used only for coarse expiry checks in middleware. */
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
||||
const json =
|
||||
typeof atob === 'function'
|
||||
? atob(padded)
|
||||
: Buffer.from(padded, 'base64').toString('utf8');
|
||||
const payload = JSON.parse(json) as Record<string, unknown>;
|
||||
return payload && typeof payload === 'object' ? payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAccessTokenExpired(token: string | undefined | null): boolean {
|
||||
if (!token?.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = decodeJwtPayload(token);
|
||||
if (!payload || typeof payload.exp !== 'number') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return payload.exp * 1000 <= Date.now();
|
||||
}
|
||||
|
||||
export function hasUsableAccessToken(token: string | undefined | null): boolean {
|
||||
return Boolean(token?.trim()) && !isAccessTokenExpired(token);
|
||||
}
|
||||
155
frontend/src/lib/auth/proactiveRefresh.ts
Normal file
155
frontend/src/lib/auth/proactiveRefresh.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { authApi } from '@/lib/api/auth';
|
||||
|
||||
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
|
||||
/** Refresh this long before the access JWT expires. */
|
||||
const REFRESH_BUFFER_MS = 2 * 60 * 1000;
|
||||
/** Safety cap — never call /auth/refresh more than once per minute per tab. */
|
||||
const MIN_REFRESH_GAP_MS = 60 * 1000;
|
||||
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshInFlight: Promise<string | undefined> | null = null;
|
||||
let lastRefreshAt = 0;
|
||||
|
||||
export function setAccessTokenExpiresAt(iso: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(STORAGE_KEY, iso);
|
||||
}
|
||||
|
||||
export function clearAccessTokenExpiresAt(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function rememberAccessTokenExpiresAt(
|
||||
iso: string | undefined | null,
|
||||
): void {
|
||||
if (iso) {
|
||||
setAccessTokenExpiresAt(iso);
|
||||
}
|
||||
}
|
||||
|
||||
function getAccessTokenExpiresAtMs(): number | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ms = Date.parse(raw);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function computeDelayMs(expiresAtMs: number): number {
|
||||
const refreshAt = expiresAtMs - REFRESH_BUFFER_MS;
|
||||
const delay = refreshAt - Date.now();
|
||||
return Math.max(delay, MIN_REFRESH_GAP_MS);
|
||||
}
|
||||
|
||||
function shouldRefreshNow(expiresAtMs: number): boolean {
|
||||
return expiresAtMs - REFRESH_BUFFER_MS <= Date.now();
|
||||
}
|
||||
|
||||
async function refreshAccessTokenWithOrg(): Promise<string | undefined> {
|
||||
if (refreshInFlight) {
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
if (Date.now() - lastRefreshAt < MIN_REFRESH_GAP_MS) {
|
||||
const expiresAtMs = getAccessTokenExpiresAtMs();
|
||||
return expiresAtMs
|
||||
? new Date(expiresAtMs).toISOString()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const result = await authApi.refreshSessionFromCookies();
|
||||
const expiresAt = result.data?.accessTokenExpiresAt;
|
||||
rememberAccessTokenExpiresAt(expiresAt);
|
||||
|
||||
const orgId = localStorage.getItem('currentOrganizationId');
|
||||
if (orgId) {
|
||||
try {
|
||||
await authApi.selectOrganization(orgId);
|
||||
} catch {
|
||||
/* refresh may already preserve organizationId on the JWT */
|
||||
}
|
||||
}
|
||||
|
||||
lastRefreshAt = Date.now();
|
||||
return expiresAt;
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
function scheduleNextRefresh(reschedule: () => void): void {
|
||||
clearTimer();
|
||||
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAtMs = getAccessTokenExpiresAtMs();
|
||||
if (!expiresAtMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
timerId = setTimeout(() => {
|
||||
void refreshAccessTokenWithOrg()
|
||||
.catch(() => {
|
||||
/* reactive refresh / next visibility check will recover */
|
||||
})
|
||||
.finally(reschedule);
|
||||
}, computeDelayMs(expiresAtMs));
|
||||
}
|
||||
|
||||
/** Keeps the access cookie fresh while the tab is visible. Returns a cleanup function. */
|
||||
export function startProactiveSessionRefresh(): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const reschedule = () => scheduleNextRefresh(reschedule);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const expiresAtMs = getAccessTokenExpiresAtMs();
|
||||
if (expiresAtMs && shouldRefreshNow(expiresAtMs)) {
|
||||
void refreshAccessTokenWithOrg()
|
||||
.catch(() => undefined)
|
||||
.finally(reschedule);
|
||||
} else {
|
||||
reschedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
reschedule();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
clearTimer();
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
} from '@/lib/auth/rememberMe';
|
||||
import { User, Organization } from '@/types/organization';
|
||||
import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing';
|
||||
import {
|
||||
clearAccessTokenExpiresAt,
|
||||
rememberAccessTokenExpiresAt,
|
||||
startProactiveSessionRefresh,
|
||||
} from '@/lib/auth/proactiveRefresh';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
@@ -91,6 +96,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (response.success) {
|
||||
const { user: userData, organizations: orgs } = normalizeProfilePayload(response.data);
|
||||
rememberAccessTokenExpiresAt(
|
||||
(response.data as { accessTokenExpiresAt?: string }).accessTokenExpiresAt,
|
||||
);
|
||||
|
||||
setUser(userData);
|
||||
setOrganizations(orgs);
|
||||
@@ -99,17 +107,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (storedOrgId && orgs.length > 0) {
|
||||
const org = orgs.find(o => o.id === storedOrgId);
|
||||
if (org) {
|
||||
setCurrentOrganization(org);
|
||||
// Ensure cookie token carries organizationId for org-scoped APIs.
|
||||
await authApi.selectOrganization(org.id);
|
||||
const selected = await authApi.selectOrganization(org.id);
|
||||
setCurrentOrganization({
|
||||
id: selected.data.organization.id,
|
||||
name: selected.data.organization.name,
|
||||
type: selected.data.organization.type as Organization['type'],
|
||||
isOwner: Boolean(selected.data.organization.isOwner),
|
||||
permissions: selected.data.organization.permissions,
|
||||
plan: selected.data.organization.plan,
|
||||
});
|
||||
} else {
|
||||
setCurrentOrganization(null);
|
||||
}
|
||||
} else if (orgs.length === 1 && userData) {
|
||||
setCurrentOrganization(orgs[0]);
|
||||
const selected = await authApi.selectOrganization(orgs[0].id);
|
||||
localStorage.setItem('currentOrganizationId', orgs[0].id);
|
||||
// Keep JWT in sync with selected org even for single-org users.
|
||||
await authApi.selectOrganization(orgs[0].id);
|
||||
setCurrentOrganization({
|
||||
id: selected.data.organization.id,
|
||||
name: selected.data.organization.name,
|
||||
type: selected.data.organization.type as Organization['type'],
|
||||
isOwner: Boolean(selected.data.organization.isOwner),
|
||||
permissions: selected.data.organization.permissions,
|
||||
plan: selected.data.organization.plan,
|
||||
});
|
||||
} else {
|
||||
setCurrentOrganization(null);
|
||||
}
|
||||
@@ -119,6 +139,61 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
(err as { statusCode?: number })?.statusCode ??
|
||||
(err as { response?: { status?: number } })?.response?.status;
|
||||
|
||||
// Access token may have expired while refresh cookie is still valid (e.g. JWT_EXPIRES_IN=15m).
|
||||
if (status === 401) {
|
||||
try {
|
||||
const refreshResult = await authApi.refreshSessionFromCookies();
|
||||
rememberAccessTokenExpiresAt(refreshResult.data?.accessTokenExpiresAt);
|
||||
const orgId = localStorage.getItem('currentOrganizationId');
|
||||
if (orgId) {
|
||||
await authApi.selectOrganization(orgId);
|
||||
}
|
||||
const retry = await authApi.getProfile();
|
||||
if (retry.success) {
|
||||
const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data);
|
||||
rememberAccessTokenExpiresAt(
|
||||
(retry.data as { accessTokenExpiresAt?: string }).accessTokenExpiresAt,
|
||||
);
|
||||
setUser(userData);
|
||||
setOrganizations(orgs);
|
||||
|
||||
const storedOrgId = localStorage.getItem('currentOrganizationId');
|
||||
if (storedOrgId && orgs.length > 0) {
|
||||
const org = orgs.find((o) => o.id === storedOrgId);
|
||||
if (org) {
|
||||
const selected = await authApi.selectOrganization(org.id);
|
||||
setCurrentOrganization({
|
||||
id: selected.data.organization.id,
|
||||
name: selected.data.organization.name,
|
||||
type: selected.data.organization.type as Organization['type'],
|
||||
isOwner: Boolean(selected.data.organization.isOwner),
|
||||
permissions: selected.data.organization.permissions,
|
||||
plan: selected.data.organization.plan,
|
||||
});
|
||||
} else {
|
||||
setCurrentOrganization(null);
|
||||
}
|
||||
} else if (orgs.length === 1 && userData) {
|
||||
const selected = await authApi.selectOrganization(orgs[0].id);
|
||||
localStorage.setItem('currentOrganizationId', orgs[0].id);
|
||||
setCurrentOrganization({
|
||||
id: selected.data.organization.id,
|
||||
name: selected.data.organization.name,
|
||||
type: selected.data.organization.type as Organization['type'],
|
||||
isOwner: Boolean(selected.data.organization.isOwner),
|
||||
permissions: selected.data.organization.permissions,
|
||||
plan: selected.data.organization.plan,
|
||||
});
|
||||
} else {
|
||||
setCurrentOrganization(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to logged-out state */
|
||||
}
|
||||
}
|
||||
|
||||
// 401 on profile is expected when there is no session — not an application error.
|
||||
if (status !== 401) {
|
||||
console.error('Auth check failed:', err);
|
||||
@@ -128,6 +203,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(null);
|
||||
setOrganizations([]);
|
||||
setCurrentOrganization(null);
|
||||
clearAccessTokenExpiresAt();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsAuthReady(true);
|
||||
@@ -138,6 +214,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
void checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !isAuthReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
return startProactiveSessionRefresh();
|
||||
}, [user, isAuthReady]);
|
||||
|
||||
const applyUrlLocaleToUser = useCallback(async (user: User): Promise<User> => {
|
||||
if (typeof window === 'undefined') return user;
|
||||
|
||||
@@ -180,6 +264,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
|
||||
const userData = await applyUrlLocaleToUser(response.data.user);
|
||||
rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt);
|
||||
setUser(userData);
|
||||
setOrganizations(response.data.organizations);
|
||||
|
||||
@@ -223,6 +308,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const userData = await applyUrlLocaleToUser(response.data.user);
|
||||
rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt);
|
||||
setUser(userData);
|
||||
setOrganizations(response.data.organizations);
|
||||
|
||||
@@ -263,6 +349,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setOrganizations([]);
|
||||
setCurrentOrganization(null);
|
||||
setError(null);
|
||||
clearAccessTokenExpiresAt();
|
||||
setIsAuthReady(true);
|
||||
router.replace('/');
|
||||
router.refresh();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
routing,
|
||||
stripLocaleFromPathname,
|
||||
} from './i18n/routing';
|
||||
import { hasUsableAccessToken } from './lib/auth/accessToken';
|
||||
|
||||
const handleI18nRouting = createMiddleware(routing);
|
||||
|
||||
@@ -36,7 +37,7 @@ export function proxy(request: NextRequest) {
|
||||
const pathWithoutLocale = stripLocaleFromPathname(pathname);
|
||||
const locale = getLocaleFromPathname(pathname);
|
||||
const token = request.cookies.get('accessToken')?.value;
|
||||
const isAuthenticated = !!token;
|
||||
const isAuthenticated = hasUsableAccessToken(token);
|
||||
|
||||
if (isAuthenticated && pathWithoutLocale === '/') {
|
||||
return NextResponse.redirect(new URL(`/${locale}/today`, request.url));
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface AuthResponse {
|
||||
data: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresAt?: string;
|
||||
user: User;
|
||||
organizations: Organization[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user