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

@@ -8,7 +8,7 @@ Fastify.js REST API serving both mobile apps and the internal control center.
- **Runtime:** Node.js + Fastify.js - **Runtime:** Node.js + Fastify.js
- **Database:** PostgreSQL via GCP Cloud SQL - **Database:** PostgreSQL via GCP Cloud SQL
- **Auth:** Firebase Auth JWT verification (no session, stateless) - **Auth:** Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in `auth_sessions`). Firebase Auth removed in Phase 3.4 (commit `f860ab6`). `firebase-admin` is kept but only for FCM messaging.
- **Payment:** Xendit - **Payment:** Xendit
- **Infra:** GCP Cloud Run - **Infra:** GCP Cloud Run
@@ -26,20 +26,25 @@ Internal listener must never be exposed to the public internet.
``` ```
/api/client/... → client app routes /api/client/... → client app routes
/api/mitra/... → mitra app routes /api/mitra/... → mitra app routes
/api/shared/... → shared routes (e.g. auth, lookup) /api/shared/... → shared routes (e.g. auth, refresh, logout, anonymous)
/internal/... → control center routes (internal listener only) /internal/... → control center routes (internal listener only)
``` ```
## Auth Flow ## Auth Flow
1. Firebase Auth issues JWT token on mobile/web - **Mobile (client/mitra):** `Authorization: Bearer <access_token>` header. Access token is our own JWT (HS256, `AUTH_JWT_SECRET`), with claims `{ sub, user_type, session_id }`. Refresh via `POST /api/shared/auth/refresh` with the opaque refresh token in the body.
2. Client sends JWT in `Authorization: Bearer <token>` header - **Control center:** Access token in `Authorization: Bearer` (kept in memory by the SPA). Refresh token lives in an `httpOnly` Secure cookie; refresh calls `POST /internal/auth/refresh` with `credentials: 'include'`.
3. Fastify verifies token using Firebase Admin SDK on every request - **Entry points:**
4. User record fetched from PostgreSQL by Firebase UID - Anonymous customer: `POST /api/shared/auth/anonymous`
- Phone OTP (customer/mitra): `/api/{client,mitra}/auth/otp/{request,verify}`**Fazpass is stubbed** in `otp.service.js`; code is logged to the backend console (`[OTP STUB] phone=… code=…`) until real API docs arrive.
- Google/Apple: `/api/client/auth/{google,apple}` (client_app only — creds pending)
- CC login: `POST /internal/auth/login` (email + bcrypt password)
- **Middleware:** `authenticate` plugin verifies the JWT and attaches `request.auth = { userType, userId, sessionId }`. WebSocket handshake uses the same verification. **No DB lookup on every request** — the user ID is encoded in the token.
## Key Conventions ## Key Conventions
- All routes must be authenticated unless explicitly marked public - All routes must be authenticated unless explicitly marked public (auth + anonymous routes are the exceptions)
- Internal routes have an additional role check (`role: admin`) - Internal routes additionally require `request.auth.userType === 'cc_user'`
- Use Fastify plugins for shared middleware (auth, error handling, logging) - Use Fastify plugins for shared middleware (auth, error handling, logging)
- Business logic lives in `services/` — never directly in route handlers - Business logic lives in `services/` — never directly in route handlers
- Never reintroduce Firebase Auth. `firebase-admin` is FCM-only; do not import `.auth()` from it.

View File

@@ -1,7 +1,2 @@
# Internal API base URL — accessible via VPN only # Internal API base URL — accessible via VPN only
VITE_API_BASE_URL=https://internal.halobestie.com VITE_API_BASE_URL=https://internal.halobestie.com
# Firebase
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=

View File

@@ -7,7 +7,7 @@ React + Vite SPA for internal platform management. **Internal use only.**
## Stack ## Stack
- **Framework:** React + Vite - **Framework:** React + Vite
- **Auth:** Firebase Auth (admin role required) - **Auth:** Self-managed (see root `CLAUDE.md` — Phase 3.4). Email + bcrypt password via `POST /internal/auth/login`. Access token lives in memory (React `AuthContext`); refresh token in an `httpOnly` Secure cookie (`cc_refresh_token`). All API calls must send `credentials: 'include'`. Admin-only provisioning — no public signup, no password-reset flow.
- **API:** Calls internal Fastify listener only (`/internal/` routes on port 3001) - **API:** Calls internal Fastify listener only (`/internal/` routes on port 3001)
- **Access:** Internal network / VPN only — never exposed to public internet - **Access:** Internal network / VPN only — never exposed to public internet

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"firebase": "^10.12.1",
"axios": "^1.7.2", "axios": "^1.7.2",
"@tanstack/react-query": "^5.45.1" "@tanstack/react-query": "^5.45.1"
}, },

View File

