TIF_E41253317/utils/auth.py

436 lines
16 KiB
Python

"""
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(
"""
<style>
div[data-testid="stForm"] {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 2.2rem 2rem 1.6rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
}
.block-container {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.st-key-login_card_wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.st-key-login_card_wrapper > div {
width: 480px;
max-width: 92vw;
}
</style>
""",
unsafe_allow_html=True,
)
_, col, _ = st.columns([0.7, 1.3, 0.7])
with col:
st.markdown("<div style='margin-top:8vh;'></div>", unsafe_allow_html=True)
with st.form("login_form", border=True):
st.markdown(
"""
<div style='text-align:center; margin-bottom:1.6rem;'>
<div style='font-size:50px; margin-bottom:5px;'>📈</div>
<h2 style='margin:0; padding:0; font-size:24px; font-weight:700;
line-height:1.3; color:#1a1a2e; white-space:nowrap;'>
Peramalan Permintaan Produk
</h2>
<h4 style='margin:6px 0 0; padding:0; font-size:18px; font-style:italic;
font-weight:500; line-height:1.3; color:#444;'>
Fast-Moving Consumer Goods
</h4>
<p style='margin:6px 0 0; padding:0; font-size:15px; color:#64748B;'>
UD Aan Jaya Toko
</p>
</div>
""",
unsafe_allow_html=True,
)
username = st.text_input("Username", placeholder="Masukan username")
password = st.text_input("Password", type="password", placeholder="Masukan password")
submitted = st.form_submit_button("Masuk", use_container_width=True, type="primary")
if submitted:
if attempt_login(username, password):
st.rerun()
else:
st.error("Username atau password salah.")