Phase 3.4: control_center self-managed auth cutover

Replaces Firebase Auth with the new JWT + httpOnly-cookie refresh flow.
Smoke-tested end-to-end via curl (login → /me → refresh rotation → logout).

- Remove firebase dep + firebase.js
- New token-bridge decouples api-client from AuthContext and de-dupes
  concurrent 401 refreshes
- AuthContext: in-memory access token (useRef), bootstrap via
  /internal/auth/refresh, login/logout/refresh methods
- api-client: withCredentials, Bearer attach, auto-retry once on 401
- LoginPage: handle INVALID_CREDENTIALS / ACCOUNT_LOCKED / VALIDATION_ERROR
- Layout: self-service "Ganti password" form
- UsersPage: initial password field on create + per-row admin-forced reset
- .env / .env.example: drop VITE_FIREBASE_* vars
- backend/CLAUDE.md + control_center/CLAUDE.md: describe new auth (were
  stale on Firebase)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 15:32:32 +08:00
parent 1a610363bb
commit 4a796277b8
12 changed files with 307 additions and 1086 deletions

View File

@@ -1,15 +1,40 @@
import axios from 'axios'
import { auth } from '../auth/firebase'
import {
readAccessToken,
refreshAccessToken,
notifyUnauthenticated,
} from '../auth/token-bridge'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true, // send httpOnly cc_refresh_token cookie on refresh calls
})
apiClient.interceptors.request.use(async (config) => {
const user = auth.currentUser
if (user) {
const token = await user.getIdToken()
config.headers.Authorization = `Bearer ${token}`
}
apiClient.interceptors.request.use((config) => {
const token = readAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
apiClient.interceptors.response.use(
(res) => res,
async (err) => {
const original = err.config
const status = err.response?.status
const url = original?.url || ''
// Never try to refresh on the refresh endpoint itself — that's a terminal failure.
const isRefreshCall = url.includes('/internal/auth/refresh')
if (status === 401 && original && !original._retry && !isRefreshCall) {
original._retry = true
const newToken = await refreshAccessToken()
if (newToken) {
original.headers.Authorization = `Bearer ${newToken}`
return apiClient(original)
}
notifyUnauthenticated()
}
return Promise.reject(err)
},
)

View File

@@ -1,37 +1,91 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { signInWithEmailAndPassword, signOut, onAuthStateChanged } from 'firebase/auth'
import { auth } from './firebase'
import { apiClient } from '../api/api-client'
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
import axios from 'axios'
import { registerAuthBridge } from './token-bridge'
const AuthContext = createContext(null)
const BASE_URL = import.meta.env.VITE_API_BASE_URL
// Raw axios (not apiClient) for auth calls — avoids the interceptor
// triggering a refresh loop while we're the one doing the refresh.
const authAxios = axios.create({
baseURL: BASE_URL,
withCredentials: true,
})
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const unsub = onAuthStateChanged(auth, async (firebaseUser) => {
if (firebaseUser) {
try {
const res = await apiClient.post('/internal/auth/verify')
setUser(res.data.data)
} catch {
await signOut(auth)
setUser(null)
}
} else {
setUser(null)
}
setLoading(false)
})
return unsub
// Keep access token in a ref so api-client (via bridge) always reads the
// latest value synchronously — React state updates are async.
const accessTokenRef = useRef(null)
const clearSession = useCallback(() => {
accessTokenRef.current = null
setUser(null)
}, [])
const login = (email, password) => signInWithEmailAndPassword(auth, email, password)
const logout = () => signOut(auth)
const applyTokens = useCallback((accessToken, profile) => {
accessTokenRef.current = accessToken
setUser(profile)
}, [])
const login = useCallback(async (email, password) => {
const res = await authAxios.post('/internal/auth/login', { email, password })
const { access_token, profile } = res.data.data
applyTokens(access_token, profile)
}, [applyTokens])
const refresh = useCallback(async () => {
try {
const res = await authAxios.post('/internal/auth/refresh')
const { access_token, profile } = res.data.data
applyTokens(access_token, profile)
return access_token
} catch {
clearSession()
return null
}
}, [applyTokens, clearSession])
const logout = useCallback(async () => {
try {
await authAxios.post('/internal/auth/logout', null, {
headers: accessTokenRef.current ? { Authorization: `Bearer ${accessTokenRef.current}` } : {},
})
} catch {
// server-side session may already be gone; always clear locally
}
clearSession()
}, [clearSession])
// Wire the bridge once — api-client imports token-bridge directly and
// reads through these closures, so identity doesn't matter as long as
// they read fresh state.
useEffect(() => {
registerAuthBridge({
getAccessToken: () => accessTokenRef.current,
runRefresh: refresh,
onUnauthenticated: clearSession,
})
}, [refresh, clearSession])
// Bootstrap: try to refresh using the httpOnly cookie. If none / expired,
// we stay unauthenticated and the router sends us to /login.
useEffect(() => {
let cancelled = false
;(async () => {
await refresh()
if (!cancelled) setLoading(false)
})()
return () => { cancelled = true }
// refresh is stable enough — we only want this to run on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
)

View File

@@ -1,11 +0,0 @@
import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
}
const app = initializeApp(firebaseConfig)
export const auth = getAuth(app)

View File

@@ -0,0 +1,33 @@
// Bridge between AuthContext (owner of the access token) and api-client
// (needs it on every request). AuthContext registers getters/setters here
// on mount; api-client reads them. Avoids circular imports + lets us
// de-duplicate concurrent 401 refreshes via a shared in-flight promise.
let getAccessToken = () => null
let runRefresh = async () => null
let onUnauthenticated = () => {}
let refreshInFlight = null
export const registerAuthBridge = ({ getAccessToken: g, runRefresh: r, onUnauthenticated: u }) => {
getAccessToken = g
runRefresh = r
onUnauthenticated = u
}
export const readAccessToken = () => getAccessToken()
export const refreshAccessToken = async () => {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
return await runRefresh()
} finally {
refreshInFlight = null
}
})()
}
return refreshInFlight
}
export const notifyUnauthenticated = () => onUnauthenticated()