@@ -1,8 +1,59 @@
import { useState } from 'react'
import { Outlet, NavLink } from 'react-router-dom' import { Outlet, NavLink } from 'react-router-dom'
import { useAuth } from '../core/auth/AuthContext' 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() { export default function Layout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [showPwForm, setShowPwForm] = useState(false)
return ( return (
<div style={{ display: 'flex', minHeight: '100vh' }}> <div style={{ display: 'flex', minHeight: '100vh' }}>
@@ -18,7 +69,9 @@ export default function Layout() {
</ul> </ul>
<div style={{ marginTop: 'auto', paddingTop: 16 }}> <div style={{ marginTop: 'auto', paddingTop: 16 }}>
<p style={{ fontSize: 12 }}>{user?.email}</p> <p style={{ fontSize: 12 }}>{user?.email}</p>
<button onClick={() => setShowPwForm(v => !v)} style={{ marginRight: 6 }}>Ganti password</button>
<button onClick={logout}>Logout</button> <button onClick={logout}>Logout</button>
{showPwForm && <PasswordChangeForm onDone={() => setShowPwForm(false)} />}
</div> </div>
</nav> </nav>
<main style={{ flex: 1, padding: 24 }}> <main style={{ flex: 1, padding: 24 }}>

View File

@@ -1,15 +1,40 @@
import axios from 'axios' import axios from 'axios'
import { auth } from '../auth/firebase' import {
readAccessToken,
refreshAccessToken,
notifyUnauthenticated,
} from '../auth/token-bridge'
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, 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) => { apiClient.interceptors.request.use((config) => {
const user = auth.currentUser const token = readAccessToken()
if (user) { if (token) config.headers.Authorization = `Bearer ${token}`
const token = await user.getIdToken()
config.headers.Authorization = `Bearer ${token}`
}
return config 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 { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
import { signInWithEmailAndPassword, signOut, onAuthStateChanged } from 'firebase/auth' import axios from 'axios'
import { auth } from './firebase' import { registerAuthBridge } from './token-bridge'
import { apiClient } from '../api/api-client'
const AuthContext = createContext(null) 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 }) => { export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null) const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { // Keep access token in a ref so api-client (via bridge) always reads the
const unsub = onAuthStateChanged(auth, async (firebaseUser) => { // latest value synchronously — React state updates are async.
if (firebaseUser) { const accessTokenRef = useRef(null)
try {
const res = await apiClient.post('/internal/auth/verify') const clearSession = useCallback(() => {
setUser(res.data.data) accessTokenRef.current = null
} catch { setUser(null)
await signOut(auth)
setUser(null)
}
} else {
setUser(null)
}
setLoading(false)
})
return unsub
}, []) }, [])
const login = (email, password) => signInWithEmailAndPassword(auth, email, password) const applyTokens = useCallback((accessToken, profile) => {
const logout = () => signOut(auth) 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 ( return (
<AuthContext.Provider value={{ user, loading, login, logout }}> <AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children} {children}
</AuthContext.Provider> </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()

View File

@@ -2,6 +2,21 @@ import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../core/auth/AuthContext' 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() { export default function LoginPage() {
const { user, loading: authLoading, login } = useAuth() const { user, loading: authLoading, login } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@@ -20,12 +35,14 @@ export default function LoginPage() {
setLoading(true) setLoading(true)
try { try {
await login(email, password) await login(email, password)
} catch { } catch (err) {
setError('Email atau password salah.') setError(messageForError(err))
setLoading(false) setLoading(false)
} }
} }
if (authLoading) return <div style={{ padding: 24 }}>Loading...</div>
return ( return (
<div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}> <div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}>
<h1>Halo Bestie</h1> <h1>Halo Bestie</h1>

View File

@@ -17,21 +17,83 @@ const createUser = async (data) => {
return res.data.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() { export default function UsersPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['cc-users'], queryFn: fetchUsers }) const { data, isLoading } = useQuery({ queryKey: ['cc-users'], queryFn: fetchUsers })
const { data: roles } = useQuery({ queryKey: ['roles'], queryFn: fetchRoles }) 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 [showForm, setShowForm] = useState(false)
const [createError, setCreateError] = useState('')
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createUser, mutationFn: createUser,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cc-users'] }) queryClient.invalidateQueries({ queryKey: ['cc-users'] })
setForm({ email: '', display_name: '', role_id: '' }) setForm({ email: '', display_name: '', role_id: '', password: '' })
setShowForm(false) setShowForm(false)
setCreateError('')
}, },
onError: (err) => setCreateError(errorMessage(err)),
}) })
if (isLoading) return <div>Loading...</div> if (isLoading) return <div>Loading...</div>
@@ -40,11 +102,11 @@ export default function UsersPage() {
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Control Center Users</h1> <h1>Control Center Users</h1>
<button onClick={() => setShowForm(!showForm)}>+ Tambah User</button> <button onClick={() => { setShowForm(!showForm); setCreateError('') }}>+ Tambah User</button>
</div> </div>
{showForm && ( {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' }}> style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
<h3>Tambah User Baru</h3> <h3>Tambah User Baru</h3>
<input placeholder="Email" type="email" value={form.email} <input placeholder="Email" type="email" value={form.email}
@@ -58,10 +120,18 @@ export default function UsersPage() {
<option value="">Pilih Role</option> <option value="">Pilih Role</option>
{roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)} {roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select> </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}> <button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'} {createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
</button> </button>
{createMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>} {createError && <p style={{ color: 'red' }}>{createError}</p>}
</form> </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' }}>Nama</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Email</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' }}>Role</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -78,7 +149,8 @@ export default function UsersPage() {
<tr key={user.id}> <tr key={user.id}>
<td style={{ padding: 8 }}>{user.display_name}</td> <td style={{ padding: 8 }}>{user.display_name}</td>
<td style={{ padding: 8 }}>{user.email}</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> </tr>
))} ))}
</tbody> </tbody>