Phase 3.3 — Session Topic Sensitivity (complete): - Backend: topic_sensitivity column + session_sensitivity_log, sensitivity service (flip with one-way-latch + audit), PATCH /api/shared/chat/sessions/:id/topic, topic carried in pairing + extension WS payloads, CC filter + sensitive stats + per-mitra sensitive columns on activity page - client_app: TopicSelectionBottomSheet before pricing, topic flows through pairing request, silent WS handler for session_topic_updated - mitra_app: SensitivityBadge + SensitivityTheme + sensitivityConfigProvider, overlay badge + yellow accent, chat screen app-bar toggle with configurable confirmation + latch, extension card shows current flag, history + transcript yellow theme - control_center: Sensitivitas Topik settings section, topic filter + column with inline audit log, sensitive stats dashboard card, mitra activity sensitive columns with QC flag Phase 3.4 — Self-Managed Auth (foundation only): - Migration: auth_sessions + otp_requests tables, social identity columns on customers, password_hash + lockout on control_center_users, OTP + CC lockout app_config keys - New services: password (bcrypt + complexity), token (JWT HS256 + refresh rotation, session_id claim pre-wires future Valkey revocation), social-identity (Google + Apple JWKS), OTP (Fazpass stub — real API TBD) - Constants: AuthProvider + OtpChannel - Middleware, auth route rewrites, WS auth update, Firebase → FCM isolation still pending (next chunk); Fazpass docs + Apple Developer setup still required before E2E testing Docs: - requirement/phase3.3.md, phase3.3-plan.md, phase3.3-testing.md - requirement/phase3.4.md, phase3.4-plan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
12 KiB
JavaScript
326 lines
12 KiB
JavaScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { apiClient } from '../../core/api/api-client'
|
|
|
|
const fetchAnonymityConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/anonymity')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateAnonymityConfig = async (anonymity_enabled) => {
|
|
const res = await apiClient.patch('/internal/config/anonymity', { anonymity_enabled })
|
|
return res.data.data
|
|
}
|
|
|
|
const fetchMaxCustomersConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/max-customers-per-mitra')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
|
|
const res = await apiClient.patch('/internal/config/max-customers-per-mitra', { 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
|
|
}
|
|
|
|
const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => {
|
|
const res = await apiClient.patch('/internal/config/extension-timeout', { extension_timeout_seconds })
|
|
return res.data.data
|
|
}
|
|
|
|
// Phase 3.1: Mitra Ping Config
|
|
const fetchMitraPingConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/mitra-ping')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateMitraPingConfig = async (data) => {
|
|
const res = await apiClient.patch('/internal/config/mitra-ping', data)
|
|
return res.data.data
|
|
}
|
|
|
|
const fetchEarlyEndConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/early-end')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateEarlyEndConfig = async (data) => {
|
|
const res = await apiClient.patch('/internal/config/early-end', data)
|
|
return res.data.data
|
|
}
|
|
|
|
// Phase 3.3: Topic Sensitivity
|
|
const fetchSensitivityConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/sensitivity')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateSensitivityConfig = async (data) => {
|
|
const res = await apiClient.patch('/internal/config/sensitivity', data)
|
|
return res.data.data
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const queryClient = useQueryClient()
|
|
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
|
|
|
|
const anonymityMutation = useMutation({
|
|
mutationFn: updateAnonymityConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-anonymity'] }),
|
|
})
|
|
|
|
const { data: maxData, isLoading: maxLoading } = useQuery({
|
|
queryKey: ['config-max-customers'],
|
|
queryFn: fetchMaxCustomersConfig,
|
|
})
|
|
|
|
const maxMutation = useMutation({
|
|
mutationFn: updateMaxCustomersConfig,
|
|
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'],
|
|
queryFn: fetchExtensionTimeoutConfig,
|
|
})
|
|
const etMutation = useMutation({
|
|
mutationFn: updateExtensionTimeoutConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-timeout'] }),
|
|
})
|
|
|
|
// Phase 3: Early End
|
|
const { data: eeData, isLoading: eeLoading } = useQuery({
|
|
queryKey: ['config-early-end'],
|
|
queryFn: fetchEarlyEndConfig,
|
|
})
|
|
const eeMutation = useMutation({
|
|
mutationFn: updateEarlyEndConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }),
|
|
})
|
|
|
|
// Phase 3.1: Mitra Ping
|
|
const { data: mpData, isLoading: mpLoading } = useQuery({
|
|
queryKey: ['config-mitra-ping'],
|
|
queryFn: fetchMitraPingConfig,
|
|
})
|
|
const mpMutation = useMutation({
|
|
mutationFn: updateMitraPingConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-mitra-ping'] }),
|
|
})
|
|
|
|
// Phase 3.3: Topic Sensitivity
|
|
const { data: senData, isLoading: senLoading } = useQuery({
|
|
queryKey: ['config-sensitivity'],
|
|
queryFn: fetchSensitivityConfig,
|
|
})
|
|
const senMutation = useMutation({
|
|
mutationFn: updateSensitivityConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-sensitivity'] }),
|
|
})
|
|
|
|
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading) return <div>Loading...</div>
|
|
|
|
return (
|
|
<div>
|
|
<h1>Settings</h1>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Anonymity</h2>
|
|
<p>Ketika dinonaktifkan, pengguna anonim akan diminta mendaftar setelah sesi selesai.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={data?.anonymity_enabled ?? true}
|
|
onChange={e => anonymityMutation.mutate(e.target.checked)}
|
|
disabled={anonymityMutation.isPending}
|
|
/>
|
|
Izinkan pengguna anonim
|
|
</label>
|
|
{anonymityMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Maks Customer per Mitra</h2>
|
|
<p>Jumlah maksimal customer yang bisa ditangani satu Mitra secara bersamaan. Perubahan hanya berlaku untuk chat baru.</p>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={maxData?.max_customers_per_mitra ?? 3}
|
|
onChange={e => {
|
|
const val = parseInt(e.target.value, 10)
|
|
if (val >= 1) maxMutation.mutate(val)
|
|
}}
|
|
disabled={maxMutation.isPending}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<span>customer</span>
|
|
</div>
|
|
{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>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="number"
|
|
min="10"
|
|
value={etData?.extension_timeout_seconds ?? 60}
|
|
onChange={e => {
|
|
const val = parseInt(e.target.value, 10)
|
|
if (val >= 10) etMutation.mutate(val)
|
|
}}
|
|
disabled={etMutation.isPending}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<span>detik</span>
|
|
</div>
|
|
{etMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Akhiri Sesi Lebih Awal</h2>
|
|
<p>Izinkan mitra dan/atau customer untuk mengakhiri sesi sebelum waktu habis.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={eeData?.mitra_enabled ?? false}
|
|
onChange={e => eeMutation.mutate({ mitra_enabled: e.target.checked })}
|
|
disabled={eeMutation.isPending}
|
|
/>
|
|
Izinkan Mitra mengakhiri lebih awal
|
|
</label>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={eeData?.customer_enabled ?? false}
|
|
onChange={e => eeMutation.mutate({ customer_enabled: e.target.checked })}
|
|
disabled={eeMutation.isPending}
|
|
/>
|
|
Izinkan Customer mengakhiri lebih awal
|
|
</label>
|
|
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Mitra Online Status (Ping)</h2>
|
|
<p>Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={mpData?.require_ping ?? true}
|
|
onChange={e => mpMutation.mutate({ require_ping: e.target.checked })}
|
|
disabled={mpMutation.isPending}
|
|
/>
|
|
Wajibkan Mitra Ping (Heartbeat)
|
|
</label>
|
|
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
|
Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra.
|
|
</p>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<label>Interval Ping:</label>
|
|
<input
|
|
type="number"
|
|
min="5"
|
|
value={mpData?.ping_interval_seconds ?? 15}
|
|
onChange={e => {
|
|
const val = parseInt(e.target.value, 10)
|
|
if (val >= 5) mpMutation.mutate({ ping_interval_seconds: val })
|
|
}}
|
|
disabled={mpMutation.isPending}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<span>detik</span>
|
|
</div>
|
|
{mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Sensitivitas Topik</h2>
|
|
<p>Konfigurasi untuk fitur penandaan sesi sebagai topik sensitif oleh Mitra.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={senData?.flip_confirmation_enabled ?? true}
|
|
onChange={e => senMutation.mutate({ flip_confirmation_enabled: e.target.checked })}
|
|
disabled={senMutation.isPending}
|
|
/>
|
|
Aktifkan dialog konfirmasi saat Mitra menandai topik sensitif
|
|
</label>
|
|
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
|
Jika dinonaktifkan, Mitra langsung menandai tanpa dialog konfirmasi.
|
|
</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={senData?.one_way_latch ?? false}
|
|
onChange={e => senMutation.mutate({ one_way_latch: e.target.checked })}
|
|
disabled={senMutation.isPending}
|
|
/>
|
|
Kunci searah — Mitra tidak bisa membatalkan tanda topik sensitif
|
|
</label>
|
|
<p style={{ fontSize: 12, color: '#666' }}>
|
|
Jika diaktifkan, setelah sesi ditandai sensitif Mitra tidak dapat mengembalikannya ke topik umum.
|
|
</p>
|
|
{senMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|