Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
17a5ba8e7b arsip‑kbri‑final 2026-03-25 07:58:17 +00:00
6 changed files with 2242 additions and 530 deletions

679
archive_bootstrap.php Normal file
View File

@ -0,0 +1,679 @@
<?php
declare(strict_types=1);
@date_default_timezone_set('Africa/Harare');
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/mail/MailService.php';
function h(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function app_env(string $key, string $default = ''): string
{
$serverValue = $_SERVER[$key] ?? null;
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
return is_string($envValue) && $envValue !== '' ? $envValue : $default;
}
function page_meta(string $title, string $description = ''): array
{
$projectDescription = app_env('PROJECT_DESCRIPTION', $description);
$projectImageUrl = app_env('PROJECT_IMAGE_URL', '');
return [
'title' => $title,
'description' => $projectDescription,
'image' => $projectImageUrl,
];
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(?string $token): bool
{
$sessionToken = $_SESSION['csrf_token'] ?? '';
return is_string($token) && $sessionToken !== '' && hash_equals($sessionToken, $token);
}
function set_flash(string $type, string $message): void
{
$_SESSION['flashes'][] = [
'type' => $type,
'message' => $message,
];
}
function get_flashes(): array
{
$flashes = $_SESSION['flashes'] ?? [];
unset($_SESSION['flashes']);
return is_array($flashes) ? $flashes : [];
}
function demo_users(): array
{
return [
'superadmin@kbriharare.go.id' => [
'email' => 'superadmin@kbriharare.go.id',
'name' => 'Super Admin KBRI',
'role' => 'super_admin',
'role_label' => 'Super Admin',
'department' => 'Semua Departemen',
'seed_password' => 'Harare2026!',
'avatar' => 'SA',
],
'staf.politik@kbriharare.go.id' => [
'email' => 'staf.politik@kbriharare.go.id',
'name' => 'Staf Politik',
'role' => 'staff',
'role_label' => 'Staf',
'department' => 'Politik',
'seed_password' => 'Harare2026!',
'avatar' => 'SP',
],
'staf.konsuler@kbriharare.go.id' => [
'email' => 'staf.konsuler@kbriharare.go.id',
'name' => 'Staf Konsuler',
'role' => 'staff',
'role_label' => 'Staf',
'department' => 'Kekonsuleran',
'seed_password' => 'Harare2026!',
'avatar' => 'SK',
],
];
}
function authenticate_demo_user(string $email, string $password): ?array
{
$key = strtolower(trim($email));
$users = demo_users();
if (!isset($users[$key])) {
return null;
}
$user = $users[$key];
$hash = password_hash($user['seed_password'], PASSWORD_BCRYPT);
if (!password_verify($password, $hash)) {
return null;
}
unset($user['seed_password']);
return $user;
}
function mask_email(string $email): string
{
if (!str_contains($email, '@')) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$prefix = substr($local, 0, 2);
return $prefix . str_repeat('•', max(2, strlen($local) - 2)) . '@' . $domain;
}
function login_locked_until(): int
{
return (int)($_SESSION['login_locked_until'] ?? 0);
}
function current_user(): ?array
{
return isset($_SESSION['auth_user']) && is_array($_SESSION['auth_user']) ? $_SESSION['auth_user'] : null;
}
function is_authenticated(): bool
{
return current_user() !== null;
}
function is_super_admin(): bool
{
return (current_user()['role'] ?? '') === 'super_admin';
}
function process_login_request(): void
{
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
set_flash('danger', 'Permintaan login tidak valid. Silakan muat ulang halaman.');
return;
}
if (time() < login_locked_until()) {
$seconds = login_locked_until() - time();
set_flash('danger', 'Terlalu banyak percobaan login. Coba lagi dalam ' . max(1, $seconds) . ' detik.');
return;
}
$email = trim((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
set_flash('danger', 'Masukkan email dan password yang valid.');
return;
}
$user = authenticate_demo_user($email, $password);
if ($user === null) {
$_SESSION['login_failures'] = (int)($_SESSION['login_failures'] ?? 0) + 1;
if ((int)$_SESSION['login_failures'] >= 5) {
$_SESSION['login_locked_until'] = time() + 120;
$_SESSION['login_failures'] = 0;
}
set_flash('danger', 'Email atau password tidak cocok.');
return;
}
unset($_SESSION['login_failures'], $_SESSION['login_locked_until']);
$otp = (string)random_int(100000, 999999);
$pending = [
'user' => $user,
'otp' => $otp,
'expires_at' => time() + 300,
'masked_email' => mask_email($user['email']),
'mail_sent' => false,
];
$mailSent = false;
try {
$subject = 'Kode OTP Arsip Digital KBRI Harare';
$html = '<p>Kode OTP Anda:</p><p style="font-size:24px;font-weight:700;letter-spacing:4px;">' . h($otp) . '</p><p>Kode berlaku selama 5 menit.</p>';
$result = MailService::sendMail($user['email'], $subject, $html, 'Kode OTP Anda: ' . $otp);
$mailSent = !empty($result['success']);
} catch (Throwable $exception) {
$mailSent = false;
}
$pending['mail_sent'] = $mailSent;
$_SESSION['pending_auth'] = $pending;
if ($mailSent) {
set_flash('success', 'Kode OTP telah dikirim ke ' . $pending['masked_email'] . '.');
} else {
set_flash('warning', 'SMTP belum terpasang untuk pengujian. Gunakan OTP demo ' . $otp . ' untuk melanjutkan.');
}
}
function process_otp_verification(): void
{
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
set_flash('danger', 'Permintaan MFA tidak valid.');
return;
}
$pending = $_SESSION['pending_auth'] ?? null;
if (!is_array($pending)) {
set_flash('danger', 'Sesi OTP tidak ditemukan. Silakan login ulang.');
return;
}
if ((int)($pending['expires_at'] ?? 0) < time()) {
unset($_SESSION['pending_auth']);
set_flash('danger', 'Kode OTP telah kedaluwarsa. Silakan login ulang.');
return;
}
$otp = preg_replace('/\D+/', '', (string)($_POST['otp'] ?? ''));
if ($otp === '' || !hash_equals((string)$pending['otp'], $otp)) {
set_flash('danger', 'Kode OTP tidak valid.');
return;
}
$_SESSION['auth_user'] = $pending['user'];
$_SESSION['login_audit'] = [
'timestamp' => date('Y-m-d H:i:s'),
'mfa_method' => 'Email OTP',
];
unset($_SESSION['pending_auth']);
session_regenerate_id(true);
set_flash('success', 'Autentikasi berhasil. Selamat datang di brankas digital KBRI Harare.');
}
function logout_user(): void
{
$token = $_SESSION['csrf_token'] ?? null;
$_SESSION = [];
if ($token) {
$_SESSION['csrf_token'] = $token;
}
session_regenerate_id(true);
set_flash('info', 'Sesi Anda telah diakhiri.');
}
function require_auth(): void
{
if (!is_authenticated()) {
header('Location: index.php');
exit;
}
}
function archive_folder_tree(): array
{
return [
[
'group' => 'Informasi Negara',
'items' => [
['folder' => 'Informasi Negara / Zimbabwe', 'label' => 'Zimbabwe', 'hint' => 'Keterangan dasar, informasi penting, catatan peristiwa penting.'],
['folder' => 'Informasi Negara / Zambia', 'label' => 'Zambia', 'hint' => 'Keterangan dasar, informasi penting, catatan peristiwa penting.'],
],
],
[
'group' => 'Politik',
'items' => [
['folder' => 'Politik / Nota Diplomatik', 'label' => 'Nota Diplomatik', 'hint' => 'Permintaan dukungan, pernyataan resmi, laporan pertemuan.'],
['folder' => 'Politik / Perjanjian & Kesepakatan', 'label' => 'Perjanjian & Kesepakatan', 'hint' => 'MoU, agreement bilateral, multilateral.'],
['folder' => 'Politik / Hubungan Bilateral', 'label' => 'Hubungan Bilateral', 'hint' => 'Laporan menteri, pertukaran delegasi, pernyataan bersama.'],
['folder' => 'Politik / Kebijakan Strategis', 'label' => 'Kebijakan Strategis', 'hint' => 'Instruksi pusat, analisis geopolitik, rekomendasi.'],
['folder' => 'Politik / Keamanan & Pertahanan', 'label' => 'Keamanan & Pertahanan', 'hint' => 'MoU pertahanan, contingency plan, intelijen terbatas.'],
],
],
[
'group' => 'Pensosbud',
'items' => [
['folder' => 'Pensosbud / Program Kerja Sama', 'label' => 'Program Kerja Sama', 'hint' => 'Dokumen kolaborasi dan agenda kerja.'],
['folder' => 'Pensosbud / Scholarship', 'label' => 'Scholarship', 'hint' => 'Permohonan, persetujuan, monitoring & evaluasi.'],
['folder' => 'Pensosbud / Event Budaya', 'label' => 'Event Budaya', 'hint' => 'Undangan dan laporan kegiatan budaya.'],
],
],
[
'group' => 'Ekonomi & Perdagangan',
'items' => [
['folder' => 'Ekonomi & Perdagangan / Nota Kesepahaman Dagang', 'label' => 'Nota Kesepahaman Dagang', 'hint' => 'Zimbabwe, Zambia, Indonesia.'],
['folder' => 'Ekonomi & Perdagangan / Agreement Investasi', 'label' => 'Agreement Investasi', 'hint' => 'Dokumen investasi lintas negara.'],
['folder' => 'Ekonomi & Perdagangan / Dukungan Perusahaan', 'label' => 'Dukungan Perusahaan', 'hint' => 'Dukungan perusahaan Indonesia, Zimbabwe, Zambia.'],
],
],
[
'group' => 'Kekonsuleran',
'items' => [
['folder' => 'Kekonsuleran / Dokumen Perjalanan & Identitas', 'label' => 'Dokumen Perjalanan & Identitas', 'hint' => 'Visa, izin tinggal, SPLP, legalisasi, apostille, pencatatan sipil.'],
['folder' => 'Kekonsuleran / Legalisasi & Administrasi Dokumen', 'label' => 'Legalisasi & Administrasi Dokumen', 'hint' => 'Administrasi dokumen konsuler.'],
['folder' => 'Kekonsuleran / Perlindungan & Bantuan WNI', 'label' => 'Perlindungan & Bantuan WNI', 'hint' => 'Kasus perlindungan dan bantuan warga negara.'],
['folder' => 'Kekonsuleran / Fasilitas Diplomatik & Transportasi', 'label' => 'Fasilitas Diplomatik & Transportasi', 'hint' => 'Fasilitas diplomatik dan logistik perjalanan.'],
],
],
[
'group' => 'Kanselerai/HOC',
'items' => [
['folder' => 'Kanselerai/HOC / SOP', 'label' => 'SOP', 'hint' => 'Standar operasional prosedur.'],
['folder' => 'Kanselerai/HOC / Kepegawaian', 'label' => 'Kepegawaian', 'hint' => 'Dokumen personalia dan administrasi SDM.'],
['folder' => 'Kanselerai/HOC / Perkantoran', 'label' => 'Perkantoran', 'hint' => 'Operasional perkantoran dan fasilitas kerja.'],
['folder' => 'Kanselerai/HOC / Wisma Dubes', 'label' => 'Wisma Dubes', 'hint' => 'Dokumen rumah dinas dan operasional.'],
],
],
[
'group' => 'PID',
'items' => [
['folder' => 'PID / Contingency Plan', 'label' => 'Contingency Plan', 'hint' => 'Rencana kontingensi dan respons insiden.'],
['folder' => 'PID / Komputerisasi', 'label' => 'Komputerisasi', 'hint' => 'Sistem, inventaris TI, dan konfigurasi.'],
['folder' => 'PID / Pengamanan Terpadu', 'label' => 'Pengamanan Terpadu', 'hint' => 'Pengamanan terpadu dan SOP keamanan.'],
],
],
[
'group' => 'Administrasi & Internal',
'items' => [
['folder' => 'Administrasi & Internal / Surat Edaran', 'label' => 'Surat Edaran', 'hint' => 'Edaran dan komunikasi internal.'],
['folder' => 'Administrasi & Internal / Laporan Internal', 'label' => 'Laporan Internal', 'hint' => 'Laporan manajemen dan operasional.'],
['folder' => 'Administrasi & Internal / Arsip Kepegawaian', 'label' => 'Arsip Kepegawaian', 'hint' => 'Arsip kepegawaian dan administrasi personal.'],
['folder' => 'Administrasi & Internal / Inventaris & Logistik', 'label' => 'Inventaris & Logistik', 'hint' => 'Persediaan dan logistik internal.'],
],
],
[
'group' => 'ARSIP',
'items' => [
['folder' => 'ARSIP / File Penting', 'label' => 'File Penting', 'hint' => 'Dokumen prioritas dan referensi inti.'],
['folder' => 'ARSIP / Photo', 'label' => 'Photo', 'hint' => 'Dokumentasi foto kegiatan dan arsip visual.'],
['folder' => 'ARSIP / Video', 'label' => 'Video', 'hint' => 'Video resmi dan dokumentasi audiovisual.'],
],
],
];
}
function folder_options(): array
{
$options = [];
foreach (archive_folder_tree() as $section) {
foreach ($section['items'] as $item) {
$options[] = $item['folder'];
}
}
return $options;
}
function category_options(): array
{
return [
'Nota Diplomatik',
'Laporan Pertemuan',
'Perjanjian',
'Instruksi',
'Scholarship',
'Event Budaya',
'Dokumen Konsuler',
'SOP',
'Kepegawaian',
'Inventaris',
'File Penting',
'Photo',
'Video',
];
}
function upload_root(): string
{
return __DIR__ . '/uploads/archives';
}
function ensure_upload_root(): void
{
$root = upload_root();
if (!is_dir($root)) {
mkdir($root, 0775, true);
}
}
function ensure_archive_schema(): void
{
ensure_upload_root();
db()->exec(
"CREATE TABLE IF NOT EXISTS archive_documents (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(190) NOT NULL,
document_date DATE NOT NULL,
category VARCHAR(120) NOT NULL,
folder_path VARCHAR(255) NOT NULL,
department VARCHAR(120) NOT NULL,
notes TEXT NULL,
attachment_name VARCHAR(255) NOT NULL,
attachment_path VARCHAR(255) NOT NULL,
attachment_ext VARCHAR(16) NOT NULL,
attachment_size INT UNSIGNED NOT NULL,
status ENUM('pending', 'validated') NOT NULL DEFAULT 'pending',
created_by VARCHAR(190) NOT NULL,
created_role VARCHAR(50) NOT NULL,
validated_by VARCHAR(190) DEFAULT NULL,
validation_notes TEXT DEFAULT NULL,
activity_log LONGTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
function department_from_folder(string $folder): string
{
$parts = explode('/', $folder);
return trim($parts[0] ?? 'Umum');
}
function append_activity(array $entries, string $action, string $actor, string $notes = ''): array
{
$entries[] = [
'action' => $action,
'actor' => $actor,
'notes' => $notes,
'timestamp' => date('Y-m-d H:i:s'),
];
return $entries;
}
function create_document(array $post, array $files, array $user): bool
{
if (!verify_csrf($post['csrf_token'] ?? null)) {
set_flash('danger', 'Token formulir unggah tidak valid.');
return false;
}
$title = trim((string)($post['title'] ?? ''));
$documentDate = trim((string)($post['document_date'] ?? ''));
$category = trim((string)($post['category'] ?? ''));
$folderPath = trim((string)($post['folder_path'] ?? ''));
$notes = trim((string)($post['notes'] ?? ''));
if ($title === '' || strlen($title) < 4) {
set_flash('danger', 'Judul dokumen minimal 4 karakter.');
return false;
}
if ($documentDate === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $documentDate)) {
set_flash('danger', 'Tanggal dokumen wajib diisi.');
return false;
}
if ($category === '') {
set_flash('danger', 'Kategori dokumen wajib diisi.');
return false;
}
if (!in_array($folderPath, folder_options(), true)) {
set_flash('danger', 'Folder tujuan tidak tersedia.');
return false;
}
if (!isset($files['attachment']) || !is_array($files['attachment']) || (int)($files['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
set_flash('danger', 'Lampiran wajib diunggah.');
return false;
}
$file = $files['attachment'];
$allowedExtensions = ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'mp4'];
$originalName = (string)($file['name'] ?? '');
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
set_flash('danger', 'Format file harus PDF, DOC, DOCX, JPG, PNG, atau MP4.');
return false;
}
$size = (int)($file['size'] ?? 0);
if ($size <= 0 || $size > 10 * 1024 * 1024) {
set_flash('danger', 'Ukuran file maksimum 10 MB.');
return false;
}
ensure_upload_root();
$storedName = bin2hex(random_bytes(16)) . '.' . $extension;
$relativePath = 'uploads/archives/' . $storedName;
$targetPath = __DIR__ . '/' . $relativePath;
if (!move_uploaded_file((string)$file['tmp_name'], $targetPath)) {
set_flash('danger', 'Lampiran gagal disimpan ke server.');
return false;
}
$department = department_from_folder($folderPath);
$activity = append_activity([], 'upload', $user['name'], 'Dokumen ditambahkan ke folder ' . $folderPath);
$sql = 'INSERT INTO archive_documents
(title, document_date, category, folder_path, department, notes, attachment_name, attachment_path, attachment_ext, attachment_size, created_by, created_role, activity_log)
VALUES
(:title, :document_date, :category, :folder_path, :department, :notes, :attachment_name, :attachment_path, :attachment_ext, :attachment_size, :created_by, :created_role, :activity_log)';
$stmt = db()->prepare($sql);
$stmt->bindValue(':title', $title);
$stmt->bindValue(':document_date', $documentDate);
$stmt->bindValue(':category', $category);
$stmt->bindValue(':folder_path', $folderPath);
$stmt->bindValue(':department', $department);
$stmt->bindValue(':notes', $notes !== '' ? $notes : null, $notes !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':attachment_name', $originalName);
$stmt->bindValue(':attachment_path', $relativePath);
$stmt->bindValue(':attachment_ext', $extension);
$stmt->bindValue(':attachment_size', $size, PDO::PARAM_INT);
$stmt->bindValue(':created_by', $user['name']);
$stmt->bindValue(':created_role', $user['role']);
$stmt->bindValue(':activity_log', json_encode($activity, JSON_UNESCAPED_UNICODE));
$stmt->execute();
set_flash('success', 'Dokumen berhasil ditambahkan. Menunggu validasi Super Admin.');
return true;
}
function get_documents(array $filters = []): array
{
$where = [];
$params = [];
if (!empty($filters['status'])) {
$where[] = 'status = :status';
$params[':status'] = $filters['status'];
}
if (!empty($filters['folder'])) {
$where[] = 'folder_path = :folder';
$params[':folder'] = $filters['folder'];
}
if (!empty($filters['department'])) {
$where[] = 'department = :department';
$params[':department'] = $filters['department'];
}
$sql = 'SELECT * FROM archive_documents';
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY updated_at DESC';
$limit = isset($filters['limit']) ? max(1, min(100, (int)$filters['limit'])) : 20;
$sql .= ' LIMIT ' . $limit;
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
return $stmt->fetchAll();
}
function get_document(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM archive_documents WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch();
return $row ?: null;
}
function validate_document(int $id, array $user, string $notes = ''): bool
{
if (!is_super_admin()) {
set_flash('danger', 'Hanya Super Admin yang dapat melakukan validasi dokumen.');
return false;
}
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
set_flash('danger', 'Token validasi tidak valid.');
return false;
}
$document = get_document($id);
if (!$document) {
set_flash('danger', 'Dokumen tidak ditemukan.');
return false;
}
if ($document['status'] === 'validated') {
set_flash('info', 'Dokumen ini sudah tervalidasi.');
return false;
}
$activity = json_decode((string)$document['activity_log'], true);
if (!is_array($activity)) {
$activity = [];
}
$activity = append_activity($activity, 'validation', $user['name'], $notes !== '' ? $notes : 'Dokumen disetujui untuk akses staf.');
$stmt = db()->prepare('UPDATE archive_documents SET status = :status, validated_by = :validated_by, validation_notes = :validation_notes, activity_log = :activity_log WHERE id = :id');
$stmt->bindValue(':status', 'validated');
$stmt->bindValue(':validated_by', $user['name']);
$stmt->bindValue(':validation_notes', $notes !== '' ? $notes : null, $notes !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':activity_log', json_encode($activity, JSON_UNESCAPED_UNICODE));
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
set_flash('success', 'Dokumen berhasil divalidasi dan dapat diakses staf terkait.');
return true;
}
function archive_dashboard_metrics(): array
{
$totals = db()->query("SELECT
COUNT(*) AS total,
SUM(status = 'pending') AS pending,
SUM(status = 'validated') AS validated,
SUM(DATE(created_at) = CURDATE()) AS today_uploads
FROM archive_documents")->fetch() ?: [];
return [
'total' => (int)($totals['total'] ?? 0),
'pending' => (int)($totals['pending'] ?? 0),
'validated' => (int)($totals['validated'] ?? 0),
'today_uploads' => (int)($totals['today_uploads'] ?? 0),
];
}
function format_filesize(int $bytes): string
{
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 1) . ' MB';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 1) . ' KB';
}
return $bytes . ' B';
}
function document_activity(array $document): array
{
$activity = json_decode((string)($document['activity_log'] ?? '[]'), true);
return is_array($activity) ? $activity : [];
}
function can_access_document_file(array $document): bool
{
if (is_super_admin()) {
return true;
}
return ($document['status'] ?? '') === 'validated';
}
function attachment_mime(string $extension): string
{
return match (strtolower($extension)) {
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'mp4' => 'video/mp4',
default => 'application/octet-stream',
};
}
function can_preview_inline(array $document): bool
{
return in_array(strtolower((string)($document['attachment_ext'] ?? '')), ['pdf', 'jpg', 'jpeg', 'png', 'mp4'], true);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,110 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form'); const root = document.documentElement;
const chatInput = document.getElementById('chat-input'); const storedTheme = localStorage.getItem('archive-theme');
const chatMessages = document.getElementById('chat-messages'); const applyTheme = (theme) => {
root.setAttribute('data-bs-theme', theme);
const appendMessage = (text, sender) => { document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
const msgDiv = document.createElement('div'); const icon = button.querySelector('i');
msgDiv.classList.add('message', sender); if (icon) {
msgDiv.textContent = text; icon.className = theme === 'dark' ? 'bi bi-sun' : 'bi bi-moon-stars';
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
appendMessage(message, 'visitor');
chatInput.value = '';
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
// Artificial delay for realism
setTimeout(() => {
appendMessage(data.reply, 'bot');
}, 500);
} catch (error) {
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
} }
}); });
};
applyTheme(storedTheme === 'dark' ? 'dark' : 'light');
document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
button.addEventListener('click', () => {
const nextTheme = root.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark';
localStorage.setItem('archive-theme', nextTheme);
applyTheme(nextTheme);
});
});
document.querySelectorAll('.toast').forEach((toastElement) => {
const toast = new bootstrap.Toast(toastElement);
toast.show();
});
const sidebarToggles = document.querySelectorAll('[data-sidebar-toggle]');
sidebarToggles.forEach((button) => {
button.addEventListener('click', () => {
document.body.classList.toggle('sidebar-open');
});
});
const folderSelect = document.getElementById('folder_path');
const categoryInput = document.getElementById('category');
document.querySelectorAll('[data-folder-select]').forEach((button) => {
button.addEventListener('click', () => {
const folder = button.getAttribute('data-folder') || '';
const category = button.getAttribute('data-category') || '';
if (folderSelect) {
folderSelect.value = folder;
folderSelect.dispatchEvent(new Event('change'));
folderSelect.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (categoryInput && categoryInput.value.trim() === '') {
categoryInput.value = category;
}
document.querySelectorAll('.folder-select').forEach((node) => node.classList.remove('active'));
const cardButton = button.classList.contains('folder-add') ? button.previousElementSibling : button;
if (cardButton) {
cardButton.classList.add('active');
}
document.getElementById('uploadCard')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
document.body.classList.remove('sidebar-open');
});
});
const dropzone = document.querySelector('[data-dropzone]');
const fileInput = document.getElementById('attachment');
const fileLabel = document.querySelector('[data-file-label]');
const updateFileLabel = (files) => {
if (!fileLabel) return;
if (!files || !files.length) {
fileLabel.textContent = 'PDF, DOC, DOCX, JPG, PNG, MP4 · maksimum 10 MB';
return;
}
const file = files[0];
fileLabel.textContent = `${file.name} · ${Math.max(1, Math.round(file.size / 1024))} KB`;
};
if (dropzone && fileInput) {
dropzone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => updateFileLabel(fileInput.files));
['dragenter', 'dragover'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.remove('dragover');
});
});
dropzone.addEventListener('drop', (event) => {
const files = event.dataTransfer?.files;
if (!files || !files.length) return;
fileInput.files = files;
updateFileLabel(files);
});
}
const searchInput = document.getElementById('documentSearch');
if (searchInput) {
searchInput.addEventListener('input', () => {
const query = searchInput.value.trim().toLowerCase();
document.querySelectorAll('[data-search-row]').forEach((row) => {
const haystack = row.getAttribute('data-search-row') || '';
row.style.display = haystack.includes(query) ? '' : 'none';
});
});
}
}); });

