/ frontend / src / app / contexts / JWTAuthContext.js
JWTAuthContext.js
  1  import { createContext, useEffect, useReducer } from "react";
  2  import axios from "axios";
  3  import { MatxLoading } from "app/components";
  4  import { applyLanguage } from "app/i18n";
  5  
  6  const initialState = {
  7    user: null,
  8    isInitialized: false,
  9    isAuthenticated: false,
 10    isImpersonating: false,
 11  };
 12  
 13  function assignRole(user) {
 14    if (user.is_admin) user.role = "ADMIN";
 15    else if (user.admin_teams && user.admin_teams.length > 0) user.role = "TEAM_ADMIN";
 16    else user.role = "USER";
 17  }
 18  
 19  const reducer = (state, action) => {
 20    switch (action.type) {
 21      case "INIT": {
 22        const { isAuthenticated, user, isImpersonating } = action.payload;
 23        return { ...state, isAuthenticated, isInitialized: true, user, isImpersonating: isImpersonating || false };
 24      }
 25  
 26      case "LOGIN": {
 27        return { ...state, isAuthenticated: true, user: action.payload.user };
 28      }
 29  
 30      case "LOGOUT": {
 31        return { ...state, isAuthenticated: false, user: null, isImpersonating: false };
 32      }
 33  
 34      default:
 35        return state;
 36    }
 37  };
 38  
 39  const AuthContext = createContext({
 40    ...initialState,
 41    method: "JWT",
 42    login: () => {},
 43    verifyTotp: () => {},
 44    checkAuth: () => {},
 45    logout: () => {},
 46    impersonate: () => {},
 47    exitImpersonation: () => {},
 48  });
 49  
 50  export const AuthProvider = ({ children }) => {
 51    const [state, dispatch] = useReducer(reducer, initialState);
 52  
 53    const apiUrl = process.env.REACT_APP_RESTAI_API_URL || "";
 54  
 55    const login = async (email, password) => {
 56      try {
 57        const response = await axios.post(
 58          `${apiUrl}/auth/login`,
 59          {},
 60          { auth: { username: email, password: password } }
 61        );
 62  
 63        const data = response.data;
 64  
 65        // 2FA required — return token for TOTP verification
 66        if (data.requires_totp) {
 67          return { requires_totp: true, totp_token: data.totp_token };
 68        }
 69  
 70        // Stash the password-age warning so the first authenticated page
 71        // can surface it. One-shot: the banner component clears the key
 72        // after rendering once per login session.
 73        if (data.password_warning) {
 74          try { sessionStorage.setItem("password_warning", JSON.stringify(data.password_warning)); } catch {}
 75        }
 76  
 77        // Normal login — fetch user profile
 78        const whoami = await axios.get(`${apiUrl}/auth/whoami`, { withCredentials: true });
 79        const user = whoami.data;
 80        assignRole(user);
 81        // Apply the user's saved UI language (if any) as soon as whoami
 82        // resolves. `options` is either a UserOptions dict or (legacy)
 83        // a JSON-string — probe for both shapes.
 84        try {
 85          const opts = typeof user.options === "string" ? JSON.parse(user.options) : user.options;
 86          if (opts && opts.language) applyLanguage(opts.language);
 87        } catch {}
 88  
 89        dispatch({ type: "INIT", payload: { isAuthenticated: true, user, isImpersonating: false } });
 90        return { requires_totp: false };
 91      } catch (err) {
 92        const detail = err.response?.data?.detail || "Login failed. Check your credentials.";
 93        throw new Error(detail);
 94      }
 95    };
 96  
 97    const verifyTotp = async (token, code) => {
 98      try {
 99        const response = await axios.post(`${apiUrl}/auth/verify-totp`, { token, code }, { withCredentials: true });
100        if (response.data && response.data.password_warning) {
101          try { sessionStorage.setItem("password_warning", JSON.stringify(response.data.password_warning)); } catch {}
102        }
103        const whoami = await axios.get(`${apiUrl}/auth/whoami`, { withCredentials: true });
104        const user = whoami.data;
105        assignRole(user);
106        // Apply the user's saved UI language (if any) as soon as whoami
107        // resolves. `options` is either a UserOptions dict or (legacy)
108        // a JSON-string — probe for both shapes.
109        try {
110          const opts = typeof user.options === "string" ? JSON.parse(user.options) : user.options;
111          if (opts && opts.language) applyLanguage(opts.language);
112        } catch {}
113        dispatch({ type: "INIT", payload: { isAuthenticated: true, user, isImpersonating: false } });
114      } catch (err) {
115        const detail = err.response?.data?.detail || "Invalid code.";
116        throw new Error(detail);
117      }
118    };
119  
120    const checkAuth = async () => {
121      try {
122        const response = await axios.get(`${apiUrl}/auth/whoami`, { withCredentials: true });
123        const user = response.data;
124        assignRole(user);
125        // Apply the user's saved UI language (if any) as soon as whoami
126        // resolves. `options` is either a UserOptions dict or (legacy)
127        // a JSON-string — probe for both shapes.
128        try {
129          const opts = typeof user.options === "string" ? JSON.parse(user.options) : user.options;
130          if (opts && opts.language) applyLanguage(opts.language);
131        } catch {}
132        dispatch({ type: "INIT", payload: { isAuthenticated: true, user, isImpersonating: user.impersonating || false } });
133      } catch (err) {
134        dispatch({ type: "LOGOUT" });
135      }
136    };
137  
138    const logout = () => {
139      localStorage.removeItem("user");
140      axios.post(`${apiUrl}/auth/logout`, {}, { withCredentials: true }).catch(console.error);
141      dispatch({ type: "LOGOUT" });
142    };
143  
144    const impersonate = async (username) => {
145      try {
146        await axios.post(`${apiUrl}/auth/impersonate/${username}`, {}, { withCredentials: true });
147        await checkAuth();
148      } catch (err) {
149        console.error("Impersonation failed:", err);
150      }
151    };
152  
153    const exitImpersonation = async () => {
154      try {
155        await axios.post(`${apiUrl}/auth/exit-impersonation`, {}, { withCredentials: true });
156        await checkAuth();
157      } catch (err) {
158        console.error("Exit impersonation failed:", err);
159      }
160    };
161  
162    useEffect(() => {
163      (async () => {
164        try {
165          const response = await axios.get(`${apiUrl}/auth/whoami`, { withCredentials: true });
166          const user = response.data;
167          assignRole(user);
168        // Apply the user's saved UI language (if any) as soon as whoami
169        // resolves. `options` is either a UserOptions dict or (legacy)
170        // a JSON-string — probe for both shapes.
171        try {
172          const opts = typeof user.options === "string" ? JSON.parse(user.options) : user.options;
173          if (opts && opts.language) applyLanguage(opts.language);
174        } catch {}
175          dispatch({ type: "INIT", payload: { isAuthenticated: true, user, isImpersonating: user.impersonating || false } });
176        } catch (err) {
177          console.error(err);
178          dispatch({ type: "INIT", payload: { isAuthenticated: false, user: null, isImpersonating: false } });
179        }
180      })();
181    }, []);
182  
183    if (!state.isInitialized) return <MatxLoading />;
184  
185    return (
186      <AuthContext.Provider value={{ ...state, method: "JWT", login, verifyTotp, checkAuth, logout, impersonate, exitImpersonation }}>
187        {children}
188      </AuthContext.Provider>
189    );
190  };
191  
192  export default AuthContext;