TIF_E41253317/views/manajemen_user.py

307 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
manajemen_user.py
==================
Halaman khusus ADMIN untuk mengelola akun user.
Layout:
"Manajemen User"
"Daftar User" [+ Tambah User]
Tabel (st.dataframe asli -- sama seperti tabel di halaman lain):
No | Username | Nama | Role
Klik salah satu baris untuk memilihnya -> muncul tombol
"✏️ Edit User Ini" dan "🗑️ Hapus User Ini" di bawah tabel.
Kalau baris yang dipilih adalah akun sendiri yang sedang login,
tombol hapus tetap tampil tapi dinonaktifkan (disabled).
Semua aksi (tambah / edit / hapus) tampil sebagai POPUP (st.dialog),
mengikuti pola yang sama seperti panel "Perbarui Model (.pkl)" di
Perbandingan Konfigurasi: aksi dikonfirmasi di dalam popup -> pesan
sukses "dititipkan" ke session_state -> st.rerun() -> popup sukses
tampil sebentar -> halaman kembali ke tampilan awal (tabel ter-refresh,
pilihan baris ikut ter-reset).
"""
from __future__ import annotations
import streamlit as st
from utils import auth, ui_components
# ---------------------------------------------------------------------------
# Popup sukses (dipakai untuk semua jenis aksi: tambah/edit/hapus)
# ---------------------------------------------------------------------------
@st.dialog("✅ Berhasil")
def _dialog_sukses(pesan: str) -> None:
st.write(pesan)
if st.button("Oke", type="primary", width="stretch", key="btn_um_sukses_ok"):
st.rerun()
def _tutup_dan_refresh(pesan_sukses: str | None = None) -> None:
"""Bersihkan semua state popup yang sedang terbuka, titipkan pesan
sukses (kalau ada), lalu rerun -- persis pola di panel upload model."""
st.session_state.pop("_um_action", None)
if pesan_sukses:
st.session_state["_um_success_msg"] = pesan_sukses
# >>> FIX: naikkan versi key tabel supaya widget dataframe jadi
# "baru" (pilihan baris ter-reset) -- mencegah index nyasar kalau
# user yang tadi dipilih ternyata baru saja dihapus/berubah posisi.
st.session_state["_um_table_version"] = st.session_state.get("_um_table_version", 0) + 1
st.rerun()
def _batalkan_aksi() -> None:
"""
Callback untuk on_dismiss di st.dialog -- Streamlit otomatis
menjalankan ini kapan pun popup ditutup DENGAN CARA APAPUN, termasuk
klik tombol "x" atau klik di luar area popup (bukan cuma lewat
tombol "Batal" buatan kita).
>>> FIX BUG: sebelumnya, kalau popup ditutup lewat "x", session_state
["_um_action"] tidak ikut kebersihkan -- akibatnya popup yang sama
muncul lagi tiba-tiba di rerun berikutnya (misal cuma karena klik
baris tabel), bahkan masih nyangkut walau sudah pindah halaman lalu
balik lagi. Dengan on_dismiss, pembersihan ini sekarang terjadi
OTOMATIS untuk semua cara menutup popup, tidak cuma tombol Batal.
"""
st.session_state.pop("_um_action", None)
# ---------------------------------------------------------------------------
# Popup: Tambah User
# ---------------------------------------------------------------------------
@st.dialog("➕ Tambah User Baru", on_dismiss=_batalkan_aksi)
def _dialog_tambah_user() -> None:
username = st.text_input(
"Username", key="tu_username",
on_change=ui_components.strip_spasi_username, args=("tu_username",),
)
nama = st.text_input("Nama tampilan", key="tu_nama")
role = st.selectbox("Role", options=["pemilik", "admin"], key="tu_role")
password = st.text_input("Password awal", type="password", placeholder="Wajib ada huruf & angka", key="tu_password")
konfirmasi = st.text_input("Konfirmasi password", type="password", key="tu_konfirmasi")
col1, col2 = st.columns(2)
if col1.button("Simpan", type="primary", width="stretch", key="btn_tu_simpan"):
if password != konfirmasi:
st.error("Password dan konfirmasinya tidak sama.")
else:
berhasil, pesan = auth.add_user(username=username, password=password, role=role, nama=nama)
if berhasil:
_tutup_dan_refresh(pesan)
else:
st.error(pesan)
if col2.button("Batal", width="stretch", key="btn_tu_batal"):
_tutup_dan_refresh()
# ---------------------------------------------------------------------------
# Popup: Edit User (nama + role + password baru, digabung jadi satu)
# ---------------------------------------------------------------------------
@st.dialog("✏️ Edit User", on_dismiss=_batalkan_aksi)
def _dialog_edit_user(username: str, nama_lama: str, role_lama: str) -> None:
# >>> FIX v2: pendekatan CSS :has(input:disabled) sebelumnya tidak
# match (struktur HTML Streamlit versi ini beda dari dugaan awal).
# Sekarang dibungkus st.container(key=...) supaya dapat class CSS
# pasti (".st-key-eu_username_wrapper"), lalu SEMUA label di dalam
# wrapper itu dipaksa warnanya normal -- tidak bergantung tebakan
# struktur disabled/aria-disabled Streamlit lagi.
st.markdown(
"""
<style>
.st-key-eu_username_wrapper label p,
.st-key-eu_username_wrapper label {
color: inherit !important;
opacity: 1 !important;
-webkit-text-fill-color: currentColor !important;
}
</style>
""",
unsafe_allow_html=True,
)
with st.container(key="eu_username_wrapper"):
st.text_input("Username", value=username, disabled=True, key=f"eu_username_display_{username}")
# >>> FIX: key widget disisipi 'username' supaya UNIK per-user.
# Sebelumnya key-nya statis ("eu_nama", "eu_role", dst) -- akibatnya
# kalau Anda edit user A lalu tutup, lalu edit user B, kolom Nama/
# Role/Password bisa ikut menampilkan sisa nilai dari user A
# (Streamlit menyimpan nilai widget berdasarkan key, bukan berdasarkan
# parameter value= yang dikirim ulang).
nama = st.text_input("Nama tampilan", value=nama_lama, key=f"eu_nama_{username}")
role = st.selectbox(
"Role", options=["admin", "pemilik"],
index=["admin", "pemilik"].index(role_lama), key=f"eu_role_{username}",
)
password_baru = st.text_input("Password baru", type="password", key=f"eu_password_{username}")
konfirmasi_baru = st.text_input("Konfirmasi password baru", type="password", key=f"eu_konfirmasi_{username}")
col1, col2 = st.columns(2)
if col1.button("Simpan", type="primary", width="stretch", key="btn_eu_simpan"):
# >>> FIX BUG: sebelumnya pakai 'return' di sini kalau password
# tidak cocok -- di Python, 'return' menghentikan SELURUH fungsi,
# jadi baris col2.button("Batal") di bawah ini ikut tidak sempat
# digambar pada render itu (makanya tombol Batal terlihat
# "hilang sesaat" setelah error muncul). Diganti if/else supaya
# fungsi tetap lanjut sampai akhir tanpa melewati Batal.
if password_baru and password_baru != konfirmasi_baru:
st.error("Password baru dan konfirmasinya tidak sama.")
else:
berhasil, pesan = auth.admin_edit_user(
username=username, nama=nama, role=role, new_password=password_baru or None,
)
if berhasil:
_tutup_dan_refresh(pesan)
else:
st.error(pesan)
if col2.button("Batal", width="stretch", key="btn_eu_batal"):
_tutup_dan_refresh()
# ---------------------------------------------------------------------------
# Popup: Konfirmasi Hapus User
# ---------------------------------------------------------------------------
@st.dialog("🗑️ Hapus User", on_dismiss=_batalkan_aksi)
def _dialog_hapus_user(username: str) -> None:
st.warning(f"Yakin ingin menghapus user **{username}**? Tindakan ini tidak bisa dibatalkan.")
col1, col2 = st.columns(2)
if col1.button("Ya, Hapus", type="primary", width="stretch", key="btn_hu_ya"):
berhasil, pesan = auth.delete_user(username, requested_by=auth.get_current_username())
if berhasil:
_tutup_dan_refresh(pesan)
else:
st.error(pesan)
if col2.button("Batal", width="stretch", key="btn_hu_batal"):
_tutup_dan_refresh()
# ---------------------------------------------------------------------------
# Halaman utama
# ---------------------------------------------------------------------------
def render_manajemen_user() -> None:
# >>> Cuma SATU aturan CSS untuk icon toolbar tabel (mengecilkan ke
# ukuran normal 0.9rem). Versi sebelumnya sempat ada 2 aturan CSS
# icon yang saling bentrok (satu memperbesar 1.3x, satu memperkecil
# ke 0.9rem) -- sudah dibersihkan, cuma disisakan yang mengecilkan.
st.markdown(
"""
<style>
[data-testid="stElementToolbarButton"] svg {
width: 0.9rem !important;
height: 0.9rem !important;
}
[data-testid="stElementToolbarButton"] {
padding: 0.25rem !important;
}
</style>
""",
unsafe_allow_html=True,
)
# Pakai st.title() supaya konsisten dengan judul di halaman lain.
# CSS di bawah cuma mengecilkan margin bawahnya saja -- ini yang
# sebelumnya bikin jarak ke "Daftar User" terasa kelebaran.
st.markdown(
"""
<style>
div[data-testid="stAppViewContainer"] h1 {
margin-bottom: 0.4rem;
}
</style>
""",
unsafe_allow_html=True,
)
st.title("Manajemen User")
# Popup sukses (kalau ada pesan "dititipkan" dari aksi sebelum rerun)
pending_success = st.session_state.pop("_um_success_msg", None)
if pending_success:
_dialog_sukses(pending_success)
# Buka popup sesuai aksi yang sedang aktif (tersimpan di session_state
# supaya tetap terbuka selagi user mengisi form di dalam popup-nya)
aksi = st.session_state.get("_um_action")
daftar = auth.list_users()
peta_user = {u["username"]: u for u in daftar}
if aksi is not None:
jenis, target_username = aksi
if jenis == "tambah":
_dialog_tambah_user()
elif jenis == "edit" and target_username in peta_user:
data = peta_user[target_username]
_dialog_edit_user(target_username, data["nama"], data["role"])
elif jenis == "hapus" and target_username in peta_user:
_dialog_hapus_user(target_username)
# --- Header: "Daftar User" + tombol Tambah User di kanan ---
# vertical_alignment="center" supaya teks & tombol sejajar tengah
# (defaultnya top-aligned, makanya sebelumnya terlihat tidak lurus).
col_judul, col_tombol = st.columns([4, 1], vertical_alignment="center")
with col_judul:
st.markdown("**Daftar User**")
with col_tombol:
if st.button("➕ Tambah User", width="stretch", key="btn_buka_tambah"):
st.session_state["_um_action"] = ("tambah", None)
st.rerun()
# --- Tabel: pakai st.dataframe() ASLI (sama seperti tabel di halaman
# lain -- otomatis rapi & responsif), bukan st.columns() manual lagi.
#
# Konsekuensinya: st.dataframe tidak bisa diisi tombol di dalam sel,
# jadi kolom "Aksi" (edit/hapus per baris) diganti pola "klik baris
# untuk pilih -> tombol aksi muncul di bawah tabel" (selection_mode
# bawaan Streamlit). Klik baris yang sudah terpilih lagi untuk
# membatalkan pilihan.
username_login_ini = auth.get_current_username()
tampil = [
{"No": i, "Username": u["username"], "Nama": u["nama"], "Role": u["role"]}
for i, u in enumerate(daftar, start=1)
]
table_version = st.session_state.get("_um_table_version", 0)
event = st.dataframe(
tampil,
width="stretch",
hide_index=True,
on_select="rerun",
selection_mode="single-row",
key=f"tabel_manajemen_user_{table_version}",
)
baris_terpilih = event.selection.rows if event and event.selection else []
if not baris_terpilih:
st.caption("👆 Klik salah satu baris di atas untuk mengedit atau menghapus user tersebut.")
else:
user_terpilih = daftar[baris_terpilih[0]]
st.markdown(f"**User terpilih: `{user_terpilih['username']}`**")
col_edit, col_hapus = st.columns(2)
if col_edit.button("✏️ Edit User Ini", width="stretch", key="btn_edit_terpilih"):
st.session_state["_um_action"] = ("edit", user_terpilih["username"])
st.rerun()
# User terpilih = akun sendiri yang sedang login -> tombol tetap
# tampil tapi dinonaktifkan (bukan disembunyikan/diganti teks)
is_akun_sendiri = user_terpilih["username"] == username_login_ini
if col_hapus.button(
"🗑️ Hapus User Ini",
width="stretch",
key="btn_hapus_terpilih",
disabled=is_akun_sendiri,
help="Tidak bisa menghapus akun sendiri." if is_akun_sendiri else None,
):
st.session_state["_um_action"] = ("hapus", user_terpilih["username"])
st.rerun()