436 lines
16 KiB
Python
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.") |