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,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 }}>