Kustomisasi WebReport Login
Kustomisasi total halaman login WebReport dengan HTML/CSS/JS atau framework
Panduan Kustomisasi WebReport Login
Panduan ini menjelaskan cara mengubah total tampilan halaman login WebReport (/app/webreport/login). Berbeda dengan panduan Login Menu Customization yang mengatur konfigurasi dari panel admin, kustomisasi di sini bersifat full-control — Anda bisa mengganti seluruh HTML, CSS, dan JavaScript sesuai keinginan.
Ringkasan
Halaman login WebReport adalah file standalone HTML yang disimpan di:
| Mode | Lokasi |
|---|---|
| Local | {config_dir}/webreport/login.html |
| S3 | .config/hexdial/webreport/login.html |
File ini dibuat otomatis saat pertama kali backend dijalankan. Setelah itu, Anda bebas mengeditnya kapan saja — perubahan akan langsung terbaca tanpa perlu restart backend (dengan cache TTL 30 menit).
Daftar Isi
- A. Cara Kerja
- B. Menemukan File Login
- C. API Backend yang Tersedia
- D. Kustomisasi Dasar (HTML + CSS + JS)
- E. Menggunakan Framework (React / Vue / Angular)
- F. Contoh Lengkap
- G. Tips & Troubleshooting
A. Cara Kerja
- User mengakses
https://domain.com/app/webreport/login - Backend membaca file
login.htmldari local config directory atau S3 - File di-cache selama 30 menit — setelah diedit, cache akan ter-refresh otomatis
- HTML ini adalah standalone penuh: semua CSS dan JS bisa di-inline atau di-load dari CDN eksternal
- Setelah login sukses, user diarahkan ke
/app/webreport/dashboard
B. Menemukan File Login
Local Mode
Default login.html dibuat di direktori config XDG:
- Linux:
~/.config/hexdial/webreport/login.html - Windows:
%APPDATA%/hexdial/webreport/login.html - macOS:
~/Library/Application Support/hexdial/webreport/login.html
Anda bisa langsung edit file tersebut menggunakan editor teks favorit.
S3 Mode
Jika S3 storage diaktifkan, file berada di bucket S3 dengan key:
.config/hexdial/webreport/login.html
Ambil file dari S3, edit, lalu upload kembali. Backend akan membaca versi terbaru setelah cache 30 menit kedaluwarsa (atau Anda bisa hapus cache dengan restart backend).
C. API Backend yang Tersedia
Login HTML Anda perlu berkomunikasi dengan endpoint berikut:
1. GET /x-token
Mendapatkan X-Token (CSRF token) yang wajib dikirim di header setiap request POST.
Response: text/plain — token string.
fetch('/x-token').then(r => r.text()).then(token => { ... })
2. POST /webreport-login
Mengirim ID/Reseller Code + nomor telepon untuk meminta OTP.
Request Headers:
Content-Type: application/jsonX-Requested-With: XMLHttpRequestX-Token: {xToken}
Request Body:
{
"id": "RESELLER_ID",
"number": "08123456789"
}
Response (success):
{
"success": true,
"session_key": "abc123..."
}
Response (gagal):
{
"success": false,
"message": "ID atau nomor tidak ditemukan"
}
3. POST /authenticate
Memverifikasi kode OTP.
Request Headers: sama seperti di atas.
Request Body:
{
"otp": "123456",
"session_key": "abc123..."
}
Response (success):
{
"success": true,
"auth_seed": "seed_xxx..."
}
Response (gagal):
{
"success": false,
"message": "OTP salah"
}
4. Alur Login yang Wajib Diikuti
1. GET /x-token → dapatkan X-Token
2. POST /webreport-login → kirim ID + nomor, dapatkan session_key
3. POST /authenticate → kirim OTP + session_key, dapatkan auth_seed
4. Simpan ke localStorage:
- memberSessionKey → session_key
- memberAuthSeed → auth_seed
5. Redirect ke /app/webreport/dashboard
D. Kustomisasi Dasar (HTML + CSS + JS)
Anda bisa mengubah total tampilan tanpa framework. Contoh perubahan yang umum dilakukan:
Mengganti Background
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* atau gambar */
background: url('https://example.com/bg.jpg') center/cover no-repeat;
}
.card {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(10px);
}
</style>
Menambahkan Logo
<div class="logo">
<img src="https://example.com/logo.png" alt="Logo" height="48">
</div>
Mengubah Layout Menjadi Tiga Langkah
Anda bebas mengubah struktur form. Backend hanya butuh data yang dikirim via fetch — tidak ada ketergantungan pada struktur HTML default.
Menambahkan Validasi Tambahan
// Contoh validasi nomor telepon
if (!/^08[0-9]{8,11}$/.test(number)) {
showError('Format nomor telepon tidak valid');
return;
}
E. Menggunakan Framework (React / Vue / Angular)
Karena login.html adalah standalone HTML, Anda bisa menggunakan framework JavaScript untuk membangun halaman login yang lebih kompleks.
Cara 1: Build & Inject (Rekomendasi)
- Buat project React/Vue/Angular terpisah
- Develop seperti biasa
- Build production (
npm run build) — hasilnya fileindex.html,bundle.js,bundle.css - Inline semua asset ke dalam satu file HTML, atau upload asset pendamping di folder yang sama
Contoh dengan React (Vite):
# Di folder project React Anda
npm create vite@latest webreport-login -- --template react
cd webreport-login
npm install
# Kembangkan komponen login Anda
# Pastikan menggunakan endpoint yang sama (/x-token, /webreport-login, /authenticate)
# Build
npm run build
Hasil build dist/index.html dan dist/assets/*.js. Buka dist/index.html dan ubah path asset menjadi absolut atau inline-kan:
<script type="module" crossorigin src="/app/webreport/login-assets/index.js"></script>
Catatan: Relatif terhadap domain root, bukan path halaman. Atau lebih mudah: inline-kan semua CSS/JS ke dalam satu file HTML, lalu upload sebagai
login.html.
Cara 2: CDN-based SPA (Inline Framework)
Gunakan React/Vue via CDN langsung di satu file HTML — tanpa build tool:
Vue 3 via CDN:
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id="app">{{ message }}</div>
<script>
const { createApp } = Vue;
createApp({
data() { return { message: 'Login WebReport' } }
}).mount('#app');
</script>
React via CDN:
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement('h1', null, 'Login WebReport'));
</script>
Cara 3: Framework dengan Routing Internal
Jika Anda menggunakan React Router, Vue Router, atau Angular Router di dalam login.html, pastikan:
- Gunakan
HashRouter(bukanBrowserRouter) agar routing tidak bentrok dengan backend - Atau deploy seluruh build login sebagai SPA di path
/app/webreport/login/(dengan trailing slash)
F. Contoh Lengkap
Contoh 1: Minimal Modern (vanilla HTML/CSS/JS)
File ini bisa Anda simpan sebagai login.html:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - WebReport</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #0f172a 0%, #1e293b 100%);
}
.card {
background: #fff;
border-radius: 16px;
padding: 40px 32px;
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
}
.logo { text-align: center; margin-bottom: 32px; }
.logo h1 { font-size: 24px; color: #0f172a; }
.logo p { color: #64748b; font-size: 14px; margin-top: 4px; }
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 13px; font-weight: 600; color: #334155; margin-bottom: 6px; }
input {
width: 100%; padding: 12px 14px; border: 1.5px solid #e2e8f0; border-radius: 10px;
font-size: 14px; transition: border-color 0.2s;
}
input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
.btn {
width: 100%; padding: 12px; border: none; border-radius: 10px; font-size: 14px; font-weight: 600;
cursor: pointer; transition: all 0.2s;
}
.btn-primary { background: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost { background: none; color: #6366f1; text-align: center; display: block; margin-top: 12px; }
.btn-ghost:hover { text-decoration: underline; }
.msg { font-size: 13px; padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; display: none; }
.msg-error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
.msg-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
.step { display: none; }
.step.active { display: block; }
.otp-input { text-align: center; font-size: 28px; letter-spacing: 10px; font-weight: 700; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<h1>📊 WebReport</h1>
<p>Masuk ke akun Anda</p>
</div>
<div class="msg" id="errorMsg"></div>
<div class="msg" id="successMsg"></div>
<!-- Step 1: ID + Nomor Telepon -->
<div class="step active" id="step1">
<form id="form1" autocomplete="off">
<div class="form-group">
<label for="id">ID / Kode Reseller</label>
<input type="text" id="id" placeholder="Masukkan ID" required>
</div>
<div class="form-group">
<label for="number">Nomor Telepon</label>
<input type="tel" id="number" placeholder="08xxxxxxxxxx" required>
</div>
<button type="submit" class="btn btn-primary" id="sendBtn">Kirim OTP</button>
</form>
</div>
<!-- Step 2: OTP -->
<div class="step" id="step2">
<form id="form2" autocomplete="off">
<div style="background:#f8fafc;padding:12px;border-radius:8px;font-size:13px;margin-bottom:16px;color:#475569;">
OTP dikirim ke <strong id="phoneDisplay"></strong>
</div>
<div class="form-group">
<label for="otp">Kode OTP</label>
<input type="text" id="otp" class="otp-input" maxlength="6" inputmode="numeric" required>
</div>
<button type="submit" class="btn btn-primary" id="verifyBtn">Verifikasi</button>
<button type="button" class="btn btn-ghost" id="backBtn">← Ganti nomor / ID</button>
</form>
</div>
</div>
<script>
let xToken = '', savedNumber = '', savedSessionKey = '';
fetch('/x-token').then(r => r.text()).then(t => xToken = t.trim()).catch(() => {});
const $ = id => document.getElementById(id);
const err = $('errorMsg'), ok = $('successMsg');
err.className = 'msg msg-error';
ok.className = 'msg msg-success';
function showError(m) { err.textContent = m; err.style.display = 'block'; ok.style.display = 'none'; }
function showSuccess(m) { ok.textContent = m; ok.style.display = 'block'; err.style.display = 'none'; }
function hideMsgs() { err.style.display = 'none'; ok.style.display = 'none'; }
$('form1').addEventListener('submit', async function(ev) {
ev.preventDefault(); hideMsgs();
const id = $('id').value.trim(), num = $('number').value.trim();
if (!id || !num) { showError('Harap isi semua bidang'); return; }
const btn = $('sendBtn'); btn.disabled = true; btn.textContent = 'Mengirim...';
try {
const r = await fetch('/webreport-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-Token': xToken },
body: JSON.stringify({ id, number: num })
});
const d = await r.json();
if (d.success) {
savedNumber = num; savedSessionKey = d.session_key || '';
$('phoneDisplay').textContent = num; $('otp').value = '';
$('step1').classList.remove('active'); $('step2').classList.add('active');
setTimeout(() => $('otp').focus(), 100);
} else showError(d.message || 'Gagal');
} catch(_) { showError('Kesalahan jaringan'); }
finally { btn.disabled = false; btn.textContent = 'Kirim OTP'; }
});
$('form2').addEventListener('submit', async function(ev) {
ev.preventDefault(); hideMsgs();
const otp = $('otp').value.trim();
if (!otp || otp.length < 4) { showError('Masukkan OTP dengan benar'); return; }
const btn = $('verifyBtn'); btn.disabled = true; btn.textContent = 'Memverifikasi...';
try {
const r = await fetch('/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-Token': xToken },
body: JSON.stringify({ otp, session_key: savedSessionKey })
});
const d = await r.json();
if (d.success && d.auth_seed) {
localStorage.setItem('memberSessionKey', savedSessionKey);
localStorage.setItem('memberAuthSeed', d.auth_seed);
showSuccess('Login berhasil!');
setTimeout(() => { window.location.href = '/app/webreport/dashboard'; }, 800);
} else showError(d.message || 'OTP salah');
} catch(_) { showError('Kesalahan jaringan'); }
finally { btn.disabled = false; btn.textContent = 'Verifikasi'; }
});
$('backBtn').addEventListener('click', function() {
hideMsgs();
$('step2').classList.remove('active');
$('step1').classList.add('active');
});
$('otp').addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '').slice(0, 6);
if (this.value.length === 6) $('form2').requestSubmit();
});
</script>
</body>
</html>
Contoh 2: Vue 3 via CDN (Satu File)
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - WebReport</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #0f172a; display: flex; min-height: 100vh; align-items: center; justify-content: center; }
.card { background: #fff; border-radius: 16px; padding: 40px; max-width: 400px; width: 100%; }
h1 { margin: 0 0 4px; } p { color: #64748b; margin: 0 0 24px; font-size: 14px; }
input, button { width: 100%; padding: 12px; font-size: 14px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #e2e8f0; }
button { background: #6366f1; color: #fff; border: none; cursor: pointer; font-weight: 600; }
button:disabled { opacity: 0.5; }
.msg { padding: 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
.error { background: #fef2f2; color: #dc2626; }
.success { background: #f0fdf4; color: #16a34a; }
</style>
</head>
<body>
<div id="app">
<div class="card">
<h1>📊 WebReport</h1>
<p>Masuk ke akun Anda</p>
<div v-if="error" class="msg error">{{ error }}</div>
<div v-if="success" class="msg success">{{ success }}</div>
<template v-if="step === 1">
<form @submit.prevent="sendOtp">
<label>ID / Kode Reseller</label>
<input v-model="id" placeholder="Masukkan ID" required>
<label>Nomor Telepon</label>
<input v-model="number" type="tel" placeholder="08xxxxxxxxxx" required>
<button :disabled="loading">{{ loading ? 'Mengirim...' : 'Kirim OTP' }}</button>
</form>
</template>
<template v-if="step === 2">
<form @submit.prevent="verifyOtp">
<div style="background:#f8fafc;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;">
OTP dikirim ke <strong>{{ phoneDisplay }}</strong>
</div>
<label>Kode OTP</label>
<input v-model="otp" maxlength="6" inputmode="numeric" style="text-align:center;font-size:28px;letter-spacing:10px;font-weight:700;" required>
<button :disabled="loading">{{ loading ? 'Memverifikasi...' : 'Verifikasi' }}</button>
<button type="button" @click="step=1;error='';success=''" style="background:none;color:#6366f1;border:none;cursor:pointer;text-align:center;display:block;width:100%;">← Ganti nomor / ID</button>
</form>
</template>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return { step: 1, id: '', number: '', otp: '', phoneDisplay: '', sessionKey: '', error: '', success: '', loading: false, xToken: '' }
},
mounted() {
fetch('/x-token').then(r => r.text()).then(t => this.xToken = t.trim()).catch(() => {});
},
methods: {
async sendOtp() {
if (!this.id || !this.number) { this.error = 'Harap isi semua bidang'; return; }
this.loading = true; this.error = ''; this.success = '';
try {
const r = await fetch('/webreport-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-Token': this.xToken },
body: JSON.stringify({ id: this.id, number: this.number })
});
const d = await r.json();
if (d.success) { this.phoneDisplay = this.number; this.sessionKey = d.session_key || ''; this.step = 2; }
else this.error = d.message || 'Gagal';
} catch(_) { this.error = 'Kesalahan jaringan'; }
finally { this.loading = false; }
},
async verifyOtp() {
if (!this.otp || this.otp.length < 4) { this.error = 'Masukkan OTP dengan benar'; return; }
this.loading = true; this.error = ''; this.success = '';
try {
const r = await fetch('/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-Token': this.xToken },
body: JSON.stringify({ otp: this.otp, session_key: this.sessionKey })
});
const d = await r.json();
if (d.success && d.auth_seed) {
localStorage.setItem('memberSessionKey', this.sessionKey);
localStorage.setItem('memberAuthSeed', d.auth_seed);
this.success = 'Login berhasil!';
setTimeout(() => { window.location.href = '/app/webreport/dashboard'; }, 800);
} else this.error = d.message || 'OTP salah';
} catch(_) { this.error = 'Kesalahan jaringan'; }
finally { this.loading = false; }
}
}
}).mount('#app');
</script>
</body>
</html>
G. Tips & Troubleshooting
Cache 30 Menit
File login.html di-cache oleh backend selama 30 menit. Jika setelah mengedit file tidak ada perubahan:
- Tunggu hingga 30 menit — cache akan ter-refresh otomatis
- Atau restart backend untuk membersihkan cache segera
Debugging
- Buka browser DevTools (F12) → tab Network untuk melihat request/response ke
/x-token,/webreport-login,/authenticate - Cek tab Console untuk error JavaScript
- Pastikan
localStorageberisimemberSessionKeydanmemberAuthSeedsetelah login sukses
Error "ID atau nomor tidak ditemukan"
User harus terdaftar di database backend. Ini bukan masalah kustomisasi HTML — pastikan data login valid.
CORS / X-Token
- Semua request POST WAJIB menyertakan header
X-Tokenyang diperoleh dariGET /x-token - Header
X-Requested-With: XMLHttpRequestjuga wajib
File Tidak Muncul
Jika login.html tidak muncul di direktori config:
- Jalankan backend sekali — file akan dibuat otomatis
- Atau buat manual dengan konten dari template default di atas
Menggunakan Asset Eksternal
Anda bisa me-load CSS/JS/font/image dari CDN manapun:
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
Aman
- Jangan pernah menyimpan hardcoded credentials di login.html
- Semua autentikasi melalui backend endpoint — tidak perlu API key di frontend
- X-Token mencegah CSRF — pastikan selalu dikirim
Catatan: Panduan ini untuk kustomisasi tampilan login WebReport. Untuk pengaturan konten login dari panel admin (background, teks, markdown, tombol CS), lihat Panduan Login Menu Customization.