251
document.php Normal file
View File

@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/archive_bootstrap.php';
ensure_archive_schema();
require_auth();
$documentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$document = $documentId > 0 ? get_document($documentId) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'validate_document' && $document) {
validate_document($documentId, current_user(), trim((string)($_POST['validation_notes'] ?? '')));
header('Location: document.php?id=' . $documentId);
exit;
}
$document = $documentId > 0 ? get_document($documentId) : null;
if (!$document) {
http_response_code(404);
}
$meta = page_meta('Detail Arsip KBRI Harare', 'Detail arsip digital, validasi, dan akses pratinjau aman KBRI Harare.');
$flashes = get_flashes();
$user = current_user();
$activity = $document ? document_activity($document) : [];
$canAccessFile = $document ? can_access_document_file($document) : false;
$canPreview = $document ? ($canAccessFile && can_preview_inline($document)) : false;
?>
<!doctype html>
<html lang="id" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= h($meta['title']) ?></title>
<?php if ($meta['description'] !== ''): ?>
<meta name="description" content="<?= h($meta['description']) ?>" />
<meta property="og:description" content="<?= h($meta['description']) ?>" />
<meta property="twitter:description" content="<?= h($meta['description']) ?>" />
<?php endif; ?>
<?php if ($meta['image'] !== ''): ?>
<meta property="og:image" content="<?= h($meta['image']) ?>" />
<meta property="twitter:image" content="<?= h($meta['image']) ?>" />
<?php endif; ?>
<meta property="og:title" content="<?= h($meta['title']) ?>" />
<meta property="twitter:title" content="<?= h($meta['title']) ?>" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= urlencode((string)filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
</head>
<body class="archive-app app-authenticated detail-page">
<div class="toast-container position-fixed top-0 end-0 p-3">
<?php foreach ($flashes as $flash): ?>
<div class="toast align-items-center text-bg-<?= h($flash['type']) ?> border-0 mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="4500">
<div class="d-flex">
<div class="toast-body"><?= h($flash['message']) ?></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
<?php endforeach; ?>
</div>
<header class="topbar topbar-static">
<div class="d-flex align-items-center gap-3">
<a href="index.php" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-2"></i>Kembali</a>
<div>
<p class="section-kicker mb-1">Detail arsip</p>
<h1 class="h4 mb-0">Pusat validasi & pratinjau dokumen</h1>
</div>
</div>
<div class="d-flex align-items-center gap-2 gap-lg-3">
<button class="btn btn-outline-secondary btn-sm theme-toggle" type="button" data-theme-toggle>
<i class="bi bi-moon-stars"></i>
</button>
<div class="profile-chip static-chip">
<span class="avatar"><?= h($user['avatar'] ?? 'U') ?></span>
<span class="text-start d-none d-md-inline-block">
<strong class="d-block"><?= h($user['name']) ?></strong>
<small class="text-secondary"><?= h($user['role_label'] ?? '') ?></small>
</span>
</div>
</div>
</header>
<main class="detail-layout container-fluid px-3 px-lg-4 py-4">
<?php if (!$document): ?>
<section class="card border-0 shadow-sm">
<div class="card-body p-5 text-center">
<i class="bi bi-file-earmark-x display-5 text-secondary"></i>
<h2 class="h4 mt-3">Dokumen tidak ditemukan</h2>
<p class="text-secondary">ID arsip tidak tersedia atau telah berubah.</p>
<a href="index.php" class="btn btn-primary">Kembali ke dashboard</a>
</div>
</section>
<?php else: ?>
<div class="row g-4 align-items-start">
<div class="col-12 col-xl-8">
<section class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 mb-3">
<div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<span class="status-pill <?= $document['status'] === 'validated' ? 'validated' : 'pending' ?>"><?= $document['status'] === 'validated' ? 'Tervalidasi' : 'Menunggu Validasi' ?></span>
<span class="badge text-bg-secondary-subtle text-secondary-emphasis border"><?= h($document['category']) ?></span>
</div>
<h2 class="h3 mb-1"><?= h($document['title']) ?></h2>
<p class="text-secondary mb-0"><?= h($document['folder_path']) ?></p>
</div>
<div class="detail-actions d-flex flex-wrap gap-2">
<?php if ($canAccessFile): ?>
<a href="file.php?id=<?= (int)$document['id'] ?>&disposition=download" class="btn btn-outline-secondary"><i class="bi bi-download me-2"></i>Unduh</a>
<?php if ($canPreview): ?>
<a href="file.php?id=<?= (int)$document['id'] ?>&disposition=inline" class="btn btn-outline-secondary" target="_blank" rel="noopener"><i class="bi bi-eye me-2"></i>Pratinjau</a>
<button class="btn btn-primary" type="button" onclick="window.print()"><i class="bi bi-printer me-2"></i>Cetak</button>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<div class="row g-3 detail-meta">
<div class="col-6 col-lg-3">
<span class="meta-label">Tanggal dokumen</span>
<strong><?= h(date('d M Y', strtotime((string)$document['document_date']))) ?></strong>
</div>
<div class="col-6 col-lg-3">
<span class="meta-label">Departemen</span>
<strong><?= h($document['department']) ?></strong>
</div>
<div class="col-6 col-lg-3">
<span class="meta-label">Diunggah oleh</span>
<strong><?= h($document['created_by']) ?></strong>
</div>
<div class="col-6 col-lg-3">
<span class="meta-label">Lampiran</span>
<strong><?= h(strtoupper((string)$document['attachment_ext'])) ?> · <?= h(format_filesize((int)$document['attachment_size'])) ?></strong>
</div>
</div>
<?php if ($document['notes']): ?>
<div class="annotation-box mt-4">
<span class="meta-label">Catatan dokumen</span>
<p class="mb-0"><?= nl2br(h($document['notes'])) ?></p>
</div>
<?php endif; ?>
</div>
</section>
<section class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<p class="section-kicker mb-2">Pratinjau aman</p>
<h2 class="h5 mb-1">Akses file sesuai status validasi</h2>
</div>
<span class="badge text-bg-dark"><?= $canAccessFile ? 'Akses dibuka' : 'Terkunci' ?></span>
</div>
<?php if ($canPreview): ?>
<div class="preview-frame">
<?php if (in_array(strtolower((string)$document['attachment_ext']), ['jpg', 'jpeg', 'png'], true)): ?>
<img src="file.php?id=<?= (int)$document['id'] ?>&disposition=inline" alt="Pratinjau <?= h($document['title']) ?>" class="img-fluid rounded-3 w-100">
<?php elseif (strtolower((string)$document['attachment_ext']) === 'mp4'): ?>
<video controls class="w-100 rounded-3" preload="metadata">
<source src="file.php?id=<?= (int)$document['id'] ?>&disposition=inline" type="video/mp4">
</video>
<?php else: ?>
<iframe src="file.php?id=<?= (int)$document['id'] ?>&disposition=inline" title="Pratinjau dokumen" class="preview-iframe"></iframe>
<?php endif; ?>
</div>
<?php elseif ($canAccessFile): ?>
<div class="empty-panel tall">
<i class="bi bi-file-earmark-lock2"></i>
<p class="mb-1 fw-semibold">Jenis file tidak mendukung pratinjau inline.</p>
<p class="mb-0 text-secondary small">Unduh lampiran untuk membuka dokumen ini.</p>
</div>
<?php else: ?>
<div class="empty-panel tall">
<i class="bi bi-shield-lock"></i>
<p class="mb-1 fw-semibold">Pratinjau dikunci sampai validasi selesai.</p>
<p class="mb-0 text-secondary small">Setelah disetujui Super Admin, staf dapat pratinjau, unduh, dan cetak dari halaman ini.</p>
</div>
<?php endif; ?>
</div>
</section>
</div>
<div class="col-12 col-xl-4">
<?php if (is_super_admin() && $document['status'] !== 'validated'): ?>
<section class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<p class="section-kicker mb-2">Aksi Super Admin</p>
<h2 class="h5 mb-1">Validasi dokumen</h2>
<p class="text-secondary small">Setelah disetujui, dokumen terbuka untuk pratinjau, unduh, dan cetak oleh staf terkait.</p>
<form method="post" class="vstack gap-3 mt-3">
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
<input type="hidden" name="action" value="validate_document">
<div>
<label class="form-label" for="validation_notes">Catatan validasi</label>
<textarea id="validation_notes" class="form-control" name="validation_notes" rows="4" placeholder="Contoh: Metadata sesuai, siap diakses unit kerja."></textarea>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-patch-check me-2"></i>Validasi dokumen</button>
</form>
</div>
</section>
<?php endif; ?>
<section class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<p class="section-kicker mb-2">Status akses</p>
<h2 class="h5 mb-1">Ringkasan kepatuhan</h2>
<ul class="list-unstyled security-list mb-0">
<li><i class="bi bi-check2-circle"></i><span>Password user demo diproses dengan verifikasi Bcrypt.</span></li>
<li><i class="bi bi-check2-circle"></i><span>Form terlindungi CSRF token dan prepared statements PDO.</span></li>
<li><i class="bi bi-check2-circle"></i><span>File hanya di-stream melalui gerbang aplikasi, bukan tautan publik langsung.</span></li>
<li><i class="bi bi-check2-circle"></i><span><?= $document['status'] === 'validated' ? 'Dokumen siap diakses sesuai hak role.' : 'Dokumen menunggu otorisasi Super Admin.' ?></span></li>
</ul>
</div>
</section>
<section class="card border-0 shadow-sm">
<div class="card-body p-4">
<p class="section-kicker mb-2">Audit trail</p>
<h2 class="h5 mb-1">Riwayat aktivitas</h2>
<div class="timeline mt-3">
<?php foreach ($activity as $entry): ?>
<article class="timeline-item">
<span class="timeline-dot"></span>
<div>
<strong class="d-block text-capitalize"><?= h($entry['action'] ?? '') ?></strong>
<small class="text-secondary d-block"><?= h($entry['actor'] ?? '') ?> · <?= h($entry['timestamp'] ?? '') ?></small>
<?php if (!empty($entry['notes'])): ?>
<p class="mb-0 mt-2 text-secondary small"><?= h($entry['notes']) ?></p>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
</div>
</section>
</div>
</div>
<?php endif; ?>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?= urlencode((string)filemtime(__DIR__ . '/assets/js/main.js')) ?>"></script>
</body>
</html>

38
file.php Normal file
View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/archive_bootstrap.php';
ensure_archive_schema();
require_auth();
$documentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$disposition = (string)($_GET['disposition'] ?? 'inline');
$disposition = $disposition === 'download' ? 'attachment' : 'inline';
$document = $documentId > 0 ? get_document($documentId) : null;
if (!$document) {
http_response_code(404);
echo 'Dokumen tidak ditemukan.';
exit;
}
if (!can_access_document_file($document)) {
http_response_code(403);
echo 'Dokumen belum tersedia untuk diakses.';
exit;
}
$filePath = __DIR__ . '/' . ltrim((string)$document['attachment_path'], '/');
if (!is_file($filePath)) {
http_response_code(404);
echo 'Lampiran tidak ditemukan di server.';
exit;
}
header('Content-Type: ' . attachment_mime((string)$document['attachment_ext']));
header('Content-Length: ' . filesize($filePath));
header('Content-Disposition: ' . $disposition . '; filename="' . rawurlencode((string)$document['attachment_name']) . '"');
header('X-Content-Type-Options: nosniff');
readfile($filePath);
exit;

577
index.php
View File

@ -1,150 +1,463 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; require_once __DIR__ . '/archive_bootstrap.php';
$now = date('Y-m-d H:i:s');
ensure_archive_schema();
if (isset($_GET['reset_otp'])) {
unset($_SESSION['pending_auth']);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string)($_POST['action'] ?? '');
if ($action === 'login_request') {
process_login_request();
header('Location: index.php');
exit;
}
if ($action === 'verify_otp') {
process_otp_verification();
header('Location: index.php');
exit;
}
if ($action === 'logout') {
if (verify_csrf($_POST['csrf_token'] ?? null)) {
logout_user();
} else {
set_flash('danger', 'Permintaan logout tidak valid.');
}
header('Location: index.php');
exit;
}
if ($action === 'upload_document' && is_authenticated()) {
create_document($_POST, $_FILES, current_user());
header('Location: index.php');
exit;
}
}
$meta = page_meta('Sistem Informasi Arsip Digital KBRI Harare', 'Sistem arsip digital ultra-aman untuk pengelolaan dokumen diplomatik, konsuler, dan internal KBRI Harare.');
$flashes = get_flashes();
$user = current_user();
$pendingAuth = $_SESSION['pending_auth'] ?? null;
$folderTree = archive_folder_tree();
$folderOptions = folder_options();
$categoryOptions = category_options();
$selectedFolder = isset($_GET['folder']) && in_array($_GET['folder'], $folderOptions, true) ? (string)$_GET['folder'] : ($folderOptions[0] ?? '');
$dashboardMetrics = is_authenticated() ? archive_dashboard_metrics() : ['total' => 0, 'pending' => 0, 'validated' => 0, 'today_uploads' => 0];
$recentDocuments = is_authenticated() ? get_documents(['limit' => 8]) : [];
$pendingDocuments = is_authenticated() ? get_documents(['status' => 'pending', 'limit' => 5]) : [];
$loginAudit = $_SESSION['login_audit'] ?? null;
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="id" data-bs-theme="light">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title> <title><?= h($meta['title']) ?></title>
<?php <?php if ($meta['description'] !== ''): ?>
// Read project preview data from environment <meta name="description" content="<?= h($meta['description']) ?>" />
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; <meta property="og:description" content="<?= h($meta['description']) ?>" />
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; <meta property="twitter:description" content="<?= h($meta['description']) ?>" />
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?> <?php endif; ?>
<?php if ($projectImageUrl): ?> <?php if ($meta['image'] !== ''): ?>
<!-- Open Graph image --> <meta property="og:image" content="<?= h($meta['image']) ?>" />
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="twitter:image" content="<?= h($meta['image']) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?> <?php endif; ?>
<meta property="og:title" content="<?= h($meta['title']) ?>" />
<meta property="twitter:title" content="<?= h($meta['title']) ?>" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
:root { <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
--bg-color-start: #6a11cb; <link rel="stylesheet" href="assets/css/custom.css?v=<?= urlencode((string)filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head> </head>
<body> <body class="archive-app <?= $user ? 'app-authenticated' : 'app-guest' ?>">
<main> <div class="toast-container position-fixed top-0 end-0 p-3">
<div class="card"> <?php foreach ($flashes as $flash): ?>
<h1>Analyzing your requirements and generating your website…</h1> <div class="toast align-items-center text-bg-<?= h($flash['type']) ?> border-0 mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="4500">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="d-flex">
<span class="sr-only">Loading…</span> <div class="toast-body"><?= h($flash['message']) ?></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div> </div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div> </div>
<?php endforeach; ?>
</div>
<?php if (!$user): ?>
<main class="login-shell">
<section class="login-splash">
<div class="glass-city" aria-hidden="true">
<span class="tower tower-a"></span>
<span class="tower tower-b"></span>
<span class="tower tower-c"></span>
<span class="grid-line grid-a"></span>
<span class="grid-line grid-b"></span>
</div>
<div class="login-copy">
<span class="eyebrow">Sistem Informasi Arsip Digital</span>
<h1>KEDUTAAN BESAR REPUBLIK INDONESIA HARARE</h1>
<p class="lead">Selamat datang di Sistem Informasi Arsip Digital KBRI Harare.</p>
<p class="muted-copy">Mewujudkan tata kelola administrasi yang tertib, transparan, dan akuntabel untuk pelayanan publik prima.</p>
<div class="security-badges">
<span><i class="bi bi-shield-lock"></i> Bcrypt + CSRF</span>
<span><i class="bi bi-envelope-check"></i> MFA via Email OTP</span>
<span><i class="bi bi-journal-text"></i> Audit trail dokumen</span>
</div>
</div>
</section>
<section class="login-panel card border-0 shadow-sm">
<div class="card-body p-4 p-lg-5">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<p class="section-kicker mb-2">Akses aman staf internal</p>
<h2 class="h4 mb-1"><?= is_array($pendingAuth) ? 'Verifikasi MFA' : 'Masuk ke brankas digital' ?></h2>
<p class="text-secondary mb-0 small"><?= is_array($pendingAuth) ? 'Masukkan kode OTP yang berlaku 5 menit.' : 'Gunakan email kedinasan, password, dan verifikasi OTP.' ?></p>
</div>
<button class="btn btn-outline-secondary btn-sm theme-toggle" type="button" data-theme-toggle>
<i class="bi bi-moon-stars"></i>
</button>
</div>
<?php if (is_array($pendingAuth)): ?>
<div class="alert alert-secondary border mb-4" role="status">
Kode OTP dikirim ke <strong><?= h($pendingAuth['masked_email'] ?? '') ?></strong>.
<?php if (empty($pendingAuth['mail_sent'])): ?>
<span class="d-block small mt-2">Mode uji aktif karena SMTP belum dikonfigurasi.</span>
<?php endif; ?>
</div>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
<input type="hidden" name="action" value="verify_otp">
<div>
<label for="otp" class="form-label">Kode OTP</label>
<input id="otp" name="otp" inputmode="numeric" maxlength="6" class="form-control form-control-lg" placeholder="Masukkan 6 digit kode" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">Verifikasi & Masuk</button>
<a href="index.php?reset_otp=1" class="btn btn-link text-decoration-none p-0">Kembali ke form login</a>
</form>
<?php else: ?>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
<input type="hidden" name="action" value="login_request">
<div>
<label for="email" class="form-label">Email</label>
<input id="email" name="email" type="email" class="form-control form-control-lg" placeholder="nama@kbriharare.go.id" required>
</div>
<div>
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" class="form-control form-control-lg" placeholder="Masukkan password" required>
</div>
<div>
<label for="mfa" class="form-label">MFA</label>
<input id="mfa" class="form-control" value="Email OTP" readonly aria-readonly="true">
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">Lanjutkan ke OTP</button>
</form>
<?php endif; ?>
<div class="demo-credentials mt-4">
<p class="mb-2 fw-semibold small text-uppercase text-secondary">Akun demo internal</p>
<div class="small text-secondary">Super Admin: <code>superadmin@kbriharare.go.id</code></div>
<div class="small text-secondary">Staf: <code>staf.politik@kbriharare.go.id</code></div>
<div class="small text-secondary">Password: <code>Harare2026!</code></div>
</div>
</div>
</section>
</main> </main>
<footer> <?php else: ?>
Page updated: <?= htmlspecialchars($now) ?> (UTC) <div class="app-shell">
</footer> <aside class="sidebar-panel" id="sidebarPanel">
<div class="sidebar-top">
<div>
<p class="section-kicker mb-1">Navigasi arsip</p>
<h2 class="h6 mb-0">Pohon folder KBRI Harare</h2>
</div>
<button class="btn btn-outline-secondary btn-sm d-lg-none" type="button" data-sidebar-toggle>
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="folder-tree">
<?php foreach ($folderTree as $section): ?>
<details class="folder-group" open>
<summary>
<span><?= h($section['group']) ?></span>
<i class="bi bi-chevron-down"></i>
</summary>
<div class="folder-links">
<?php foreach ($section['items'] as $item): ?>
<div class="folder-item">
<button
type="button"
class="folder-select"
data-folder-select
data-folder="<?= h($item['folder']) ?>"
data-category="<?= h($item['label']) ?>"
>
<span class="folder-name"><?= h($item['label']) ?></span>
<small><?= h($item['hint']) ?></small>
</button>
<button type="button" class="btn btn-sm btn-outline-primary folder-add" data-folder-select data-folder="<?= h($item['folder']) ?>" data-category="<?= h($item['label']) ?>" aria-label="Tambah dokumen ke <?= h($item['folder']) ?>">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
</div>
</aside>
<div class="app-main">
<header class="topbar">
<div class="d-flex align-items-center gap-3">
<button class="btn btn-outline-secondary d-lg-none" type="button" data-sidebar-toggle>
<i class="bi bi-list"></i>
</button>
<div>
<p class="section-kicker mb-1">Sistem Informasi Arsip Digital</p>
<h1 class="h4 mb-0">KBRI Harare Secure Vault</h1>
</div>
</div>
<div class="d-flex align-items-center gap-2 gap-lg-3">
<button class="btn btn-outline-secondary btn-sm theme-toggle" type="button" data-theme-toggle>
<i class="bi bi-moon-stars"></i>
</button>
<div class="dropdown">
<button class="btn profile-chip dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<span class="avatar"><?= h($user['avatar'] ?? 'U') ?></span>
<span class="text-start d-none d-md-inline-block">
<strong class="d-block"><?= h($user['name']) ?></strong>
<small class="text-secondary"><?= h($user['role_label'] ?? '') ?> · <?= h($user['department'] ?? '') ?></small>
</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li><span class="dropdown-item-text small text-secondary">Foto profil dapat diaktifkan pada iterasi berikutnya.</span></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
<input type="hidden" name="action" value="logout">
<button type="submit" class="dropdown-item text-danger">Keluar</button>
</form>
</li>
</ul>
</div>
</div>
</header>
<main class="content-grid">
<section class="hero-panel card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-4">
<div>
<p class="section-kicker mb-2">Workflow MVP siap dipakai</p>
<h2 class="display-title">Unggah Validasi Pratinjau aman Riwayat audit</h2>
<p class="text-secondary mb-4">Staf dapat menempatkan dokumen pada folder yang tepat, sementara Super Admin memvalidasi sebelum dokumen dapat dipratinjau, diunduh, atau dicetak.</p>
<div class="hero-actions d-flex flex-wrap gap-2">
<a href="#uploadCard" class="btn btn-primary"><i class="bi bi-cloud-arrow-up me-2"></i>Tambah Dokumen</a>
<a href="#documentTable" class="btn btn-outline-secondary"><i class="bi bi-journal-text me-2"></i>Lihat Arsip</a>
</div>
</div>
<div class="session-panel">
<div class="metric-box">
<span>Total arsip</span>
<strong><?= number_format($dashboardMetrics['total']) ?></strong>
</div>
<div class="metric-box">
<span>Menunggu validasi</span>
<strong><?= number_format($dashboardMetrics['pending']) ?></strong>
</div>
<div class="metric-box">
<span>Tervalidasi</span>
<strong><?= number_format($dashboardMetrics['validated']) ?></strong>
</div>
<div class="metric-box">
<span>Upload hari ini</span>
<strong><?= number_format($dashboardMetrics['today_uploads']) ?></strong>
</div>
</div>
</div>
</div>
</section>
<section class="card border-0 shadow-sm" id="uploadCard">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<p class="section-kicker mb-2">Input dokumen</p>
<h2 class="h5 mb-1">Tambah dokumen ke folder terpilih</h2>
<p class="text-secondary mb-0 small">Gunakan drag-and-drop, isi metadata inti, lalu sistem menyimpan arsip ke folder terkait secara otomatis.</p>
</div>
<span class="badge text-bg-secondary-subtle text-secondary-emphasis border">CSRF aktif</span>
</div>
<form method="post" enctype="multipart/form-data" class="row g-3" id="uploadForm">
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
<input type="hidden" name="action" value="upload_document">
<div class="col-12 col-xl-7">
<label class="form-label" for="title">Judul dokumen</label>
<input class="form-control" id="title" name="title" placeholder="Contoh: Nota Diplomatik Dukungan Kegiatan RI" required>
</div>
<div class="col-6 col-xl-2">
<label class="form-label" for="document_date">Tanggal</label>
<input class="form-control" id="document_date" name="document_date" type="date" value="<?= h(date('Y-m-d')) ?>" required>
</div>
<div class="col-6 col-xl-3">
<label class="form-label" for="category">Kategori</label>
<input class="form-control" id="category" name="category" list="categoryList" placeholder="Pilih / ketik kategori" required>
<datalist id="categoryList">
<?php foreach ($categoryOptions as $category): ?>
<option value="<?= h($category) ?>"></option>
<?php endforeach; ?>
</datalist>
</div>
<div class="col-12 col-lg-7">
<label class="form-label" for="folder_path">Folder tujuan</label>
<select class="form-select" id="folder_path" name="folder_path" required>
<?php foreach ($folderOptions as $folder): ?>
<option value="<?= h($folder) ?>" <?= $selectedFolder === $folder ? 'selected' : '' ?>><?= h($folder) ?></option>
<?php endforeach; ?>
</select>
<div class="form-text">Klik folder di sidebar untuk mengisi otomatis.</div>
</div>
<div class="col-12 col-lg-5">
<label class="form-label">Lampiran</label>
<div class="dropzone" data-dropzone>
<input class="visually-hidden" type="file" id="attachment" name="attachment" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.mp4" required>
<div>
<i class="bi bi-file-earmark-arrow-up"></i>
<p class="mb-1 fw-semibold">Tarik file ke sini atau klik untuk memilih</p>
<small class="text-secondary" data-file-label>PDF, DOC, DOCX, JPG, PNG, MP4 · maksimum 10 MB</small>
</div>
</div>
</div>
<div class="col-12">
<label class="form-label" for="notes">Catatan</label>
<textarea class="form-control" id="notes" name="notes" rows="4" placeholder="Isi ringkasan, status, atau konteks diplomatik singkat."></textarea>
</div>
<div class="col-12 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
<div class="small text-secondary">Akses staf untuk pratinjau/unduh/cetak akan terbuka setelah validasi Super Admin.</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Simpan dokumen</button>
</div>
</form>
</div>
</section>
<section class="card border-0 shadow-sm compact-card">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<p class="section-kicker mb-2">Kontrol akses</p>
<h2 class="h5 mb-1">Status sesi & keamanan</h2>
</div>
<span class="badge text-bg-dark"><?= h($user['role_label'] ?? '') ?></span>
</div>
<ul class="list-unstyled security-list mb-0">
<li><i class="bi bi-check2-circle"></i><span>Login terakhir: <?= h($loginAudit['timestamp'] ?? date('Y-m-d H:i:s')) ?></span></li>
<li><i class="bi bi-check2-circle"></i><span>MFA: <?= h($loginAudit['mfa_method'] ?? 'Email OTP') ?></span></li>
<li><i class="bi bi-check2-circle"></i><span>Hak akses: <?= is_super_admin() ? 'validasi, unggah, dan akses semua dokumen' : 'unggah dan akses dokumen setelah validasi' ?></span></li>
<li><i class="bi bi-check2-circle"></i><span>Proteksi aktif: prepared statements, CSRF token, dan rate limiting sesi.</span></li>
</ul>
</div>
</section>
<section class="card border-0 shadow-sm compact-card">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<p class="section-kicker mb-2">Validasi dokumen</p>
<h2 class="h5 mb-1">Queue persetujuan</h2>
</div>
<span class="badge text-bg-warning"><?= number_format($dashboardMetrics['pending']) ?> pending</span>
</div>
<?php if ($pendingDocuments): ?>
<div class="queue-list">
<?php foreach ($pendingDocuments as $document): ?>
<a class="queue-item" href="document.php?id=<?= (int)$document['id'] ?>">
<div>
<strong><?= h($document['title']) ?></strong>
<span><?= h($document['folder_path']) ?></span>
</div>
<span class="status-pill pending">Menunggu</span>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-panel">
<i class="bi bi-inbox"></i>
<p class="mb-0">Belum ada dokumen yang menunggu validasi.</p>
</div>
<?php endif; ?>
</div>
</section>
<section class="card border-0 shadow-sm table-card" id="documentTable">
<div class="card-body p-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div>
<p class="section-kicker mb-2">Daftar arsip</p>
<h2 class="h5 mb-1">Dokumen terbaru</h2>
<p class="text-secondary mb-0 small">Setiap arsip memiliki detail metadata, status validasi, dan jejak audit.</p>
</div>
<div class="search-box">
<i class="bi bi-search"></i>
<input type="search" class="form-control" id="documentSearch" placeholder="Cari judul, folder, atau pembuat">
</div>
</div>
<?php if ($recentDocuments): ?>
<div class="table-responsive">
<table class="table align-middle archive-table mb-0">
<thead>
<tr>
<th>Dokumen</th>
<th>Folder</th>
<th>Upload</th>
<th>Status</th>
<th class="text-end">Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentDocuments as $document): ?>
<tr data-search-row="<?= h(strtolower($document['title'] . ' ' . $document['folder_path'] . ' ' . $document['created_by'])) ?>">
<td>
<strong class="d-block"><?= h($document['title']) ?></strong>
<small class="text-secondary"><?= h($document['category']) ?> · <?= h(format_filesize((int)$document['attachment_size'])) ?></small>
</td>
<td><?= h($document['folder_path']) ?></td>
<td>
<span class="d-block"><?= h($document['created_by']) ?></span>
<small class="text-secondary"><?= h(date('d M Y H:i', strtotime((string)$document['created_at']))) ?></small>
</td>
<td><span class="status-pill <?= $document['status'] === 'validated' ? 'validated' : 'pending' ?>"><?= $document['status'] === 'validated' ? 'Tervalidasi' : 'Menunggu' ?></span></td>
<td class="text-end"><a class="btn btn-sm btn-outline-secondary" href="document.php?id=<?= (int)$document['id'] ?>">Buka detail</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty-panel tall">
<i class="bi bi-folder2-open"></i>
<p class="mb-1 fw-semibold">Belum ada arsip.</p>
<p class="mb-0 text-secondary small">Mulai dengan memilih folder di sidebar lalu unggah dokumen pertama Anda.</p>
</div>
<?php endif; ?>
</div>
</section>
</main>
</div>
</div>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?= urlencode((string)filemtime(__DIR__ . '/assets/js/main.js')) ?>"></script>
</body> </body>
</html> </html>