feat: 實作使用者啟停用功能與安全性強化
- 新增使用者「啟用/停用」狀態切換功能 (含後端 API、權限控管、活動紀錄) - 強化安全性:隱藏超級管理員角色的可見度與操作權限 - 更新開發規範:加入多租戶資料同步規範於 framework.md - 前端優化:使用 Switch 元件進行狀態快速切換,調整表格欄位順序
This commit is contained in:
@@ -340,9 +340,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key))
|
||||
.map((key) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="font-medium text-gray-700 w-[150px]">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-words max-w-[200px]">-</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-words max-w-[200px]">
|
||||
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-all min-w-[150px]">-</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap">
|
||||
{getFormattedValue(key, attributes[key])}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -398,11 +398,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
||||
|
||||
return (
|
||||
<TableRow key={key} className={isChanged ? 'bg-amber-50/30 hover:bg-amber-50/50' : 'hover:bg-gray-50/50'}>
|
||||
<TableCell className="font-medium text-gray-700 w-[150px]">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-words max-w-[200px]">
|
||||
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell>
|
||||
<TableCell className="text-gray-500 break-all min-w-[150px] whitespace-pre-wrap">
|
||||
{displayBefore}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-900 font-medium break-words max-w-[200px]">
|
||||
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap">
|
||||
{displayAfter}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -173,16 +173,18 @@ export default function LogTable({
|
||||
<TableCell>
|
||||
<span className="font-medium text-gray-900">{activity.causer}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getDescription(activity)}
|
||||
<TableCell className="min-w-[300px]">
|
||||
<div className="break-all">
|
||||
{getDescription(activity)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={getEventBadgeClass(activity.event)}>
|
||||
{getEventLabel(activity.event)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200">
|
||||
<TableCell className="max-w-[200px]">
|
||||
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200 break-all whitespace-normal text-left h-auto py-1">
|
||||
{activity.subject_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function RoleCreate({ groupedPermissions }: Props) {
|
||||
'complete': '完成',
|
||||
'view_cost': '檢視成本',
|
||||
'view_logs': '檢視日誌',
|
||||
'activate': '啟用/停用',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
|
||||
'complete': '完成',
|
||||
'view_cost': '檢視成本',
|
||||
'view_logs': '檢視日誌',
|
||||
'activate': '啟用/停用',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/Components/ui/radio-group';
|
||||
import { FormEvent } from 'react';
|
||||
import { Switch } from '@/Components/ui/switch';
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
|
||||
interface Props {
|
||||
roles: Record<string, string>; // Name (ID) -> DisplayName map from pluck
|
||||
@@ -19,6 +21,7 @@ export default function UserCreate({ roles }: Props) {
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
roles: [] as string[], // Role names
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
@@ -119,6 +122,25 @@ export default function UserCreate({ roles }: Props) {
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<Can permission="users.activate">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="is_active" className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4" /> 帳號狀態
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={data.is_active}
|
||||
onCheckedChange={(checked: boolean) => setData('is_active', checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{data.is_active ? '啟用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/Components/ui/radio-group';
|
||||
import { FormEvent } from 'react';
|
||||
import { Switch } from '@/Components/ui/switch';
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
@@ -18,6 +20,7 @@ interface UserData {
|
||||
name: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -34,6 +37,7 @@ export default function UserEdit({ user, roles, currentRoles }: Props) {
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
roles: currentRoles,
|
||||
is_active: user.is_active,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
@@ -133,6 +137,28 @@ export default function UserEdit({ user, roles, currentRoles }: Props) {
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<Can permission="users.activate">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="is_active" className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4" /> 帳號狀態
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={data.is_active}
|
||||
onCheckedChange={(checked: boolean) => setData('is_active', checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{data.is_active ? '啟用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
停用後該使用者將無法登入系統
|
||||
</p>
|
||||
</div>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user