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