[DOCS] 更新 RBAC 實作規範與角色初始化流程建議
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 55s
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 55s
This commit is contained in:
@@ -91,7 +91,7 @@ trigger: always_on
|
||||
|
||||
* **本地測試網址**:`http://localhost:8090/` (注意:非 8000 或 8080)
|
||||
* **預設管理員帳號**:`admin`
|
||||
* **預設管理員密碼**:`password`
|
||||
* **預設管理員密碼**:`Star82779061`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 在執行 `open_browser_url` 或進行 E2E 測試時,請務必優先確認 Port 是否為 `8090`,以避免連線至錯誤的服務環境。
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 多租戶與權限架構實作規範 (RBAC Rules)
|
||||
|
||||
本文件定義 Star Cloud 系統的多租戶與權限(RBAC)實作標準,開發者必須嚴格遵守以下準則,以確保資料隔離與安全性。
|
||||
@@ -50,3 +54,17 @@
|
||||
## 4. API 安全
|
||||
- 所有的 API Route 應預設包含 `CheckTenantAccess` Middleware。
|
||||
- 嚴禁透過 URL 修改 ID 存取不屬於該租戶的資料,必須依賴 `company_id` 的 Scope 過濾。
|
||||
|
||||
---
|
||||
|
||||
## 5. 客戶初次建立與角色初始化 (Role Provisioning)
|
||||
|
||||
### 5.1 初始角色建立
|
||||
當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯:
|
||||
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null` 且 `is_system = 0`)中選取一個作為基礎。
|
||||
2. **自動克隆**:系統會將該範本的權限內容複製一份至該租戶下。
|
||||
3. **統一命名**:克隆後的角色名稱在該租戶公司內應統一命名為**「管理員」**。
|
||||
4. **帳號綁定**:該新客戶帳號將被指派至此新建立的「管理員」角色。
|
||||
|
||||
### 5.2 角色權限維護
|
||||
- 初始建立後,該租戶的「管理員」角色即成為獨立資源,可由具有權限的帳號進行細部調整。
|
||||
|
||||
@@ -32,7 +32,12 @@ class CompanyController extends Controller
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$companies = $query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
return view('admin.companies.index', compact('companies'));
|
||||
// 取得可供選擇的客戶角色範本 (is_system = 0, company_id = null)
|
||||
$template_roles = \App\Models\System\Role::where('is_system', 0)
|
||||
->whereNull('company_id')
|
||||
->get();
|
||||
|
||||
return view('admin.companies.index', compact('companies', 'template_roles'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +59,7 @@ class CompanyController extends Controller
|
||||
'admin_username' => 'nullable|string|max:255|unique:users,username',
|
||||
'admin_password' => 'nullable|string|min:8',
|
||||
'admin_name' => 'nullable|string|max:255',
|
||||
'admin_role' => 'nullable|string|exists:roles,name',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
@@ -79,8 +85,30 @@ class CompanyController extends Controller
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
// 綁定客戶管理員角色
|
||||
$user->assignRole('tenant-admin');
|
||||
// 角色初始化與克隆邏輯 (優先使用選擇的角色,否則使用預設)
|
||||
$selected_role_name = $validated['admin_role'] ?? '通用客戶角色範本';
|
||||
$role_to_assign = '管理員';
|
||||
|
||||
$template_role = \App\Models\System\Role::where('name', $selected_role_name)
|
||||
->whereNull('company_id')
|
||||
->where('is_system', 0)
|
||||
->first();
|
||||
|
||||
if ($template_role) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company->id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($template_role->permissions);
|
||||
} else {
|
||||
// 如果找不到選定的角色範本,退而求其次嘗試指派現有角色 (通常不應發生)
|
||||
$role_to_assign = $selected_role_name;
|
||||
}
|
||||
|
||||
$user->assignRole($role_to_assign);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,10 +16,7 @@ class PermissionController extends Controller
|
||||
|
||||
// 租戶隔離:租戶只能看到自己公司的角色 + 系統角色 (company_id is null)
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where(function($q) use ($user) {
|
||||
$q->where('company_id', $user->company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
// 搜尋:角色名稱
|
||||
@@ -58,15 +55,21 @@ class PermissionController extends Controller
|
||||
*/
|
||||
public function storeRole(Request $request)
|
||||
{
|
||||
$is_system = auth()->user()->isSystemAdmin() && $request->boolean('is_system');
|
||||
$company_id = $is_system ? null : auth()->user()->company_id;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:roles,name',
|
||||
'name' => [
|
||||
'required', 'string', 'max:255',
|
||||
\Illuminate\Validation\Rule::unique('roles', 'name')->where(function ($query) use ($company_id) {
|
||||
return $query->where('company_id', $company_id);
|
||||
})
|
||||
],
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() && $request->boolean('is_system');
|
||||
|
||||
$role = \App\Models\System\Role::create([
|
||||
$role = \App\Models\System\Role::query()->create([
|
||||
'name' => $validated['name'],
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $is_system ? null : auth()->user()->company_id,
|
||||
@@ -92,8 +95,18 @@ class PermissionController extends Controller
|
||||
{
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
|
||||
$is_system = $role->is_system;
|
||||
$company_id = $role->company_id;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:roles,name,' . $id,
|
||||
'name' => [
|
||||
'required', 'string', 'max:255',
|
||||
\Illuminate\Validation\Rule::unique('roles', 'name')
|
||||
->ignore($id)
|
||||
->where(function ($query) use ($company_id) {
|
||||
return $query->where('company_id', $company_id);
|
||||
})
|
||||
],
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
@@ -177,10 +190,7 @@ class PermissionController extends Controller
|
||||
$companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
$roles_query = \App\Models\System\Role::where('name', '!=', 'super-admin');
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$roles_query->where(function($q) {
|
||||
$q->where('company_id', auth()->user()->company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
$roles_query->where('company_id', auth()->user()->company_id);
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
|
||||
@@ -198,7 +208,7 @@ class PermissionController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'email' => 'nullable|email|max:255|unique:users,email',
|
||||
'email' => 'required|email|max:255|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
@@ -206,17 +216,73 @@ class PermissionController extends Controller
|
||||
'phone' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
|
||||
$role = \App\Models\System\Role::where('name', $validated['role'])
|
||||
->where(function($q) use ($company_id) {
|
||||
$q->where('company_id', $company_id)->orWhereNull('company_id');
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$role) {
|
||||
return redirect()->back()->with('error', __('Role not found.'));
|
||||
}
|
||||
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($company_id !== null) {
|
||||
// 如果是租戶帳號,不能選各項系統角色 (is_system = 1)
|
||||
if ($role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be assigned to tenant accounts.'));
|
||||
}
|
||||
// 如果角色有特定的 company_id,必須匹配
|
||||
if ($role->company_id !== null && $role->company_id != $company_id) {
|
||||
return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.'));
|
||||
}
|
||||
} else {
|
||||
// 如果是系統層級帳號,只能選系統角色 (is_system = 1)
|
||||
if (!$role->is_system) {
|
||||
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
|
||||
}
|
||||
}
|
||||
|
||||
// 角色初始化與克隆邏輯 (只有 super-admin 在幫空白公司開帳號時觸發)
|
||||
$role_to_assign = $validated['role'];
|
||||
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
if ($company_id && $role && !$role->is_system && $role->company_id === null) {
|
||||
// 檢查該公司是否已有名為「管理員」的角色
|
||||
$existingRole = \App\Models\System\Role::where('company_id', $company_id)
|
||||
->where('name', '管理員')
|
||||
->first();
|
||||
|
||||
if (!$existingRole) {
|
||||
// 克隆範本為該公司的「管理員」
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $company_id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($role->permissions);
|
||||
$role_to_assign = '管理員';
|
||||
} else {
|
||||
// 如果已存在名為「管理員」的角色,則直接使用它
|
||||
$role_to_assign = '管理員';
|
||||
}
|
||||
}
|
||||
|
||||
$user = \App\Models\System\User::create([
|
||||
'name' => $validated['name'],
|
||||
'username' => $validated['username'],
|
||||
'email' => $validated['email'],
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
|
||||
'status' => $validated['status'],
|
||||
'company_id' => auth()->user()->isSystemAdmin() ? $validated['company_id'] : auth()->user()->company_id,
|
||||
'company_id' => $company_id,
|
||||
'phone' => $validated['phone'],
|
||||
]);
|
||||
|
||||
$user->assignRole($validated['role']);
|
||||
$user->assignRole($role_to_assign);
|
||||
|
||||
return redirect()->back()->with('success', __('Account created successfully.'));
|
||||
}
|
||||
@@ -235,7 +301,7 @@ class PermissionController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username,' . $id,
|
||||
'email' => 'nullable|email|max:255|unique:users,email,' . $id,
|
||||
'email' => 'required|email|max:255|unique:users,email,' . $id,
|
||||
'password' => 'nullable|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
@@ -243,6 +309,35 @@ class PermissionController extends Controller
|
||||
'phone' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
|
||||
$roleObj = \App\Models\System\Role::where('name', $validated['role'])
|
||||
->where(function($q) use ($target_company_id) {
|
||||
$q->where('company_id', $target_company_id)->orWhereNull('company_id');
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$roleObj) {
|
||||
return redirect()->back()->with('error', __('Role not found.'));
|
||||
}
|
||||
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯)
|
||||
if ($target_company_id !== null) {
|
||||
if ($roleObj->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be assigned to tenant accounts.'));
|
||||
}
|
||||
if ($roleObj->company_id !== null && $roleObj->company_id != $target_company_id) {
|
||||
return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.'));
|
||||
}
|
||||
} else {
|
||||
if (!$roleObj->is_system) {
|
||||
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'name' => $validated['name'],
|
||||
'username' => $validated['username'],
|
||||
@@ -265,13 +360,37 @@ class PermissionController extends Controller
|
||||
$updateData['password'] = \Illuminate\Support\Facades\Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
// 角色初始化與克隆邏輯
|
||||
$role_to_assign = $validated['role'];
|
||||
$target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
|
||||
|
||||
if ($target_company_id && $roleObj && !$roleObj->is_system && $roleObj->company_id === null) {
|
||||
// 檢查該公司是否已有名為「管理員」的角色
|
||||
$existingRole = \App\Models\System\Role::where('company_id', $target_company_id)
|
||||
->where('name', '管理員')
|
||||
->first();
|
||||
|
||||
if (!$existingRole) {
|
||||
$clonedRole = \App\Models\System\Role::query()->create([
|
||||
'name' => '管理員',
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $target_company_id,
|
||||
'is_system' => false,
|
||||
]);
|
||||
$clonedRole->syncPermissions($roleObj->permissions);
|
||||
$role_to_assign = '管理員';
|
||||
} else {
|
||||
$role_to_assign = '管理員';
|
||||
}
|
||||
}
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
|
||||
if ($user->id === auth()->id() && auth()->user()->isSystemAdmin()) {
|
||||
$user->syncRoles(['super-admin']);
|
||||
} else {
|
||||
$user->syncRoles([$validated['role']]);
|
||||
$user->syncRoles([$role_to_assign]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Account updated successfully.'));
|
||||
@@ -292,6 +411,12 @@ class PermissionController extends Controller
|
||||
return redirect()->back()->with('error', __('You cannot delete your own account.'));
|
||||
}
|
||||
|
||||
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位
|
||||
$timestamp = now()->getTimestamp();
|
||||
$user->username = $user->username . '.deleted.' . $timestamp;
|
||||
$user->email = $user->email . '.deleted.' . $timestamp;
|
||||
$user->save();
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Account deleted successfully.'));
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
"Enter login ID": "Enter login ID",
|
||||
"Min 8 characters": "Min 8 characters",
|
||||
"Admin display name": "Admin display name",
|
||||
"Initial Role": "Initial Role",
|
||||
"Contact & Details": "Contact & Details",
|
||||
"e.g. Taiwan Star": "e.g. Taiwan Star",
|
||||
"e.g. TWSTAR": "e.g. TWSTAR",
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
"Enter login ID": "請輸入登入帳號",
|
||||
"Min 8 characters": "至少 8 個字元",
|
||||
"Admin display name": "管理員顯示名稱",
|
||||
"Initial Role": "初始角色",
|
||||
"Contact & Details": "聯絡資訊與詳情",
|
||||
"e.g. Taiwan Star": "例如:台灣之星",
|
||||
"e.g. TWSTAR": "例如:TWSTAR",
|
||||
|
||||
@@ -61,6 +61,66 @@
|
||||
animation: fadeUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
/* Additional Loading Animations */
|
||||
@keyframes fadeInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-right {
|
||||
animation: fadeInRight 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes trickle {
|
||||
0% { width: 0%; opacity: 1; }
|
||||
20% { width: 30%; }
|
||||
50% { width: 70%; }
|
||||
80% { width: 90%; }
|
||||
95% { width: 95%; }
|
||||
100% { width: 95%; }
|
||||
}
|
||||
|
||||
.animate-trickle {
|
||||
animation: trickle 10s cubic-bezier(0.1, 0.5, 0.5, 1) forwards;
|
||||
}
|
||||
|
||||
/* Top Progress Bar (Trickle Style) */
|
||||
.top-loading-bar {
|
||||
@apply fixed top-0 left-0 right-0 h-0.5 z-[99999] bg-gradient-to-r from-cyan-500 to-blue-500 opacity-0 transition-opacity duration-300;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.top-loading-bar.loading {
|
||||
@apply opacity-100;
|
||||
animation: trickle 12s cubic-bezier(0.1, 0.5, 0.5, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes loadingPulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Skeleton Loading Utilities (Option C) */
|
||||
.skeleton {
|
||||
@apply bg-slate-100 dark:bg-slate-800 animate-pulse-subtle rounded-lg overflow-hidden relative border-none !text-transparent selection:bg-transparent pointer-events-none;
|
||||
}
|
||||
|
||||
@keyframes pulse-subtle {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-pulse-subtle {
|
||||
animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar - Minimal & Elegant */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -322,10 +322,22 @@
|
||||
placeholder="{{ __('Min 8 characters') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Admin Name') }}</label>
|
||||
<input type="text" name="admin_name" class="luxury-input w-full" placeholder="{{ __('Admin display name') }}">
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Initial Role') }}</label>
|
||||
<select name="admin_role" class="luxury-select w-full">
|
||||
@foreach($template_roles as $role)
|
||||
<option value="{{ $role->name }}" {{ $role->name == '通用客戶角色範本' ? 'selected' : '' }}>
|
||||
{{ $role->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Section -->
|
||||
|
||||
@@ -7,28 +7,53 @@
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6" x-data="{
|
||||
showModal: false,
|
||||
editing: false,
|
||||
showModal: {{ $errors->any() ? 'true' : 'false' }},
|
||||
editing: {{ old('_method') === 'PUT' || (isset($user) && $errors->any()) ? 'true' : 'false' }},
|
||||
allRoles: @js($roles),
|
||||
currentUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
company_id: '',
|
||||
role: 'user',
|
||||
status: 1
|
||||
id: '{{ old('id') }}',
|
||||
name: '{{ old('name') }}',
|
||||
username: '{{ old('username') }}',
|
||||
email: '{{ old('email') }}',
|
||||
phone: '{{ old('phone') }}',
|
||||
company_id: '{{ old('company_id', auth()->user()->isSystemAdmin() ? '' : auth()->user()->company_id) }}',
|
||||
role: '{{ old('role', '') }}',
|
||||
status: {{ old('status', 1) }}
|
||||
},
|
||||
get filteredRoles() {
|
||||
if (this.currentUser.company_id === '' || this.currentUser.company_id === null) {
|
||||
// 系統層級:顯示 is_system = 1 的角色
|
||||
return this.allRoles.filter(r => r.is_system);
|
||||
} else {
|
||||
// 客戶層級:只顯示該公司的角色
|
||||
let roles = this.allRoles.filter(r => r.company_id == this.currentUser.company_id);
|
||||
|
||||
// 如果是系統管理員,額外允許選擇「客戶層級範本」
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
let templates = this.allRoles.filter(r => !r.is_system && (r.company_id === null || r.company_id === ''));
|
||||
roles = [...roles, ...templates];
|
||||
@endif
|
||||
|
||||
return roles;
|
||||
}
|
||||
},
|
||||
openCreateModal() {
|
||||
this.editing = false;
|
||||
this.currentUser = { id: '', name: '', username: '', email: '', phone: '', company_id: '', role: 'user', status: 1 };
|
||||
this.currentUser = { id: '', name: '', username: '', email: '', phone: '', company_id: '{{ auth()->user()->isSystemAdmin() ? "" : auth()->user()->company_id }}', role: '', status: 1 };
|
||||
this.showModal = true;
|
||||
// 預設選取第一個可用的角色
|
||||
this.$nextTick(() => {
|
||||
if (this.filteredRoles.length > 0) {
|
||||
this.currentUser.role = this.filteredRoles[0].name;
|
||||
}
|
||||
});
|
||||
},
|
||||
openEditModal(user) {
|
||||
this.editing = true;
|
||||
this.currentUser = {
|
||||
...user,
|
||||
role: user.roles && user.roles.length > 0 ? user.roles[0].name : 'user'
|
||||
company_id: user.company_id || '',
|
||||
role: user.roles && user.roles.length > 0 ? user.roles[0].name : ''
|
||||
};
|
||||
this.showModal = true;
|
||||
}
|
||||
@@ -217,61 +242,97 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Full Name') }}</label>
|
||||
<input type="text" name="name" x-model="currentUser.name" required class="luxury-input" placeholder="{{ __('e.g. John Doe') }}">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
|
||||
{{ __('Full Name') }} <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" x-model="currentUser.name" required class="luxury-input @error('name') border-rose-500 @enderror" placeholder="{{ __('e.g. John Doe') }}">
|
||||
@error('name')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Username') }}</label>
|
||||
<input type="text" name="username" x-model="currentUser.username" required class="luxury-input" placeholder="{{ __('e.g. johndoe') }}">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
|
||||
{{ __('Username') }} <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="username" x-model="currentUser.username" required class="luxury-input @error('username') border-rose-500 @enderror" placeholder="{{ __('e.g. johndoe') }}">
|
||||
@error('username')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Email') }}</label>
|
||||
<input type="email" name="email" x-model="currentUser.email" class="luxury-input" placeholder="{{ __('john@example.com') }}">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
|
||||
{{ __('Email') }} <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="email" name="email" x-model="currentUser.email" required class="luxury-input @error('email') border-rose-500 @enderror" placeholder="{{ __('john@example.com') }}">
|
||||
@error('email')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Phone') }}</label>
|
||||
<input type="text" name="phone" x-model="currentUser.phone" class="luxury-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Role') }}</label>
|
||||
<select name="role" x-model="currentUser.role" class="luxury-select">
|
||||
@foreach($roles as $role)
|
||||
<option value="{{ $role->name }}">{{ __($role->name) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Status') }}</label>
|
||||
<select name="status" x-model="currentUser.status" class="luxury-select">
|
||||
<option value="1">{{ __('Active') }}</option>
|
||||
<option value="0">{{ __('Disabled') }}</option>
|
||||
</select>
|
||||
<input type="text" name="phone" x-model="currentUser.phone" class="luxury-input @error('phone') border-rose-500 @enderror">
|
||||
@error('phone')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2 mb-6">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Company') }}</label>
|
||||
<select name="company_id" x-model="currentUser.company_id" class="luxury-select">
|
||||
<select name="company_id" x-model="currentUser.company_id" class="luxury-select @error('company_id') border-rose-500 @enderror"
|
||||
@change="$nextTick(() => { if (filteredRoles.length > 0 && !filteredRoles.find(r => r.name === currentUser.role)) { currentUser.role = filteredRoles[0].name; } })">
|
||||
<option value="">{{ __('SYSTEM') }}</option>
|
||||
@foreach($companies as $company)
|
||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('company_id')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
|
||||
{{ __('Role') }} <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select name="role" x-model="currentUser.role" class="luxury-select @error('role') border-rose-500 @enderror">
|
||||
<template x-for="role in filteredRoles" :key="role.id">
|
||||
<option :value="role.name" x-text="role.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('role')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Status') }}</label>
|
||||
<select name="status" x-model="currentUser.status" class="luxury-select @error('status') border-rose-500 @enderror">
|
||||
<option value="1">{{ __('Active') }}</option>
|
||||
<option value="0">{{ __('Disabled') }}</option>
|
||||
</select>
|
||||
@error('status')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
|
||||
<span x-text="editing ? '{{ __('New Password (leave blank to keep current)') }}' : '{{ __('Password') }}'"></span>
|
||||
<template x-if="!editing">
|
||||
<span class="text-rose-500">*</span>
|
||||
</template>
|
||||
</label>
|
||||
<input type="password" name="password" :required="!editing" class="luxury-input" placeholder="••••••••">
|
||||
<input type="password" name="password" :required="!editing" class="luxury-input @error('password') border-rose-500 @enderror" placeholder="••••••••">
|
||||
@error('password')
|
||||
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-x-4 pt-8">
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6" x-data="{
|
||||
showModal: false,
|
||||
isEdit: false,
|
||||
roleId: '',
|
||||
roleName: '',
|
||||
rolePermissions: [],
|
||||
isSystem: false,
|
||||
modalTitle: '{{ __('Create Role') }}',
|
||||
showModal: {{ $errors->any() ? 'true' : 'false' }},
|
||||
isEdit: {{ (old('_method') == 'PUT' || request()->has('edit')) ? 'true' : 'false' }},
|
||||
roleId: '{{ old('roleId', '') }}',
|
||||
roleName: '{{ old('name', '') }}',
|
||||
rolePermissions: @js(old('permissions', [])),
|
||||
isSystem: {{ old('is_system', '0') }},
|
||||
modalTitle: '{{ $errors->any() && old('_method') == 'PUT' ? __('Edit Role') : ($errors->any() ? __('Create Role') : __('Create Role')) }}',
|
||||
openModal(edit = false, id = '', name = '', permissions = [], isSys = false) {
|
||||
this.isEdit = edit;
|
||||
this.roleId = id;
|
||||
@@ -160,6 +160,7 @@
|
||||
<form :action="isEdit ? '{{ route($baseRoute) }}/' + roleId : '{{ route($baseRoute . '.store') }}'" method="POST">
|
||||
@csrf
|
||||
<template x-if="isEdit"><input type="hidden" name="_method" value="PUT"></template>
|
||||
<input type="hidden" name="roleId" x-model="roleId">
|
||||
|
||||
<div class="p-8 max-h-[65vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
@@ -167,7 +168,13 @@
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-400 uppercase tracking-widest pl-1">{{ __('Role Name') }}</label>
|
||||
<input type="text" name="name" x-model="roleName" required class="luxury-input w-full" placeholder="{{ __('Enter role name') }}" :disabled="isEdit && roleName === 'super-admin'">
|
||||
<input type="text" name="name" x-model="roleName" required class="luxury-input w-full @error('name') border-rose-500 @enderror" placeholder="{{ __('Enter role name') }}" :disabled="isEdit && roleName === 'super-admin'">
|
||||
@error('name')
|
||||
<p class="text-[11px] text-rose-500 font-bold mt-1.5 px-1 flex items-center gap-1.5 animate-luxury-in">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
{{ $message }}
|
||||
</p>
|
||||
@enderror
|
||||
<template x-if="isEdit && roleName === 'super-admin'">
|
||||
<p class="text-[10px] text-amber-500 font-bold mt-1 px-1 flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
|
||||
51
resources/views/components/loading-screen.blade.php
Normal file
51
resources/views/components/loading-screen.blade.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<div x-data="{
|
||||
loading: true
|
||||
}"
|
||||
x-init="
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const simulateDelay = urlParams.get('simulate_loading');
|
||||
const delay = simulateDelay ? parseInt(simulateDelay) : 300;
|
||||
|
||||
const hideLoading = () => setTimeout(() => loading = false, delay);
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
hideLoading();
|
||||
} else {
|
||||
window.addEventListener('load', hideLoading);
|
||||
// 安全保險:模擬模式下為 30 秒,正常模式下為 5 秒
|
||||
const safetyTimeout = simulateDelay ? 30000 : 5000;
|
||||
setTimeout(hideLoading, safetyTimeout);
|
||||
}
|
||||
"
|
||||
x-show="loading"
|
||||
x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-md"
|
||||
style="display: none;"
|
||||
x-cloak>
|
||||
<div class="relative flex flex-col items-center animate-luxury-in">
|
||||
<!-- Logo with Spinner Animation -->
|
||||
<div class="relative w-28 h-28 mb-10 flex items-center justify-center">
|
||||
<!-- Luxury Rotating Ring -->
|
||||
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin" style="animation-duration: 1.5s;"></div>
|
||||
<div class="absolute inset-2 rounded-full border border-white/5 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||
|
||||
<!-- Glow Effect -->
|
||||
<div class="absolute inset-0 rounded-full bg-cyan-500/10 blur-xl animate-pulse"></div>
|
||||
|
||||
<!-- Central Logo -->
|
||||
<div class="relative w-20 h-20 rounded-3xl bg-slate-900/80 backdrop-blur-xl border border-white/20 flex items-center justify-center shadow-2xl">
|
||||
<span class="text-white text-5xl font-black font-display tracking-tighter">S</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Animation -->
|
||||
<div class="flex items-center gap-x-3 text-3xl font-bold text-white font-display tracking-tightest overflow-hidden mb-4">
|
||||
<span class="animate-fade-in-right opacity-0" style="animation-delay: 100ms; animation-fill-mode: forwards;">Star</span>
|
||||
<span class="text-cyan-400 animate-fade-in-right opacity-0" style="animation-delay: 300ms; animation-fill-mode: forwards;">Cloud</span>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] font-black text-white/30 uppercase tracking-[0.6em] animate-pulse">{{ __('Systems Initializing') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,6 +151,26 @@
|
||||
</div>
|
||||
<!-- End Card -->
|
||||
</div>
|
||||
|
||||
<!-- Skeleton Example (Option C) -->
|
||||
<div class="luxury-card p-8 animate-fade-up">
|
||||
<div class="flex items-center justify-between mb-10">
|
||||
<div>
|
||||
<div class="h-4 w-32 skeleton mb-2"></div>
|
||||
<div class="h-7 w-48 skeleton"></div>
|
||||
</div>
|
||||
<div class="h-10 w-24 skeleton"></div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="h-4 w-full skeleton"></div>
|
||||
<div class="h-4 w-11/12 skeleton"></div>
|
||||
<div class="h-4 w-4/5 skeleton"></div>
|
||||
</div>
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800 flex gap-x-3">
|
||||
<div class="h-10 w-28 skeleton"></div>
|
||||
<div class="h-10 w-28 skeleton"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-[#0f172a] antialiased font-sans h-full selection:bg-indigo-100 dark:selection:bg-indigo-900/40" x-data="{ sidebarOpen: false, userDropdownOpen: false }">
|
||||
<!-- Option A: Loading Screen -->
|
||||
<x-loading-screen />
|
||||
|
||||
<!-- Option B: Top Progress Bar -->
|
||||
<div id="top-loading-bar" class="top-loading-bar"></div>
|
||||
|
||||
<script>
|
||||
// 僅保留最基本的導航列觸發,不使用全螢幕遮罩防止卡死
|
||||
window.addEventListener('beforeunload', () => {
|
||||
document.getElementById('top-loading-bar').classList.add('loading');
|
||||
});
|
||||
|
||||
window.addEventListener('pageshow', () => {
|
||||
document.getElementById('top-loading-bar').classList.remove('loading');
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Sidebar Overlay (Mobile) -->
|
||||
<div x-show="sidebarOpen"
|
||||
|
||||
Reference in New Issue
Block a user