OTP overhaul: test-user bypass + hash-at-rest + Fazpass integration

- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
  managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
  toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
  + DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
  bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
  sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
  fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
  OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
  pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
  pricing.service.js.

208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 22:39:34 +08:00
parent 3a0cdf5c4e
commit 6fd98ca99c
15 changed files with 1958 additions and 158 deletions

View File

@@ -23,17 +23,6 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
return res.data.data
}
// Phase 3 config fetchers
const fetchFreeTrialConfig = async () => {
const res = await apiClient.get('/internal/config/free-trial')
return res.data.data
}
const updateFreeTrialConfig = async (data) => {
const res = await apiClient.patch('/internal/config/free-trial', data)
return res.data.data
}
const fetchExtensionTimeoutConfig = async () => {
const res = await apiClient.get('/internal/config/extension-timeout')
return res.data.data
@@ -160,6 +149,30 @@ const updateSupportHandles = async (patch) => {
return res.data.data
}
// Test OTP bypass allowlist — phone-scoped static OTPs for Apple reviewers / QA.
// Backend rejects requestOtp() to Fazpass for these phones; plaintext OTP is
// bcrypt-hashed on save and never readable after.
const fetchTestOtpBypass = async () => {
const res = await apiClient.get('/internal/config/test-otp-bypass')
return res.data.data
}
const setTestOtpBypassEnabled = async (enabled) => {
const res = await apiClient.patch('/internal/config/test-otp-bypass/enabled', { enabled })
return res.data.data
}
const addTestOtpBypassEntry = async (body) => {
const res = await apiClient.post('/internal/config/test-otp-bypass/entries', body)
return res.data.data
}
const updateTestOtpBypassEntry = async ({ id, ...patch }) => {
const res = await apiClient.patch(`/internal/config/test-otp-bypass/entries/${id}`, patch)
return res.data.data
}
const deleteTestOtpBypassEntry = async (id) => {
const res = await apiClient.delete(`/internal/config/test-otp-bypass/entries/${id}`)
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -179,16 +192,6 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
})
// Phase 3: Free Trial
const { data: ftData, isLoading: ftLoading } = useQuery({
queryKey: ['config-free-trial'],
queryFn: fetchFreeTrialConfig,
})
const ftMutation = useMutation({
mutationFn: updateFreeTrialConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-free-trial'] }),
})
// Phase 3: Extension Timeout
const { data: etData, isLoading: etLoading } = useQuery({
queryKey: ['config-extension-timeout'],
@@ -331,10 +334,21 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }),
})
// Test OTP bypass allowlist. Single query, four mutations (enable + CRUD).
const { data: tobData, isLoading: tobLoading } = useQuery({
queryKey: ['config-test-otp-bypass'],
queryFn: fetchTestOtpBypass,
})
const invalidateTob = () => queryClient.invalidateQueries({ queryKey: ['config-test-otp-bypass'] })
const tobEnabledMutation = useMutation({ mutationFn: setTestOtpBypassEnabled, onSuccess: invalidateTob })
const tobAddMutation = useMutation({ mutationFn: addTestOtpBypassEntry, onSuccess: invalidateTob })
const tobUpdateMutation = useMutation({ mutationFn: updateTestOtpBypassEntry, onSuccess: invalidateTob })
const tobDeleteMutation = useMutation({ mutationFn: deleteTestOtpBypassEntry, onSuccess: invalidateTob })
if (
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
isLoading || maxLoading || etLoading || eeLoading || mpLoading || senLoading ||
pbtLoading || pstLoading || rctLoading || edaLoading ||
fsdLoading || ptLoading || shLoading
fsdLoading || ptLoading || shLoading || tobLoading
) return <div>Loading...</div>
return (
@@ -376,36 +390,6 @@ export default function SettingsPage() {
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Free Trial</h2>
<p>Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={ftData?.enabled ?? false}
onChange={e => ftMutation.mutate({ enabled: e.target.checked })}
disabled={ftMutation.isPending}
/>
Aktifkan Free Trial
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label>Durasi:</label>
<input
type="number"
min="1"
value={ftData?.duration_minutes ?? 5}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) ftMutation.mutate({ duration_minutes: val })
}}
disabled={ftMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{ftMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Extension Timeout</h2>
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
@@ -645,6 +629,15 @@ export default function SettingsPage() {
))}
</section>
{/* Test OTP bypass — Apple reviewer / QA static OTP allowlist */}
<TestOtpBypassSection
data={tobData}
enabledMutation={tobEnabledMutation}
addMutation={tobAddMutation}
updateMutation={tobUpdateMutation}
deleteMutation={tobDeleteMutation}
/>
{/* Phase 4: Support handles */}
<section style={{ marginBottom: 24 }}>
<h2>Support Handles (Tanya Admin)</h2>
@@ -1107,3 +1100,293 @@ function FirstSessionDiscountSection({ data, mutation, toast, onDismissToast })
</section>
)
}
// ============================================================================
// Test OTP Bypass — Apple-reviewer / QA static OTP allowlist
// ============================================================================
//
// SECURITY-SENSITIVE: any phone in this list authenticates with a static OTP
// and never receives an SMS. Backend bcrypt-hashes the plaintext on save and
// never returns it again — to rotate an OTP, edit the entry and set a new one.
//
// The kill-switch toggle disables ALL entries instantly without touching the
// list, useful for incidents. Per-entry expires_at provides automatic disable.
const formatExpiresAt = (iso) => {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('id-ID', { dateStyle: 'short', timeStyle: 'short' })
}
const toDatetimeLocal = (iso) => {
// <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in local time.
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const fromDatetimeLocal = (s) => {
// Convert local-tz "YYYY-MM-DDTHH:mm" back to ISO. new Date() handles it.
if (!s) return null
const d = new Date(s)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
const sectionErrorText = (err) =>
err?.response?.data?.error?.message || err?.message || 'Gagal menyimpan.'
function TestOtpBypassSection({ data, enabledMutation, addMutation, updateMutation, deleteMutation }) {
const [showAdd, setShowAdd] = useState(false)
const [editingId, setEditingId] = useState(null)
const entries = data?.entries ?? []
const isExpired = (iso) => {
const t = new Date(iso).getTime()
return Number.isFinite(t) && t <= Date.now()
}
return (
<section style={{ marginBottom: 24, border: '1px solid #f0c36d', padding: 12, background: '#fff8e8' }}>
<h2 style={{ marginTop: 0 }}>Test OTP Bypass (Apple Reviewer / QA)</h2>
<p style={{ fontSize: 13, color: '#664' }}>
Daftar nomor HP yang melewati Fazpass dan login dengan OTP statis.
Untuk reviewer App Store dan tester internal saja {' '}
<strong>siapa pun yang tahu pasangan nomor + OTP bisa login sebagai user ini.</strong>{' '}
Jaga daftarnya kecil, tetapkan <em>expires_at</em>, dan hapus segera setelah pemakaian.
</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input
type="checkbox"
checked={data?.enabled ?? false}
onChange={e => enabledMutation.mutate(e.target.checked)}
disabled={enabledMutation.isPending}
/>
<strong>Aktifkan bypass</strong> (kill switch global matikan untuk menonaktifkan semua entri sekaligus)
</label>
{enabledMutation.isError && (
<p style={{ color: 'red', fontSize: 12 }}>{sectionErrorText(enabledMutation.error)}</p>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 style={{ margin: 0, fontSize: 15 }}>Entri ({entries.length})</h3>
<button type="button" onClick={() => setShowAdd(s => !s)} disabled={addMutation.isPending}>
{showAdd ? 'Batal tambah' : '+ Tambah entri'}
</button>
</div>
{showAdd && (
<AddTestOtpBypassForm
onSubmit={(body) => addMutation.mutate(body, { onSuccess: () => setShowAdd(false) })}
onCancel={() => setShowAdd(false)}
isPending={addMutation.isPending}
serverError={addMutation.isError ? sectionErrorText(addMutation.error) : null}
/>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, marginTop: 8 }}>
<thead>
<tr style={{ background: '#f7f7f7', textAlign: 'left' }}>
<th style={th}>Phone</th>
<th style={th}>User type</th>
<th style={th}>Label</th>
<th style={th}>Expires</th>
<th style={th}>OTP hash</th>
<th style={th}>Aksi</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && (
<tr><td colSpan={6} style={{ ...td, color: '#999', fontStyle: 'italic' }}>Belum ada entri.</td></tr>
)}
{entries.map((entry) => (
editingId === entry.id ? (
<EditTestOtpBypassRow
key={entry.id}
entry={entry}
onSave={(patch) => updateMutation.mutate(
{ id: entry.id, ...patch },
{ onSuccess: () => setEditingId(null) },
)}
onCancel={() => setEditingId(null)}
isPending={updateMutation.isPending}
serverError={updateMutation.isError ? sectionErrorText(updateMutation.error) : null}
/>
) : (
<tr key={entry.id} style={{ opacity: isExpired(entry.expires_at) ? 0.5 : 1 }}>
<td style={td}>{entry.phone}</td>
<td style={td}>{entry.user_type}</td>
<td style={td}>{entry.label}</td>
<td style={td}>
{formatExpiresAt(entry.expires_at)}
{isExpired(entry.expires_at) && (
<span style={{ marginLeft: 6, color: '#a00', fontWeight: 'bold' }}>EXPIRED</span>
)}
</td>
<td style={{ ...td, fontFamily: 'monospace', fontSize: 11, color: '#888' }}>
{entry.otp_hash ? `${entry.otp_hash.slice(0, 12)}` : '—'}
</td>
<td style={td}>
<button type="button" onClick={() => setEditingId(entry.id)} style={{ marginRight: 4 }}>
Edit
</button>
<button
type="button"
onClick={() => {
if (!window.confirm(`Hapus entri "${entry.label}" (${entry.phone})?`)) return
deleteMutation.mutate(entry.id)
}}
disabled={deleteMutation.isPending}
style={{ color: '#a00' }}
>
Hapus
</button>
</td>
</tr>
)
))}
</tbody>
</table>
{deleteMutation.isError && (
<p style={{ color: 'red', fontSize: 12 }}>{sectionErrorText(deleteMutation.error)}</p>
)}
</section>
)
}
function AddTestOtpBypassForm({ onSubmit, onCancel, isPending, serverError }) {
const [phone, setPhone] = useState('+62')
const [userType, setUserType] = useState('customer')
const [otp, setOtp] = useState('')
const [label, setLabel] = useState('')
// Default expiry: 30 days from now.
const defaultExpiry = (() => {
const d = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
return toDatetimeLocal(d.toISOString())
})()
const [expiresAt, setExpiresAt] = useState(defaultExpiry)
const [localError, setLocalError] = useState(null)
const submit = (e) => {
e.preventDefault()
if (!/^\+[1-9]\d{6,14}$/.test(phone)) return setLocalError('Phone harus format E.164 (mis. +628...).')
if (!/^\d{4,8}$/.test(otp)) return setLocalError('OTP harus 4-8 digit angka.')
if (label.trim().length === 0) return setLocalError('Label wajib diisi.')
const iso = fromDatetimeLocal(expiresAt)
if (!iso) return setLocalError('Tanggal expires_at tidak valid.')
if (new Date(iso).getTime() <= Date.now()) return setLocalError('expires_at harus di masa depan.')
setLocalError(null)
onSubmit({ phone, user_type: userType, otp, label: label.trim(), expires_at: iso })
}
return (
<form onSubmit={submit} style={{
padding: 10, marginBottom: 8, background: '#f0f7ff', border: '1px solid #cde',
display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end',
}}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
Phone (E.164)
<input type="text" value={phone} onChange={e => setPhone(e.target.value.trim())}
style={{ width: 180 }} disabled={isPending} placeholder="+628111111111" required />
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
User type
<select value={userType} onChange={e => setUserType(e.target.value)}
disabled={isPending} style={{ width: 110 }}>
<option value="customer">customer</option>
<option value="mitra">mitra</option>
</select>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
OTP (4-8 digit)
<input type="text" inputMode="numeric" value={otp}
onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 8))}
style={{ width: 110, fontFamily: 'monospace' }} disabled={isPending} required />
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
Label
<input type="text" value={label} onChange={e => setLabel(e.target.value)}
style={{ width: 200 }} disabled={isPending} placeholder="Apple Reviewer #1" required />
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
Expires at
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
disabled={isPending} required />
</label>
<button type="submit" disabled={isPending}>{isPending ? '...' : 'Tambah'}</button>
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
{(localError || serverError) && (
<div style={{ color: 'red', fontSize: 12, width: '100%' }}>
{localError || serverError}
</div>
)}
</form>
)
}
function EditTestOtpBypassRow({ entry, onSave, onCancel, isPending, serverError }) {
const [label, setLabel] = useState(entry.label)
const [expiresAt, setExpiresAt] = useState(toDatetimeLocal(entry.expires_at))
const [otp, setOtp] = useState('') // blank = don't rotate
const [localError, setLocalError] = useState(null)
const submit = () => {
const patch = {}
if (label.trim() !== entry.label) {
if (label.trim().length === 0) return setLocalError('Label tidak boleh kosong.')
patch.label = label.trim()
}
const iso = fromDatetimeLocal(expiresAt)
if (!iso) return setLocalError('Tanggal expires_at tidak valid.')
if (iso !== entry.expires_at) {
if (new Date(iso).getTime() <= Date.now()) return setLocalError('expires_at harus di masa depan.')
patch.expires_at = iso
}
if (otp.length > 0) {
if (!/^\d{4,8}$/.test(otp)) return setLocalError('OTP harus 4-8 digit angka (kosongkan untuk tidak ganti).')
patch.otp = otp
}
if (Object.keys(patch).length === 0) {
onCancel()
return
}
setLocalError(null)
onSave(patch)
}
return (
<tr style={{ background: '#fffdf3' }}>
<td style={td}>{entry.phone}</td>
<td style={td}>{entry.user_type}</td>
<td style={td}>
<input type="text" value={label} onChange={e => setLabel(e.target.value)}
style={{ width: 180 }} disabled={isPending} />
</td>
<td style={td}>
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
disabled={isPending} />
</td>
<td style={td}>
<input
type="text" inputMode="numeric" value={otp}
onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="Rotate OTP (opsional)"
style={{ width: 140, fontFamily: 'monospace' }}
disabled={isPending}
/>
</td>
<td style={td}>
<button type="button" onClick={submit} disabled={isPending} style={{ marginRight: 4 }}>
{isPending ? '...' : 'Simpan'}
</button>
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
{(localError || serverError) && (
<div style={{ color: 'red', fontSize: 11, marginTop: 2 }}>{localError || serverError}</div>
)}
</td>
</tr>
)
}