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 }}>
|
||||
|
||||
Reference in New Issue
Block a user