Fix Stale User Session with Proper fetch Error Handling (#4770)

* add refresh user functionality

* prettier

* add eslint disable comment for exhaustive-deps warning in AuthContext to stop nagging about navigate func

* remove unused imports and fix typo

* handle unsafe parse of undefined for in-session user deleted

* Refactor refreshUser function to handle errors and return structured response. Update AuthProvider to manage user data based on success status.

* Remove console error logging from promise catch in System model for cleaner error handling.

* change status from 404 to 400 and valid to success

* Refactor error handling in AuthProvider's refreshUser logic to remove redundant catch block and streamline user session management on failure.

* prettier

* reorder clauses - return errors

* refactor
account for all user modes
dev build

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
This commit is contained in:
Marcello Fitton 2025-12-12 11:43:20 -08:00 committed by GitHub
parent c76b0708c3
commit 8aa78c2b75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 107 additions and 3 deletions

View File

@ -6,7 +6,7 @@ concurrency:
on:
push:
branches: ['migrate-to-bcryptjs'] # put your current branch to create a build. Core team only.
branches: ['bug-user-session-stale'] # put your current branch to create a build. Core team only.
paths-ignore:
- '**.md'
- 'cloud-deployments/*'

View File

@ -1,20 +1,31 @@
import React, { useState, createContext } from "react";
import React, { useState, createContext, useEffect } from "react";
import {
AUTH_TIMESTAMP,
AUTH_TOKEN,
AUTH_USER,
USER_PROMPT_INPUT_MAP,
} from "@/utils/constants";
import System from "./models/system";
import { useNavigate } from "react-router-dom";
import { safeJsonParse } from "@/utils/request";
export const AuthContext = createContext(null);
export function AuthProvider(props) {
const localUser = localStorage.getItem(AUTH_USER);
const localAuthToken = localStorage.getItem(AUTH_TOKEN);
const [store, setStore] = useState({
user: localUser ? JSON.parse(localUser) : null,
user: localUser ? safeJsonParse(localUser, null) : null,
authToken: localAuthToken ? localAuthToken : null,
});
const navigate = useNavigate();
/* NOTE:
* 1. There's no reason for these helper functions to be stateful. They could
* just be regular funcs or methods on a basic object.
* 2. These actions are not being invoked anywhere in the
* codebase, dead code.
*/
const [actions] = useState({
updateUser: (user, authToken = "") => {
localStorage.setItem(AUTH_USER, JSON.stringify(user));
@ -30,6 +41,37 @@ export function AuthProvider(props) {
},
});
/*
* On initial mount and whenever the token changes, fetch a new user object
* If the user is suspended, (success === false and data === null) logout the user and redirect to the login page
* If success is true and data is not null, update the user object in the store (multi-user mode only)
* If success is true and data is null, do nothing (single-user mode only) with or without password protection
*/
useEffect(() => {
async function refreshUser() {
const { success, user: refreshedUser } = await System.refreshUser();
if (success && refreshedUser === null) return;
if (!success) {
localStorage.removeItem(AUTH_USER);
localStorage.removeItem(AUTH_TOKEN);
localStorage.removeItem(AUTH_TIMESTAMP);
localStorage.removeItem(USER_PROMPT_INPUT_MAP);
setStore({ user: null, authToken: null });
navigate("/login");
return;
}
localStorage.setItem(AUTH_USER, JSON.stringify(refreshedUser));
setStore((prev) => ({
...prev,
user: refreshedUser,
}));
}
if (store.authToken) refreshUser();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store.authToken]);
return (
<AuthContext.Provider value={{ store, actions }}>
{props.children}

View File

@ -83,6 +83,22 @@ const System = {
return { valid: false, message: e.message };
});
},
/**
* Refreshes the user object from the session.
* @returns {Promise<{success: boolean, user: Object | null, message: string | null}>}
*/
refreshUser: () => {
return fetch(`${API_BASE}/system/refresh-user`, {
headers: baseHeaders(),
})
.then((res) => {
if (!res.ok) throw new Error("Could not refresh user.");
return res.json();
})
.catch((e) => {
return { success: false, user: null, message: e.message };
});
},
recoverAccount: async function (username, recoveryCodes) {
return await fetch(`${API_BASE}/system/recover-account`, {
method: "POST",

View File

@ -114,6 +114,52 @@ function systemEndpoints(app) {
}
);
/**
* Refreshes the user object from the session from a provided token.
* This does not refresh the token itself - if that is expired or invalid, the user will be logged out.
* This simply keeps the user object in sync with the database over the course of the session.
* @returns {Promise<{success: boolean, user: Object | null, message: string | null}>}
*/
app.get(
"/system/refresh-user",
[validatedRequest],
async (request, response) => {
try {
if (!multiUserMode(response))
return response
.status(200)
.json({ success: true, user: null, message: null });
const user = await userFromSession(request, response);
if (!user)
return response.status(200).json({
success: false,
user: null,
message: "Session expired or invalid.",
});
if (user.suspended)
return response.status(200).json({
success: false,
user: null,
message: "User is suspended.",
});
return response.status(200).json({
success: true,
user: User.filterFields(user),
message: null,
});
} catch (e) {
return response.status(500).json({
success: false,
user: null,
message: e.message,
});
}
}
);
app.post("/request-token", async (request, response) => {
try {
const bcrypt = require("bcryptjs");