""" auth.py ======= Verifikasi login, pengelolaan role login lewat st.session_state, ganti password (edit profil), dan manajemen user (khusus admin). PENYIMPANAN USER: Data user disimpan di file data/users.json (BUKAN lagi di .streamlit/secrets.toml). Alasannya: secrets.toml cuma bisa DIBACA oleh aplikasi yang sedang berjalan, tidak bisa ditulis ulang secara resmi -- padahal fitur "tambah user" / "ganti password" butuh MENULIS data baru saat aplikasi jalan. File JSON biasa bisa dibaca & ditulis kapan saja. File data/users.json WAJIB masuk .gitignore (sama seperti secrets.toml sebelumnya) karena isinya password ter-hash user asli. MIGRASI OTOMATIS: Kalau data/users.json belum ada, tapi .streamlit/secrets.toml (dari setup sebelumnya) ada isinya, data akan otomatis dipindahkan sekali ke users.json saat pertama kali dijalankan -- supaya Anda tidak perlu input ulang password admin/pemilik dari awal. Kalau dua-duanya belum ada sama sekali, dibuatkan default: admin / admin123 (role: admin) pemilik / pemilik123 (role: pemilik) -- SEGERA ganti password default ini lewat menu "Edit Profil" setelah login pertama kali. """ from __future__ import annotations import hashlib import json import re from pathlib import Path import streamlit as st USERS_FILE = Path("data/users.json") _DEFAULT_USERS: dict[str, dict[str, str]] = { "admin": { "password_hash": hashlib.sha256(b"admin123").hexdigest(), "role": "admin", "nama": "Administrator", }, "pemilik": { "password_hash": hashlib.sha256(b"pemilik123").hexdigest(), "role": "pemilik", "nama": "Pemilik Toko", }, } def _hash_password(password: str) -> str: return hashlib.sha256(password.encode("utf-8")).hexdigest() def _migrate_from_secrets_if_needed() -> dict[str, dict[str, str]] | None: """Kalau users.json belum ada tapi secrets.toml (setup lama) ada, pindahkan datanya sekali. Return None kalau tidak ada yang bisa dimigrasi.""" try: users_secrets = st.secrets["users"] except Exception: return None migrated = { username: { "password_hash": data["password_hash"], "role": data["role"], "nama": data["nama"], } for username, data in users_secrets.items() } return migrated or None def _load_users() -> dict[str, dict[str, str]]: if USERS_FILE.exists(): with USERS_FILE.open("r", encoding="utf-8") as f: return json.load(f) # users.json belum ada -> coba migrasi dari secrets.toml lama, # kalau tidak ada juga -> pakai default bawaan. users = _migrate_from_secrets_if_needed() or dict(_DEFAULT_USERS) _save_users(users) return users def _save_users(users: dict[str, dict[str, str]]) -> None: USERS_FILE.parent.mkdir(parents=True, exist_ok=True) with USERS_FILE.open("w", encoding="utf-8") as f: json.dump(users, f, indent=2, ensure_ascii=False) # --------------------------------------------------------------------------- # Status login (session_state) # --------------------------------------------------------------------------- def is_logged_in() -> bool: return st.session_state.get("logged_in", False) def get_current_role() -> str | None: return st.session_state.get("role") def get_current_username() -> str | None: return st.session_state.get("username") def get_display_name() -> str: return st.session_state.get("nama_tampilan", get_current_username() or "Pengguna") def attempt_login(username: str, password: str) -> bool: users = _load_users() user = users.get(username) if user is not None and user["password_hash"] == _hash_password(password): st.session_state["logged_in"] = True st.session_state["username"] = username st.session_state["role"] = user["role"] st.session_state["nama_tampilan"] = user["nama"] return True return False def logout() -> None: for key in ("logged_in", "username", "role", "nama_tampilan"): st.session_state.pop(key, None) # --------------------------------------------------------------------------- # Edit profil (ganti password sendiri) -- untuk admin MAUPUN pemilik # --------------------------------------------------------------------------- _POLA_USERNAME_VALID = re.compile(r"^[a-zA-Z0-9._-]+$") def _validasi_password(password: str) -> str | None: """ Aturan password: WAJIB mengandung minimal 1 huruf dan 1 angka. (Tidak ada aturan simbol/huruf besar-kecil -- disengaja sesederhana ini karena pemakainya cuma internal toko sendiri, bukan publik.) Return None kalau valid, atau pesan error kalau tidak valid. """ if not any(c.isalpha() for c in password): return "Password harus mengandung minimal 1 huruf." if not any(c.isdigit() for c in password): return "Password harus mengandung minimal 1 angka." return None def update_own_profile(username_lama: str, username_baru: str, nama_baru: str) -> tuple[bool, str]: """ Ganti username & nama tampilan milik akun yang SEDANG LOGIN sendiri. Validasi: - Username tidak boleh kosong, tidak boleh ada spasi/simbol aneh (cuma huruf, angka, titik, underscore, strip). - Kalau username baru BEDA dari yang lama, WAJIB belum dipakai user lain -- inilah "kontrol" yang diminta: tidak boleh bentrok dengan username yang sudah ada. - Boleh submit tanpa mengubah username sama sekali (username baru == username lama), itu dianggap valid, cuma nama-nya yang berubah. """ username_baru = username_baru.strip() nama_baru = nama_baru.strip() if not username_baru: return False, "Username tidak boleh kosong." if not _POLA_USERNAME_VALID.match(username_baru): return False, "Username cuma boleh berisi huruf, angka, titik (.), underscore (_), atau strip (-) -- tanpa spasi." if not nama_baru: return False, "Nama tampilan tidak boleh kosong." users = _load_users() if username_lama not in users: return False, "User tidak ditemukan." if username_baru != username_lama and username_baru in users: return False, f"Username '{username_baru}' sudah dipakai user lain. Pilih username lain." data = users.pop(username_lama) data["nama"] = nama_baru users[username_baru] = data _save_users(users) # Kalau username berubah, session_state juga WAJIB ikut diperbarui -- # kalau tidak, pada rerun berikutnya aplikasi masih mencari username # LAMA (yang sudah tidak ada lagi di users.json) dan user otomatis # ter-logout tanpa sebab yang jelas. if st.session_state.get("username") == username_lama: st.session_state["username"] = username_baru st.session_state["nama_tampilan"] = nama_baru if username_baru != username_lama: return True, f"Profil berhasil diperbarui. Username Anda sekarang: '{username_baru}'." return True, "Profil berhasil diperbarui." def verify_password(username: str, password: str) -> bool: """Cek apakah `password` cocok dengan password akun `username` saat ini. Dipakai untuk validasi password lama -- baik di dalam change_own_password di bawah, maupun langsung dari UI (edit_profil.py) supaya urutan pesan error bisa diatur sesuai urutan kolom di form (password lama dicek duluan, baru validasi password baru).""" users = _load_users() user = users.get(username) if user is None: return False return user["password_hash"] == _hash_password(password) def change_own_password(username: str, old_password: str, new_password: str) -> tuple[bool, str]: """ Ganti password milik akun yang sedang login sendiri. WAJIB verifikasi password lama dulu -- supaya kalau sesi login ketinggalan terbuka, orang lain tidak bisa ambil alih akun cuma dengan mengisi form ganti password. >>> FIX: urutan pengecekan diubah supaya PASSWORD LAMA dicek PALING AWAL (sebelum validasi format password baru) -- sebelumnya validasi format password baru dicek duluan, jadi kalau password baru tidak valid DAN password lama juga salah, yang ditampilkan cuma pesan soal password baru -- padahal harusnya password lama yang salah itu lebih mendasar dan perlu diberi tahu lebih dulu. Return (berhasil: bool, pesan: str). """ users = _load_users() user = users.get(username) if user is None: return False, "User tidak ditemukan." if user["password_hash"] != _hash_password(old_password): return False, "Password lama yang Anda masukkan salah." error = _validasi_password(new_password) if error: return False, error if _hash_password(new_password) == user["password_hash"]: return False, "Password baru tidak boleh sama dengan password lama." user["password_hash"] = _hash_password(new_password) _save_users(users) return True, "Password berhasil diubah. Silakan gunakan password baru saat login berikutnya." # --------------------------------------------------------------------------- # Manajemen user -- KHUSUS ADMIN (tidak perlu password lama user lain, # karena otoritasnya sudah dibuktikan lewat admin sendiri sudah login) # --------------------------------------------------------------------------- def list_users() -> list[dict[str, str]]: """Daftar semua user untuk ditampilkan di tabel admin (tanpa password_hash).""" users = _load_users() return [ {"username": uname, "nama": data["nama"], "role": data["role"]} for uname, data in users.items() ] def add_user(username: str, password: str, role: str, nama: str) -> tuple[bool, str]: if role not in ("admin", "pemilik"): return False, "Role harus 'admin' atau 'pemilik'." if not username or not username.strip(): return False, "Username tidak boleh kosong." if not _POLA_USERNAME_VALID.match(username.strip()): return False, "Username cuma boleh berisi huruf, angka, titik (.), underscore (_), atau strip (-) -- tanpa spasi." error = _validasi_password(password) if error: return False, error users = _load_users() username = username.strip() if username in users: return False, f"Username '{username}' sudah dipakai." users[username] = { "password_hash": _hash_password(password), "role": role, "nama": nama.strip() or username, } _save_users(users) return True, f"User '{username}' berhasil ditambahkan." def admin_edit_user( username: str, nama: str, role: str, new_password: str | None = None ) -> tuple[bool, str]: """ Satu fungsi gabungan untuk tombol "Edit" di Manajemen User: - selalu update nama & role - kalau new_password diisi (tidak None/kosong), password ikut direset (TIDAK perlu password lama, karena otoritas admin sudah cukup) """ if role not in ("admin", "pemilik"): return False, "Role harus 'admin' atau 'pemilik'." if new_password: error = _validasi_password(new_password) if error: return False, error users = _load_users() if username not in users: return False, "User tidak ditemukan." users[username]["nama"] = nama.strip() or username users[username]["role"] = role if new_password: users[username]["password_hash"] = _hash_password(new_password) _save_users(users) return True, f"Data user '{username}' berhasil diperbarui." def admin_reset_password(username: str, new_password: str) -> tuple[bool, str]: """Admin reset-kan password user lain, TANPA perlu tahu password lamanya.""" error = _validasi_password(new_password) if error: return False, error users = _load_users() if username not in users: return False, "User tidak ditemukan." users[username]["password_hash"] = _hash_password(new_password) _save_users(users) return True, f"Password untuk '{username}' berhasil direset." def update_user_profile(username: str, role: str | None = None, nama: str | None = None) -> tuple[bool, str]: users = _load_users() if username not in users: return False, "User tidak ditemukan." if role is not None: if role not in ("admin", "pemilik"): return False, "Role harus 'admin' atau 'pemilik'." users[username]["role"] = role if nama is not None and nama.strip(): users[username]["nama"] = nama.strip() _save_users(users) return True, f"Data user '{username}' berhasil diperbarui." def delete_user(username: str, requested_by: str) -> tuple[bool, str]: users = _load_users() if username not in users: return False, "User tidak ditemukan." if username == requested_by: return False, "Anda tidak bisa menghapus akun Anda sendiri saat sedang login." jumlah_admin = sum(1 for u in users.values() if u["role"] == "admin") if users[username]["role"] == "admin" and jumlah_admin <= 1: return False, "Tidak bisa menghapus satu-satunya akun admin yang tersisa." del users[username] _save_users(users) return True, f"User '{username}' berhasil dihapus." # --------------------------------------------------------------------------- # Form login (tidak berubah dari sebelumnya) # --------------------------------------------------------------------------- def render_login_form() -> None: if is_logged_in(): return st.markdown( """ """, unsafe_allow_html=True, ) _, col, _ = st.columns([0.7, 1.3, 0.7]) with col: st.markdown("
", unsafe_allow_html=True) with st.form("login_form", border=True): st.markdown( """UD Aan Jaya Toko