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:
@@ -1,8 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, NavLink } from 'react-router-dom'
|
||||
import { useAuth } from '../core/auth/AuthContext'
|
||||
import { apiClient } from '../core/api/api-client'
|
||||
|
||||
const PasswordChangeForm = ({ onDone }) => {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiClient.patch('/internal/control-center-users/me/password', {
|
||||
current_password: current,
|
||||
new_password: next,
|
||||
})
|
||||
setSuccess(true)
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
} catch (err) {
|
||||
const code = err?.response?.data?.error?.code
|
||||
const msg = err?.response?.data?.error?.message
|
||||
if (code === 'INVALID_CREDENTIALS') setError('Password saat ini salah.')
|
||||
else if (code?.startsWith('PASSWORD_')) setError(msg || 'Password tidak memenuhi syarat.')
|
||||
else setError('Gagal mengubah password.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ padding: 8, border: '1px solid #eee', marginTop: 8 }}>
|
||||
<input type="password" placeholder="Password lama" value={current}
|
||||
onChange={e => setCurrent(e.target.value)} required
|
||||
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
|
||||
<input type="password" placeholder="Password baru (min 8, huruf besar/kecil + angka)" value={next}
|
||||
onChange={e => setNext(e.target.value)} required minLength={8}
|
||||
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
|
||||
{error && <p style={{ color: 'red', margin: '4px 0', fontSize: 12 }}>{error}</p>}
|
||||
{success && <p style={{ color: 'green', margin: '4px 0', fontSize: 12 }}>Password berhasil diubah.</p>}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="submit" disabled={saving}>{saving ? '...' : 'Simpan'}</button>
|
||||
<button type="button" onClick={onDone}>Tutup</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const [showPwForm, setShowPwForm] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
@@ -18,7 +69,9 @@ export default function Layout() {
|
||||
</ul>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
|
||||
<p style={{ fontSize: 12 }}>{user?.email}</p>
|
||||
<button onClick={() => setShowPwForm(v => !v)} style={{ marginRight: 6 }}>Ganti password</button>
|
||||
<button onClick={logout}>Logout</button>
|
||||
{showPwForm && <PasswordChangeForm onDone={() => setShowPwForm(false)} />}
|
||||
</div>
|
||||
</nav>
|
||||
<main style={{ flex: 1, padding: 24 }}>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
33
control_center/src/core/auth/token-bridge.js
Normal file
33
control_center/src/core/auth/token-bridge.js
Normal 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()
|
||||
@@ -2,6 +2,21 @@ import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../core/auth/AuthContext'
|
||||
|
||||
const messageForError = (err) => {
|
||||
const code = err?.response?.data?.error?.code
|
||||
const msg = err?.response?.data?.error?.message
|
||||
switch (code) {
|
||||
case 'ACCOUNT_LOCKED':
|
||||
return msg || 'Akun terkunci sementara. Coba lagi nanti.'
|
||||
case 'INVALID_CREDENTIALS':
|
||||
return 'Email atau password salah.'
|
||||
case 'VALIDATION_ERROR':
|
||||
return 'Email dan password wajib diisi.'
|
||||
default:
|
||||
return 'Gagal masuk. Coba lagi.'
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, loading: authLoading, login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
@@ -20,12 +35,14 @@ export default function LoginPage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
} catch {
|
||||
setError('Email atau password salah.')
|
||||
} catch (err) {
|
||||
setError(messageForError(err))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) return <div style={{ padding: 24 }}>Loading...</div>
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}>
|
||||
<h1>Halo Bestie</h1>
|
||||
|
||||
@@ -17,21 +17,83 @@ const createUser = async (data) => {
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
const resetPassword = async ({ id, new_password }) => {
|
||||
const res = await apiClient.patch(`/internal/control-center-users/${id}/password`, { new_password })
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
// Generate a temporary password that meets backend complexity rules:
|
||||
// min 8 chars, ≥1 digit, ≥1 uppercase, ≥1 lowercase.
|
||||
const generateTempPassword = () => {
|
||||
const raw = crypto.randomUUID().replace(/-/g, '').slice(0, 12)
|
||||
// Ensure uppercase + lowercase + digit — crypto.randomUUID is lowercase hex,
|
||||
// so we explicitly prefix to guarantee complexity.
|
||||
return `A${raw}9`
|
||||
}
|
||||
|
||||
const errorMessage = (err) => {
|
||||
const code = err?.response?.data?.error?.code
|
||||
const msg = err?.response?.data?.error?.message
|
||||
if (code?.startsWith('PASSWORD_')) return msg || 'Password tidak memenuhi syarat.'
|
||||
if (code === 'VALIDATION_ERROR') return msg || 'Input tidak lengkap.'
|
||||
if (code === 'EMAIL_ALREADY_EXISTS' || code === 'EMAIL_TAKEN') return 'Email sudah digunakan.'
|
||||
return msg || 'Gagal menyimpan.'
|
||||
}
|
||||
|
||||
const ResetPasswordRow = ({ userId }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pw, setPw] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: () => {
|
||||
setSuccess(true)
|
||||
setPw('')
|
||||
},
|
||||
onError: (err) => setError(errorMessage(err)),
|
||||
})
|
||||
|
||||
if (!open) {
|
||||
return <button onClick={() => { setOpen(true); setSuccess(false); setError('') }}>Reset password</button>
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); setError(''); mutation.mutate({ id: userId, new_password: pw }) }}
|
||||
style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="text" placeholder="Password baru" value={pw}
|
||||
onChange={e => setPw(e.target.value)} required minLength={8}
|
||||
style={{ width: 180 }} />
|
||||
<button type="button" onClick={() => setPw(generateTempPassword())}>Generate</button>
|
||||
<button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '...' : 'Simpan'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setOpen(false); setError(''); setSuccess(false) }}>Batal</button>
|
||||
{error && <span style={{ color: 'red', fontSize: 12 }}>{error}</span>}
|
||||
{success && <span style={{ color: 'green', fontSize: 12 }}>Tersimpan.</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['cc-users'], queryFn: fetchUsers })
|
||||
const { data: roles } = useQuery({ queryKey: ['roles'], queryFn: fetchRoles })
|
||||
|
||||
const [form, setForm] = useState({ email: '', display_name: '', role_id: '' })
|
||||
const [form, setForm] = useState({ email: '', display_name: '', role_id: '', password: '' })
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [createError, setCreateError] = useState('')
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cc-users'] })
|
||||
setForm({ email: '', display_name: '', role_id: '' })
|
||||
setForm({ email: '', display_name: '', role_id: '', password: '' })
|
||||
setShowForm(false)
|
||||
setCreateError('')
|
||||
},
|
||||
onError: (err) => setCreateError(errorMessage(err)),
|
||||
})
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
@@ -40,11 +102,11 @@ export default function UsersPage() {
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1>Control Center Users</h1>
|
||||
<button onClick={() => setShowForm(!showForm)}>+ Tambah User</button>
|
||||
<button onClick={() => { setShowForm(!showForm); setCreateError('') }}>+ Tambah User</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
|
||||
<form onSubmit={(e) => { e.preventDefault(); setCreateError(''); createMutation.mutate(form) }}
|
||||
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
|
||||
<h3>Tambah User Baru</h3>
|
||||
<input placeholder="Email" type="email" value={form.email}
|
||||
@@ -58,10 +120,18 @@ export default function UsersPage() {
|
||||
<option value="">Pilih Role</option>
|
||||
{roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<input placeholder="Password awal (min 8, huruf besar/kecil + angka)" type="text" value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required minLength={8}
|
||||
style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => setForm(f => ({ ...f, password: generateTempPassword() }))}>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
||||
</button>
|
||||
{createMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||
{createError && <p style={{ color: 'red' }}>{createError}</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -71,6 +141,7 @@ export default function UsersPage() {
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Email</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Role</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -78,7 +149,8 @@ export default function UsersPage() {
|
||||
<tr key={user.id}>
|
||||
<td style={{ padding: 8 }}>{user.display_name}</td>
|
||||
<td style={{ padding: 8 }}>{user.email}</td>
|
||||
<td style={{ padding: 8 }}>{user.role.name}</td>
|
||||
<td style={{ padding: 8 }}>{user.role?.name}</td>
|
||||
<td style={{ padding: 8 }}><ResetPasswordRow userId={user.id} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user