feat: 實作使用者啟停用功能與安全性強化
- 新增使用者「啟用/停用」狀態切換功能 (含後端 API、權限控管、活動紀錄) - 強化安全性:隱藏超級管理員角色的可見度與操作權限 - 更新開發規範:加入多租戶資料同步規範於 framework.md - 前端優化:使用 Switch 元件進行狀態快速切換,調整表格欄位順序
This commit is contained in:
@@ -67,7 +67,17 @@ trigger: always_on
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 8. 運行機制 (Docker / Sail)
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||
* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。
|
||||
* **指令執行**:
|
||||
* **Seeders**: 必須執行 `./vendor/bin/sail php artisan tenants:run db:seed` 以確保所有租戶均獲得更新。
|
||||
* **Tinker**: 檢查租戶資料時應使用 `./vendor/bin/sail php artisan tenants:run tinker`。
|
||||
* **Migrations**: 租戶相關的 Schema 異動應放在 `database/migrations/tenant/` 並執行 `./vendor/bin/sail artisan tenants:migrate`。
|
||||
|
||||
## 9. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
|
||||
@@ -43,6 +43,17 @@ class LoginController extends Controller
|
||||
$credentials = $request->only('username', 'password');
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
// Check activation status
|
||||
if (!Auth::user()->is_active) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'username' => '此帳號已被停用,請聯繫管理員。',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
|
||||
@@ -22,9 +22,26 @@ class UserController extends Controller
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
$search = $request->input('search');
|
||||
$roleId = $request->input('role');
|
||||
|
||||
$query = User::with(['roles:id,name,display_name']);
|
||||
$roleId = $request->input('role');
|
||||
$isActive = $request->input('is_active'); // 'all', '1', '0'
|
||||
|
||||
$query = User::query();
|
||||
|
||||
// 隱藏超級管理員:若非 super-admin,則不可看到 super-admin 過往
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$query->whereDoesntHave('roles', function ($q) {
|
||||
$q->where('name', 'super-admin');
|
||||
});
|
||||
|
||||
// 預載入角色時也過濾掉 super-admin 標籤
|
||||
$query->with(['roles' => function ($q) {
|
||||
$q->select('id', 'name', 'display_name')
|
||||
->where('name', '!=', 'super-admin');
|
||||
}]);
|
||||
} else {
|
||||
$query->with(['roles:id,name,display_name']);
|
||||
}
|
||||
|
||||
// 處理搜尋
|
||||
if ($search) {
|
||||
@@ -42,6 +59,11 @@ class UserController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// 處理狀態篩選
|
||||
if ($isActive !== null && $isActive !== 'all') {
|
||||
$query->where('is_active', $isActive === '1' || $isActive === 'true');
|
||||
}
|
||||
|
||||
// 處理排序
|
||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
@@ -50,12 +72,19 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$users = $query->paginate($perPage)->withQueryString();
|
||||
$roles = Role::select('id', 'name', 'display_name')->get();
|
||||
|
||||
// 只能看到自己權限以下的角色
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Index', [
|
||||
'users' => $users,
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role']),
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role', 'is_active']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -64,7 +93,11 @@ class UserController extends Controller
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$roles = Role::pluck('display_name', 'name');
|
||||
$rolesQuery = Role::query();
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->pluck('display_name', 'name');
|
||||
|
||||
return Inertia::render('Admin/User/Create', [
|
||||
'roles' => $roles
|
||||
@@ -80,8 +113,10 @@ class UserController extends Controller
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users'],
|
||||
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.required' => '請輸入密碼',
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
@@ -92,10 +127,16 @@ class UserController extends Controller
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
|
||||
'password' => Hash::make($validated['password']),
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
if (!empty($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
$user->syncRoles($validated['roles']);
|
||||
|
||||
// 更新 'created' 紀錄以包含角色資訊
|
||||
@@ -123,7 +164,17 @@ class UserController extends Controller
|
||||
public function edit(string $id)
|
||||
{
|
||||
$user = User::with('roles')->findOrFail($id);
|
||||
$roles = Role::get(['id', 'name', 'display_name']);
|
||||
|
||||
// 安全檢查:非 super-admin 不能編輯 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$rolesQuery = Role::select('id', 'name', 'display_name');
|
||||
if (!auth()->user()->hasRole('super-admin')) {
|
||||
$rolesQuery->where('name', '!=', 'super-admin');
|
||||
}
|
||||
$roles = $rolesQuery->get();
|
||||
|
||||
return Inertia::render('Admin/User/Edit', [
|
||||
'user' => $user,
|
||||
@@ -139,12 +190,19 @@ class UserController extends Controller
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能更新 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限編輯系統管理員');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
'roles' => ['array'],
|
||||
'is_active' => ['boolean'],
|
||||
], [
|
||||
'password.min' => '密碼長度至少需 :min 個字元',
|
||||
'password.confirmed' => '密碼確認不符',
|
||||
@@ -157,10 +215,6 @@ class UserController extends Controller
|
||||
'username' => $validated['username'],
|
||||
];
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$userData['password'] = Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
$user->fill($userData);
|
||||
|
||||
// 捕捉變更屬性以進行手動記錄
|
||||
@@ -179,6 +233,11 @@ class UserController extends Controller
|
||||
// 2. 處理角色
|
||||
$roleChanges = null;
|
||||
if (isset($validated['roles'])) {
|
||||
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
|
||||
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
|
||||
abort(403, '您沒有權限指派系統管理員角色');
|
||||
}
|
||||
|
||||
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
$user->syncRoles($validated['roles']);
|
||||
$newRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
@@ -230,6 +289,11 @@ class UserController extends Controller
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:非 super-admin 不能刪除 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限刪除系統管理員');
|
||||
}
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return back()->with('error', '無法刪除超級管理員帳號');
|
||||
}
|
||||
@@ -240,6 +304,46 @@ class UserController extends Controller
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', '使用者已刪除');
|
||||
return redirect()->route('users.index')->with('success', "使用者「{$user->name}」已刪除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換使用者啟用/停用狀態
|
||||
*/
|
||||
public function toggleActive(string $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// 安全檢查:不能停用自己
|
||||
if ($user->id === auth()->id() && $user->is_active) {
|
||||
return back()->with('error', '無法停用自己的帳號');
|
||||
}
|
||||
|
||||
// 安全檢查:非 super-admin 不能停用 super-admin
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
abort(403, '您沒有權限變更系統管理員狀態');
|
||||
}
|
||||
|
||||
$oldStatus = $user->is_active;
|
||||
$user->is_active = !$oldStatus;
|
||||
$user->save();
|
||||
|
||||
// 記錄活動
|
||||
activity()
|
||||
->performedOn($user)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => ['is_active' => $user->is_active],
|
||||
'old' => ['is_active' => $oldStatus],
|
||||
'snapshot' => [
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
|
||||
$statusText = $user->is_active ? '已啟用' : '已停用';
|
||||
return back()->with('success', "使用者「{$user->name}」{$statusText}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class User extends Authenticatable
|
||||
'email',
|
||||
'username',
|
||||
'password',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,9 @@ class User extends Authenticatable
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
|
||||
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
||||
Route::patch('/users/{user}/toggle-active', [UserController::class, 'toggleActive'])->middleware('permission:users.activate')->name('users.toggle-active');
|
||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class UserFactory extends Factory
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(true)->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(true)->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder
|
||||
'name' => '系統管理員',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ class PermissionSeeder extends Seeder
|
||||
'users.create',
|
||||
'users.edit',
|
||||
'users.delete',
|
||||
'users.activate', // 啟用/停用使用者
|
||||
|
||||
// 角色權限管理
|
||||
'roles.view',
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# 開發框架規範說明書:ERP 系統 (star-erp)
|
||||
|
||||
## 1. 專案概述
|
||||
* **目標**: 打造一個強大且穩定的 ERP 後台管理系統。
|
||||
* **核心架構**: 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。
|
||||
* **工作流程**: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。
|
||||
|
||||
## 2. 技術棧 (Tech Stack)
|
||||
* **後端**: PHP 8.5 / Laravel 12
|
||||
* **前端橋樑**: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
|
||||
* **前端庫**: React (以 Functional Components 與 Hooks 為主)
|
||||
* **樣式處理**: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
|
||||
* **資料庫**: MySQL 8.0
|
||||
* **開發環境**: Laravel Sail (Docker / WSL2)
|
||||
* **未來擴充**: 針對高併發或跨平台模組,預留 Golang 微服務接口。
|
||||
|
||||
## 3. 目錄結構與慣例
|
||||
|
||||
### 3.1 後端 (Laravel - Modular Monolith)
|
||||
系統採用模組化架構,核心邏輯位於 `app/Modules/` 下:
|
||||
|
||||
* **Modules**: 位於 `app/Modules/{ModuleName}/`。
|
||||
* **Controllers**: `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`。
|
||||
* **Models**: `app/Modules/{ModuleName}/Models/`。
|
||||
* **Routes**: `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。
|
||||
* **Global Routes**: `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。
|
||||
|
||||
### 3.2 前端 (React)
|
||||
* **Pages (頁面)**: 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。
|
||||
* **Components (組件)**: 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
|
||||
* **Layouts (版面)**: 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。
|
||||
|
||||
## 4. 整合指南 (UI/UX 轉換至 Laravel)
|
||||
* **組件遷移**: 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。
|
||||
* **資料傳遞**: 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。
|
||||
* **狀態管理**: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
|
||||
|
||||
## 5. 開發標準 (Coding Standards)
|
||||
* **命名規範**:
|
||||
* Controllers: `PascalCaseController.php`
|
||||
* React Components: `PascalCase.jsx`
|
||||
* Routes: `kebab-case` (小寫橫線分隔)
|
||||
* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||
|
||||
## 6. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 7. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
||||
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
||||
* **執行 Composer**: `./vendor/bin/sail composer install`
|
||||
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||
@@ -1,71 +0,0 @@
|
||||
# Multi-tenancy 部署手冊
|
||||
|
||||
> 記錄本地開發完成後,上 Demo/Production 環境時需要手動執行的操作。
|
||||
> CI/CD 會自動執行的項目已排除。
|
||||
|
||||
---
|
||||
|
||||
## Step 1: 安裝 stancl/tenancy
|
||||
**CI/CD 會自動執行**:`composer install`
|
||||
**手動操作**:無
|
||||
|
||||
---
|
||||
|
||||
## Step 2: 設定 Central Domain + Tenant 識別
|
||||
**手動操作**:
|
||||
1. 修改 `.env`,加入:
|
||||
```bash
|
||||
# Demo 環境 (192.168.0.103)
|
||||
CENTRAL_DOMAINS=192.168.0.103,localhost
|
||||
|
||||
# Production 環境 (erp.koori.tw)
|
||||
CENTRAL_DOMAINS=erp.koori.tw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: 分離 Migrations
|
||||
**CI/CD 會自動執行**:`php artisan migrate --force`
|
||||
**手動操作**:無
|
||||
|
||||
> 注意:migrations 結構已調整如下:
|
||||
> - `database/migrations/` - Central tables (tenants, domains)
|
||||
> - `database/migrations/tenant/` - Tenant tables (所有業務表)
|
||||
|
||||
---
|
||||
|
||||
## Step 4: 遷移現有資料到 tenant_koori
|
||||
**首次部署手動操作**:
|
||||
1. 授予 MySQL sail 使用者 CREATE DATABASE 權限:
|
||||
```bash
|
||||
docker exec koori-erp-mysql mysql -uroot -p[PASSWORD] -e "GRANT ALL PRIVILEGES ON *.* TO 'sail'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
2. 建立第一個租戶 (小小冰室):
|
||||
```bash
|
||||
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
|
||||
use App\Models\Tenant;
|
||||
Tenant::create(['id' => 'koori', 'name' => '小小冰室']);
|
||||
"
|
||||
```
|
||||
|
||||
3. 為租戶綁定域名:
|
||||
```bash
|
||||
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
|
||||
use App\Models\Tenant;
|
||||
Tenant::find('koori')->domains()->create(['domain' => 'koori.your-domain.com']);
|
||||
"
|
||||
```
|
||||
|
||||
4. 執行資料遷移 (從 central DB 複製到 tenant DB):
|
||||
```bash
|
||||
docker exec -w /var/www/html koori-erp-laravel php artisan tenancy:migrate-data koori
|
||||
```
|
||||
|
||||
## Step 5: 建立房東後台
|
||||
**手動操作**:無
|
||||
|
||||
---
|
||||
|
||||
## 其他注意事項
|
||||
- 待補充...
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
@@ -1768,6 +1769,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
|
||||
@@ -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