262c44f6a1
- New: GET /api/auth/users (admin only) returns [{id, email, name, role, is_active}]
- New: 'User Management' card in Settings page
- Lists all users with role badge (admin/user) and inactive indicator
- 'Reset Password' button per user
- Modal: enter new password (>=6 chars), confirm/cancel
- Toast success / inline error
- Esc closes modal, click backdrop closes
- ESC + Enter shortcuts
- Non-admin (or 403) shows empty state
518 lines
20 KiB
React
518 lines
20 KiB
React
import { useState, useEffect } from "react"
|
||
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info, KeyRound, User as UserIcon } from "lucide-react"
|
||
import api from "../api"
|
||
import toast from "react-hot-toast"
|
||
|
||
const RESET_SECTIONS = [
|
||
{
|
||
key: "attendance",
|
||
label: "出勤 Attendance",
|
||
description: "刪除所有出勤記錄(attendance_records 全表清空)",
|
||
color: "red",
|
||
},
|
||
{
|
||
key: "accident",
|
||
label: "意外 Accident",
|
||
description: "刪除所有意外記錄(accidents 全表清空)",
|
||
color: "orange",
|
||
},
|
||
{
|
||
key: "holidays",
|
||
label: "假期 Holidays",
|
||
description: "刪除所有公眾假期記錄",
|
||
color: "amber",
|
||
},
|
||
{
|
||
key: "leaves",
|
||
label: "請假 Leaves",
|
||
description: "刪除所有員工請假記錄",
|
||
color: "lime",
|
||
},
|
||
]
|
||
|
||
export default function Settings() {
|
||
const [confirmText, setConfirmText] = useState({})
|
||
const [loading, setLoading] = useState({})
|
||
const [result, setResult] = useState({})
|
||
const [version, setVersion] = useState(null)
|
||
const [backups, setBackups] = useState([])
|
||
const [loadingBackups, setLoadingBackups] = useState(false)
|
||
const [creatingBackup, setCreatingBackup] = useState(false)
|
||
const [restoring, setRestoring] = useState(null)
|
||
const [restoreConfirm, setRestoreConfirm] = useState("")
|
||
|
||
// User management (admin only)
|
||
const [users, setUsers] = useState([])
|
||
const [loadingUsers, setLoadingUsers] = useState(false)
|
||
const [resetTarget, setResetTarget] = useState(null)
|
||
const [newPassword, setNewPassword] = useState("")
|
||
const [resetting, setResetting] = useState(false)
|
||
const [resetError, setResetError] = useState(null)
|
||
|
||
useEffect(() => {
|
||
fetchVersion()
|
||
fetchBackups()
|
||
fetchUsers()
|
||
}, [])
|
||
|
||
const fetchUsers = async () => {
|
||
setLoadingUsers(true)
|
||
try {
|
||
const { data } = await api.get("/api/auth/users")
|
||
setUsers(data || [])
|
||
} catch (e) {
|
||
// 403 if not admin — leave list empty
|
||
setUsers([])
|
||
} finally {
|
||
setLoadingUsers(false)
|
||
}
|
||
}
|
||
|
||
const openResetDialog = (u) => {
|
||
setResetTarget(u)
|
||
setNewPassword("")
|
||
setResetError(null)
|
||
}
|
||
|
||
const closeResetDialog = () => {
|
||
if (resetting) return
|
||
setResetTarget(null)
|
||
setNewPassword("")
|
||
setResetError(null)
|
||
}
|
||
|
||
const handleResetPassword = async () => {
|
||
if (!resetTarget || !newPassword) return
|
||
if (newPassword.length < 6) {
|
||
setResetError("密碼至少需要 6 個字元")
|
||
return
|
||
}
|
||
setResetting(true)
|
||
setResetError(null)
|
||
try {
|
||
await api.post("/api/auth/reset-password", {
|
||
email: resetTarget.email,
|
||
new_password: newPassword,
|
||
})
|
||
toast.success(`已重設 ${resetTarget.email} 嘅密碼`)
|
||
closeResetDialog()
|
||
} catch (e) {
|
||
setResetError(e.response?.data?.detail || e.message)
|
||
} finally {
|
||
setResetting(false)
|
||
}
|
||
}
|
||
|
||
const fetchVersion = async () => {
|
||
try {
|
||
const { data } = await api.get("/api/version")
|
||
setVersion(data)
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const fetchBackups = async () => {
|
||
setLoadingBackups(true)
|
||
try {
|
||
const { data } = await api.get("/api/admin/backup/list")
|
||
setBackups(data || [])
|
||
} catch (e) {
|
||
console.error("Failed to fetch backups:", e)
|
||
} finally {
|
||
setLoadingBackups(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateBackup = async () => {
|
||
setCreatingBackup(true)
|
||
try {
|
||
const { data } = await api.post("/api/admin/backup/create")
|
||
toast.success(`Backup created: ${data.saved}`)
|
||
fetchBackups()
|
||
} catch (e) {
|
||
toast.error("Backup failed: " + (e.response?.data?.detail || e.message))
|
||
} finally {
|
||
setCreatingBackup(false)
|
||
}
|
||
}
|
||
|
||
const handleRestore = async (filename) => {
|
||
if (restoreConfirm !== filename) {
|
||
toast.error("Please type the filename to confirm restore")
|
||
return
|
||
}
|
||
setRestoring(filename)
|
||
try {
|
||
const { data } = await api.post("/api/admin/backup/restore", { filename })
|
||
toast.success(`Restored from ${filename}. Auto-backup: ${data.auto_backup}`)
|
||
setRestoreConfirm("")
|
||
fetchBackups()
|
||
} catch (e) {
|
||
toast.error("Restore failed: " + (e.response?.data?.detail || e.message))
|
||
} finally {
|
||
setRestoring(null)
|
||
}
|
||
}
|
||
|
||
const handleDownload = (filename) => {
|
||
const token = localStorage.getItem("aars_token")
|
||
if (!token) return
|
||
window.open(`/api/admin/backup/download/${filename}?token=${token}`, "_blank")
|
||
}
|
||
|
||
const handleReset = async (section) => {
|
||
const confirmVal = confirmText[section] || ""
|
||
if (confirmVal !== section) {
|
||
setResult((prev) => ({ ...prev, [section]: { error: `請輸入 "${section}" 以確認刪除` } }))
|
||
return
|
||
}
|
||
|
||
setLoading((prev) => ({ ...prev, [section]: true }))
|
||
setResult((prev) => ({ ...prev, [section]: null }))
|
||
|
||
try {
|
||
const res = await api.post(`/api/admin/reset/${section}`, { confirm: section })
|
||
setResult((prev) => ({
|
||
...prev,
|
||
[section]: {
|
||
success: `已刪除 ${res.data.deleted} 條記錄`,
|
||
},
|
||
}))
|
||
setConfirmText((prev) => ({ ...prev, [section]: "" }))
|
||
} catch (err) {
|
||
setResult((prev) => ({
|
||
...prev,
|
||
[section]: {
|
||
error: err.response?.data?.detail || err.message || "刪除失敗",
|
||
},
|
||
}))
|
||
} finally {
|
||
setLoading((prev) => ({ ...prev, [section]: false }))
|
||
}
|
||
}
|
||
|
||
const formatSize = (bytes) => {
|
||
if (!bytes) return "—"
|
||
if (bytes < 1024) return bytes + " B"
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||
return (bytes / 1024 / 1024).toFixed(1) + " MB"
|
||
}
|
||
|
||
const formatDate = (iso) => {
|
||
if (!iso) return "—"
|
||
try {
|
||
const d = new Date(iso)
|
||
return d.toLocaleString("zh-HK", { timeZone: "Asia/Hong_Kong" })
|
||
} catch { return iso }
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto space-y-6">
|
||
|
||
{/* Version Info */}
|
||
{version && (
|
||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-5 py-4 flex items-start gap-3">
|
||
<Info className="w-5 h-5 text-blue-600 mt-0.5 shrink-0" />
|
||
<div>
|
||
<div className="text-sm font-semibold text-blue-900">
|
||
AARS v{version.version}
|
||
</div>
|
||
<div className="text-xs text-blue-700 mt-0.5">
|
||
Commit: <code className="bg-blue-100 px-1 rounded">{version.commit?.slice(0, 8)}</code>
|
||
{version.date && <> · {new Date(version.date).toLocaleString("zh-HK", {timeZone:"Asia/Hong_Kong"})}</>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Backup / Restore */}
|
||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Database className="w-5 h-5 text-slate-600" />
|
||
<h2 className="text-lg font-semibold text-slate-800">💾 資料庫 Backup / Restore</h2>
|
||
</div>
|
||
<button
|
||
onClick={handleCreateBackup}
|
||
disabled={creatingBackup}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${creatingBackup ? "animate-spin" : ""}`} />
|
||
{creatingBackup ? "建立中..." : "建立 Backup"}
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-slate-500 mt-1">手動建立快照,或從過往備份還原</p>
|
||
</div>
|
||
|
||
<div className="divide-y divide-slate-100">
|
||
{loadingBackups ? (
|
||
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中...</div>
|
||
) : backups.length === 0 ? (
|
||
<div className="px-5 py-6 text-sm text-slate-400 text-center">尚無備份記錄</div>
|
||
) : (
|
||
backups.map((b) => (
|
||
<div key={b.filename} className="px-5 py-3 flex items-center justify-between gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium text-slate-800 truncate">{b.filename}</div>
|
||
<div className="text-xs text-slate-400">
|
||
{formatDate(b.created)} · {formatSize(b.size)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
onClick={() => handleDownload(b.filename)}
|
||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md transition-colors"
|
||
title="Download backup"
|
||
>
|
||
<Download className="w-3.5 h-3.5" /> 下載
|
||
</button>
|
||
<button
|
||
onClick={() => setRestoreConfirm(b.filename === restoreConfirm ? "" : b.filename)}
|
||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-600 hover:text-amber-700 hover:bg-amber-50 rounded-md transition-colors"
|
||
title="Restore from this backup"
|
||
>
|
||
<Upload className="w-3.5 h-3.5" /> 還原
|
||
</button>
|
||
<button
|
||
onClick={async () => {
|
||
if (!confirm(`確定要刪除 ${b.filename}?`)) return
|
||
try {
|
||
await api.delete(`/api/admin/backup/${b.filename}`)
|
||
toast.success(`已刪除 ${b.filename}`)
|
||
fetchBackups()
|
||
} catch (e) {
|
||
toast.error("刪除失敗: " + (e.response?.data?.detail || e.message))
|
||
}
|
||
}}
|
||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
||
title="Delete backup"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" /> 刪除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Restore confirmation */}
|
||
{restoreConfirm && (
|
||
<div className="px-5 py-4 bg-amber-50 border-t border-amber-100">
|
||
<p className="text-sm text-amber-800 mb-2">
|
||
⚠️ 還原將覆蓋當前資料庫。輸入 <code className="font-mono bg-amber-100 px-1 rounded">{restoreConfirm}</code> 確認:
|
||
</p>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="text"
|
||
className="flex-1 px-3 py-2 text-sm border border-amber-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||
placeholder={"輸入檔案名稱確認"}
|
||
value={restoreConfirm}
|
||
onChange={(e) => setRestoreConfirm(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter" && restoreConfirm === restoreConfirm) handleRestore(restoreConfirm) }}
|
||
/>
|
||
<button
|
||
onClick={() => handleRestore(restoreConfirm)}
|
||
disabled={restoreConfirm !== restoreConfirm || restoring}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||
>
|
||
{restoring ? "還原中..." : "確認還原"}
|
||
</button>
|
||
<button
|
||
onClick={() => setRestoreConfirm("")}
|
||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* User Management — admin only */}
|
||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
|
||
<div className="flex items-center gap-2">
|
||
<UserIcon className="w-5 h-5 text-slate-600" />
|
||
<h2 className="text-lg font-semibold text-slate-800">使用者管理 User Management</h2>
|
||
</div>
|
||
<p className="text-sm text-slate-500 mt-1">
|
||
重設使用者密碼。Admin 限。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="divide-y divide-slate-100">
|
||
{loadingUsers && (
|
||
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中…</div>
|
||
)}
|
||
{!loadingUsers && users.length === 0 && (
|
||
<div className="px-5 py-6 text-sm text-slate-400 text-center">
|
||
沒有使用者(或你唔係 admin)
|
||
</div>
|
||
)}
|
||
{!loadingUsers && users.map((u) => (
|
||
<div key={u.id} className="px-5 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<div className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
|
||
<UserIcon className="w-4 h-4 text-slate-500" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-slate-800 truncate">
|
||
{u.name || u.email}
|
||
</div>
|
||
<div className="text-xs text-slate-500 truncate">
|
||
{u.email}
|
||
<span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
||
u.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'
|
||
}`}>
|
||
{u.role}
|
||
</span>
|
||
{!u.is_active && (
|
||
<span className="ml-1 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-100 text-red-700">
|
||
inactive
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => openResetDialog(u)}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 hover:bg-slate-50 hover:border-slate-400 rounded-md transition-colors"
|
||
>
|
||
<KeyRound className="w-3.5 h-3.5" />
|
||
重設密碼
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reset Password Modal */}
|
||
{resetTarget && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" onClick={closeResetDialog}>
|
||
<div
|
||
className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-6 py-4 border-b border-slate-100">
|
||
<h3 className="text-lg font-semibold text-slate-900">重設密碼</h3>
|
||
<p className="text-sm text-slate-500 mt-1">
|
||
為 <span className="font-mono text-slate-700">{resetTarget.email}</span> 設定新密碼
|
||
</p>
|
||
</div>
|
||
<div className="px-6 py-5 space-y-3">
|
||
<label className="block text-sm font-medium text-slate-700">
|
||
新密碼
|
||
<input
|
||
type="text"
|
||
value={newPassword}
|
||
onChange={(e) => {
|
||
setNewPassword(e.target.value)
|
||
setResetError(null)
|
||
}}
|
||
placeholder="至少 6 個字元"
|
||
className="mt-1.5 block w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
autoFocus
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') handleResetPassword()
|
||
if (e.key === 'Escape') closeResetDialog()
|
||
}}
|
||
/>
|
||
</label>
|
||
{resetError && (
|
||
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||
❌ {resetError}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-2">
|
||
<button
|
||
onClick={closeResetDialog}
|
||
disabled={resetting}
|
||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:opacity-50"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleResetPassword}
|
||
disabled={resetting || !newPassword}
|
||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
|
||
>
|
||
{resetting ? "重設中…" : "確認重設"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Danger Zone */}
|
||
<div className="bg-white border border-red-200 rounded-xl overflow-hidden">
|
||
<div className="px-5 py-4 border-b border-red-100 bg-red-50/50">
|
||
<div className="flex items-center gap-2">
|
||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||
<h2 className="text-lg font-semibold text-red-800">Danger Zone — 資料庫重置</h2>
|
||
</div>
|
||
<p className="text-sm text-red-600 mt-1">
|
||
以下操作將永久刪除資料,無法復原。請確認後再執行。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="divide-y divide-slate-100">
|
||
{RESET_SECTIONS.map((section) => {
|
||
const res = result[section.key]
|
||
const isLoading = loading[section.key]
|
||
|
||
return (
|
||
<div key={section.key} className="px-5 py-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<div className="flex-1">
|
||
<h3 className="font-semibold text-slate-900">{section.label}</h3>
|
||
<p className="text-sm text-slate-500 mt-0.5">{section.description}</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="text"
|
||
className="w-36 px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-slate-400"
|
||
placeholder={`輸入 "${section.key}"`}
|
||
value={confirmText[section.key] || ""}
|
||
onChange={(e) => {
|
||
setConfirmText((prev) => ({ ...prev, [section.key]: e.target.value }))
|
||
setResult((prev) => ({ ...prev, [section.key]: null }))
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") handleReset(section.key)
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => handleReset(section.key)}
|
||
disabled={isLoading || (confirmText[section.key] || "") !== section.key}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
{isLoading ? "刪除中..." : "重置"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{res && (
|
||
<div
|
||
className={`mt-3 text-sm px-3 py-2 rounded-md ${
|
||
res.error
|
||
? "bg-red-50 text-red-700 border border-red-200"
|
||
: "bg-green-50 text-green-700 border border-green-200"
|
||
}`}
|
||
>
|
||
{res.error ? `❌ ${res.error}` : `✅ ${res.success}`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|