feat: 實作使用者啟停用功能與安全性強化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m1s

- 新增使用者「啟用/停用」狀態切換功能 (含後端 API、權限控管、活動紀錄)
- 強化安全性:隱藏超級管理員角色的可見度與操作權限
- 更新開發規範:加入多租戶資料同步規範於 framework.md
- 前端優化:使用 Switch 元件進行狀態快速切換,調整表格欄位順序
This commit is contained in:
2026-02-03 11:51:46 +08:00
parent 0185843c62
commit d671c08338
21 changed files with 350 additions and 161 deletions

View File

@@ -18,6 +18,7 @@ import { Can } from '@/Components/Permission/Can';
import { cn } from "@/lib/utils";
import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Switch } from "@/Components/ui/switch";
import {
AlertDialog,
AlertDialogAction,
@@ -42,6 +43,7 @@ interface User {
username: string | null;
created_at: string;
roles: Role[];
is_active: boolean;
}
interface PaginationLinks {
@@ -62,6 +64,7 @@ interface Props {
sort_order?: 'asc' | 'desc';
search?: string;
role?: string;
is_active?: string;
};
roles: Role[];
}
@@ -70,6 +73,7 @@ export default function UserIndex({ users, roles, filters }: Props) {
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [roleFilter, setRoleFilter] = useState<string>(filters.role || "all");
const [isActiveFilter, setIsActiveFilter] = useState<string>(filters.is_active || "all");
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleteName, setDeleteName] = useState<string>('');
const [modelOpen, setModelOpen] = useState(false);
@@ -95,17 +99,17 @@ export default function UserIndex({ users, roles, filters }: Props) {
setPerPage(value);
router.get(
route('users.index'),
{ ...filters, per_page: value, search: searchTerm, role: roleFilter },
{ ...filters, per_page: value, search: searchTerm, role: roleFilter, is_active: isActiveFilter },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
// Debounced Search Handler
const debouncedSearch = useCallback(
debounce((term: string, role: string) => {
debounce((term: string, role: string, isActive: string) => {
router.get(
route('users.index'),
{ ...filters, search: term, role: role },
{ ...filters, search: term, role: role, is_active: isActive },
{ preserveState: true, replace: true, preserveScroll: true }
);
}, 500),
@@ -114,14 +118,23 @@ export default function UserIndex({ users, roles, filters }: Props) {
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedSearch(term, roleFilter);
debouncedSearch(term, roleFilter, isActiveFilter);
};
const handleRoleChange = (value: string) => {
setRoleFilter(value);
router.get(
route('users.index'),
{ ...filters, search: searchTerm, role: value },
{ ...filters, search: searchTerm, role: value, is_active: isActiveFilter },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleIsActiveChange = (value: string) => {
setIsActiveFilter(value);
router.get(
route('users.index'),
{ ...filters, search: searchTerm, role: roleFilter, is_active: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
@@ -130,7 +143,7 @@ export default function UserIndex({ users, roles, filters }: Props) {
setSearchTerm("");
router.get(
route('users.index'),
{ ...filters, search: "", role: roleFilter },
{ ...filters, search: "", role: roleFilter, is_active: isActiveFilter },
{ preserveState: true, replace: true, preserveScroll: true }
);
};
@@ -165,8 +178,6 @@ export default function UserIndex({ users, roles, filters }: Props) {
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
};
return (
<AuthenticatedLayout
breadcrumbs={[
@@ -223,6 +234,19 @@ export default function UserIndex({ users, roles, filters }: Props) {
className="w-full md:w-[180px]"
/>
{/* Status Filter */}
<SearchableSelect
value={isActiveFilter}
onValueChange={handleIsActiveChange}
options={[
{ label: "全部狀態", value: "all" },
{ label: "啟用", value: "1" },
{ label: "停用", value: "0" },
]}
placeholder="狀態篩選"
className="w-full md:w-[120px]"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="users.create">
@@ -251,6 +275,7 @@ export default function UserIndex({ users, roles, filters }: Props) {
</button>
</TableHead>
<TableHead></TableHead>
<TableHead className="w-[150px] text-center"></TableHead>
<TableHead>
<button
onClick={() => handleSort('created_at')}
@@ -308,6 +333,30 @@ export default function UserIndex({ users, roles, filters }: Props) {
)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-3">
<div className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border whitespace-nowrap",
user.is_active
? "bg-green-50 text-green-700 border-green-200"
: "bg-gray-100 text-gray-700 border-gray-200"
)}>
<span className={cn(
"w-1.5 h-1.5 rounded-full mr-1.5",
user.is_active ? "bg-green-500" : "bg-gray-400"
)} />
{user.is_active ? '啟用' : '停用'}
</div>
<Can permission="users.activate">
<Switch
checked={user.is_active}
onCheckedChange={() => router.patch(route('users.toggle-active', user.id), {}, { preserveScroll: true })}
className="data-[state=checked]:bg-green-500"
/>
</Can>
</div>
</TableCell>
<TableCell className="text-gray-500 text-sm">
{format(new Date(user.created_at), 'yyyy/MM/dd')}
</TableCell>