[FEAT] 實作系統管理員切換帳號 (Impersonate) 功能

1. 新增 `ImpersonateController` 處理帳號切換與退出邏輯,並提供公司與使用者的動態下拉選單 API (包含 username 支援)。
2. 於 `admin.blade.php` 的 Header 個人選單中加入「切換帳號」入口,並支援退出切換狀態的顯示。
3. 實作 `modal.blade.php` Alpine.js 彈窗,使用專案規範的 `x-searchable-select` 動態重建模式 (Dynamic Rebuild Pattern) 與 `focus-within:z-[60]` 防護。
4. 註冊相關 Web Routes。
5. 更新 `ui-minimal-luxury/SKILL.md`,新增 Alpine.js Modal 實作規範,並禁止使用 `hs-overlay` 避免層級堆疊問題。
This commit is contained in:
sky121113 2026-05-17 11:07:41 +08:00
parent cf2c2f6e39
commit ea438d55d1
5 changed files with 486 additions and 1 deletions

View File

@ -404,7 +404,82 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
| 刪除確認 Modal | `<x-delete-confirm-modal />` |
| 頁面內警告(琥珀色)| `<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 font-bold">` |
---
### 7.1 Modal 實作規範(強制)
> [!CAUTION]
> **嚴禁使用 Preline 的 `hs-overlay` 系統**來實作 Modal。
> 本專案全部 Modal 統一使用 **Alpine.js** (`x-show` + `x-transition`) 實作。
> 使用 `hs-overlay` class 會導致 Modal 出現但完全透明(`opacity: 0`),因為 Preline overlay 的動畫 class 在此專案中未被正確初始化。
**正確Alpine.js Modal 標準結構(參考 `modal-replenishment-create.blade.php`**
```html
{{-- 1. 觸發按鈕:用 $dispatch 發送 window 事件 --}}
<button type="button" @click="$dispatch('open-xxx-modal')">開啟</button>
{{-- 2. Modal 主體:放在 layout 底部x-data 宣告 Alpine state --}}
<div x-data="{ showModal: false }" @open-xxx-modal.window="showModal = true" x-cloak>
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;">
<div class="flex items-center justify-center min-h-screen px-4 ...">
{{-- Backdrop --}}
<div x-show="showModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"
@click="showModal = false"></div>
{{-- Modal 內容 --}}
<div x-show="showModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900
rounded-[2.5rem] text-left shadow-2xl transform
sm:my-8 sm:align-middle sm:max-w-lg w-full
border border-slate-100 dark:border-slate-800 z-10"
@click.stop>
{{-- Header --}}
<div class="px-10 py-8 pb-4 flex items-center justify-between">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">
標題
</h3>
<button @click="showModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- Body / Footer --}}
<div class="px-10 py-6">...</div>
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex justify-end gap-4">
<button @click="showModal = false" class="btn-luxury-ghost px-8">取消</button>
<button type="submit" class="btn-luxury-primary px-12">確認</button>
</div>
</div>
</div>
</div>
</div>
```
**跨元件開啟 Modaldropdown 內的按鈕):**
- 按鈕在 Alpine.js dropdown 內時,需先關閉 dropdown 再發送事件:
```html
<button @click="open = false; $dispatch('open-xxx-modal')">切換帳號</button>
```
- Modal 透過 `@open-xxx-modal.window` 接收(`window` 層級才能跨 Alpine scope 傳遞)
## 8. AJAX fetchPage 標準實作

View File

@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\System\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ImpersonateController extends Controller
{
/**
* Start impersonating a user.
*/
public function store(Request $request)
{
$request->validate([
'user_id' => 'required|exists:users,id',
]);
$currentUser = $request->user();
// 1. Only system admins can impersonate
if (!$currentUser->isSystemAdmin()) {
abort(403, '只有系統管理員可以使用此功能。');
}
// 2. Prevent nested impersonation (cannot impersonate someone while already impersonating)
if (session()->has('impersonated_by')) {
return back()->with('error', '您目前已經處於切換狀態,無法再次切換。請先退出目前的切換狀態。');
}
$targetUserId = $request->input('user_id');
// 3. Cannot impersonate oneself
if ($currentUser->id == $targetUserId) {
return back()->with('error', '無法切換至自己的帳號。');
}
$targetUser = User::findOrFail($targetUserId);
// 4. Set session and login
session()->put('impersonated_by', $currentUser->id);
Auth::loginUsingId($targetUser->id);
return redirect()->route('admin.dashboard')->with('success', "已成功切換至 {$targetUser->name} 的帳號。");
}
/**
* Stop impersonating and return to the original user.
*/
public function leave(Request $request)
{
if (!session()->has('impersonated_by')) {
return back()->with('error', '您目前並未處於切換狀態。');
}
$originalUserId = session()->pull('impersonated_by');
Auth::loginUsingId($originalUserId);
return redirect()->route('admin.dashboard')->with('success', '已退出切換狀態,返回原帳號。');
}
/**
* Fetch companies for the impersonate modal.
*/
public function companies(Request $request)
{
if (!$request->user()->isSystemAdmin()) abort(403);
$companies = \App\Models\System\Company::select('id', 'name')->orderBy('name')->get();
return response()->json($companies);
}
/**
* Fetch users for the impersonate modal, optionally filtered by company.
*/
public function users(Request $request)
{
if (!$request->user()->isSystemAdmin()) abort(403);
$query = User::select('id', 'name', 'username', 'email', 'company_id');
if ($request->filled('company_id')) {
$query->where('company_id', $request->company_id);
} else {
// Include system accounts (company_id is null) when no company is selected?
// Actually let's just return all users if no company selected, or just system accounts.
// But if company_id is explicitly sent as 'system', we filter for null.
if ($request->company_id === 'system') {
$query->whereNull('company_id');
}
}
$users = $query->orderBy('name')->get();
return response()->json($users);
}
}

View File

@ -0,0 +1,273 @@
{{-- 切換帳號 Modal --}}
<div x-data="impersonateModal()" @open-impersonate-modal.window="openModal()" x-cloak>
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
{{-- Backdrop --}}
<div x-show="showModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="showModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span>
{{-- Modal Content --}}
<div x-show="showModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] text-left shadow-2xl transform sm:my-8 sm:align-middle sm:max-w-lg w-full border border-slate-100 dark:border-slate-800 z-10"
@click.stop>
{{-- Header --}}
<div class="px-10 py-8 pb-4 flex items-center justify-between">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-3">
{{ __('切換帳號') }}
</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">
{{ __('以其他帳號身份操作系統') }}
</p>
</div>
<button @click="showModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700 shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- Body --}}
<form action="{{ route('admin.impersonate.store') }}" method="POST" class="flex-1 overflow-y-auto px-10 py-2">
@csrf
<div class="space-y-6 pb-6">
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ __('請選擇您要切換的目標公司與帳號。切換後,您將擁有該帳號的所有操作權限。') }}
</p>
{{-- Company Select --}}
<div class="space-y-3 relative group focus-within:z-[60]">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">
{{ __('公司') }}
</label>
<div id="impersonate-company-select-wrapper" class="relative">
{{-- 透過 Alpine.js 動態重建 HSSelect --}}
<div class="luxury-input w-full px-6 py-4 bg-slate-50/50 dark:bg-slate-900/50 text-slate-400 text-sm flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-cyan-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ __('載入中...') }}
</div>
</div>
</div>
{{-- User Select --}}
<div class="space-y-3 relative group focus-within:z-[60]">
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">
{{ __('使用者帳號') }}
</label>
<div id="impersonate-user-select-wrapper" class="relative">
{{-- 透過 Alpine.js 動態重建 HSSelect --}}
</div>
</div>
</div>
{{-- Footer --}}
<div class="py-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-end gap-4">
<button type="button" @click="showModal = false" class="btn-luxury-ghost px-8">
{{ __('取消') }}
</button>
<button type="submit" :disabled="!selectedUser" class="btn-luxury-primary px-12 disabled:opacity-50">
{{ __('確認切換') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
function impersonateModal() {
return {
showModal: false,
companies: [],
users: [],
selectedCompany: '',
selectedUser: '',
isLoadingUsers: false,
initialized: false,
openModal() {
this.showModal = true;
if (!this.initialized) {
this.fetchCompanies();
this.initialized = true;
} else {
// Make sure Preline JS re-initializes or attaches correctly when modal becomes visible
this.$nextTick(() => {
this.updateCompanySelect();
this.updateUserSelect();
});
}
},
fetchCompanies() {
fetch("{{ route('admin.impersonate.companies') }}")
.then(res => res.json())
.then(data => {
this.companies = data;
this.$nextTick(() => {
this.updateCompanySelect();
this.updateUserSelect();
});
});
},
fetchUsers() {
if (this.selectedCompany === '') return;
this.isLoadingUsers = true;
this.users = [];
this.selectedUser = '';
this.$nextTick(() => { this.updateUserSelect(); });
let url = "{{ route('admin.impersonate.users') }}" + '?company_id=' + this.selectedCompany;
fetch(url)
.then(res => res.json())
.then(data => {
this.users = data;
this.isLoadingUsers = false;
this.$nextTick(() => { this.updateUserSelect(); });
});
},
updateCompanySelect() {
const wrapper = document.getElementById('impersonate-company-select-wrapper');
if (!wrapper) return;
const oldSelect = wrapper.querySelector('select');
if (oldSelect && window.HSSelect?.getInstance(oldSelect)) {
try { window.HSSelect.getInstance(oldSelect).destroy(); } catch (e) { }
}
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.id = `impersonate-company-${Date.now()}`;
selectEl.className = 'hidden';
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = '{{ __("請選擇公司") }}';
selectEl.appendChild(placeholderOpt);
const sysOpt = document.createElement('option');
sysOpt.value = 'system';
sysOpt.textContent = '{{ __("系統帳號 (無公司)") }}';
if (this.selectedCompany === 'system') sysOpt.selected = true;
selectEl.appendChild(sysOpt);
this.companies.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
if (this.selectedCompany == c.id) opt.selected = true;
selectEl.appendChild(opt);
});
selectEl.setAttribute('data-hs-select', JSON.stringify({
"placeholder": "{{ __('請選擇公司') }}",
"hasSearch": true,
"searchPlaceholder": "{{ __('Search...') }}",
"isHidePlaceholder": false,
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left",
"toggleTemplate": "<button type=\"button\" aria-expanded=\"false\"><span class=\"me-2\" data-icon></span><span class=\"text-slate-800 dark:text-slate-200 truncate\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400 transition-transform duration-300\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\" /></svg></div></button>",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in max-h-72 overflow-y-auto custom-scrollbar-thin",
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate": "<div class=\"flex items-center justify-between w-full\"><span data-title></span><span class=\"hs-select-active-indicator hidden text-cyan-500\"><svg class=\"w-4 h-4\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg></span></div>"
}));
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', (e) => {
this.selectedCompany = e.target.value;
this.fetchUsers();
});
if (window.HSStaticMethods) {
window.HSStaticMethods.autoInit();
}
},
updateUserSelect() {
const wrapper = document.getElementById('impersonate-user-select-wrapper');
if (!wrapper) return;
const oldSelect = wrapper.querySelector('select');
if (oldSelect && window.HSSelect?.getInstance(oldSelect)) {
try { window.HSSelect.getInstance(oldSelect).destroy(); } catch (e) { }
}
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.id = `impersonate-user-${Date.now()}`;
selectEl.name = 'user_id';
selectEl.required = true;
selectEl.className = 'hidden';
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
if (this.isLoadingUsers) {
placeholderOpt.textContent = '{{ __("載入中...") }}';
selectEl.disabled = true;
} else if (!this.selectedCompany) {
placeholderOpt.textContent = '{{ __("請先選擇公司") }}';
selectEl.disabled = true;
} else if (this.users.length === 0) {
placeholderOpt.textContent = '{{ __("此公司目前沒有帳號") }}';
selectEl.disabled = true;
} else {
placeholderOpt.textContent = '{{ __("請選擇帳號") }}';
}
selectEl.appendChild(placeholderOpt);
this.users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
const identifier = u.username || u.email || '';
opt.textContent = identifier ? `${u.name} (${identifier})` : u.name;
if (this.selectedUser == u.id) opt.selected = true;
selectEl.appendChild(opt);
});
selectEl.setAttribute('data-hs-select', JSON.stringify({
"placeholder": selectEl.disabled ? placeholderOpt.textContent : "{{ __('請選擇帳號') }}",
"hasSearch": true,
"searchPlaceholder": "{{ __('Search...') }}",
"isHidePlaceholder": false,
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left " + (selectEl.disabled ? "opacity-50 cursor-not-allowed" : ""),
"toggleTemplate": "<button type=\"button\" aria-expanded=\"false\"><span class=\"me-2\" data-icon></span><span class=\"text-slate-800 dark:text-slate-200 truncate\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400 transition-transform duration-300\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\" /></svg></div></button>",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in max-h-72 overflow-y-auto custom-scrollbar-thin",
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate": "<div class=\"flex items-center justify-between w-full\"><span class=\"truncate\" data-title></span><span class=\"hs-select-active-indicator hidden text-cyan-500 shrink-0\"><svg class=\"w-4 h-4\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg></span></div>"
}));
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', (e) => {
this.selectedUser = e.target.value;
});
if (window.HSStaticMethods) {
window.HSStaticMethods.autoInit();
}
}
}
}
</script>

View File

@ -306,6 +306,35 @@
</svg>
{{ __('Account Settings') }}
</a>
@if(session()->has('impersonated_by'))
<div class="my-2 border-t border-slate-100 dark:border-slate-700"></div>
<form method="POST" action="{{ route('admin.impersonate.leave') }}">
@csrf
<button type="submit"
class="w-full flex items-center gap-x-3.5 py-2.5 px-3 rounded-xl text-sm font-bold text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 transition-colors">
<svg class="flex-shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
{{ __('退出切換 (返回)') }}
</button>
</form>
@elseif(auth()->user()->isSystemAdmin())
<div class="my-2 border-t border-slate-100 dark:border-slate-700"></div>
<button type="button" @click="open = false; $dispatch('open-impersonate-modal')"
class="w-full flex items-center gap-x-3.5 py-2.5 px-3 rounded-xl text-sm font-bold text-slate-700 hover:bg-gray-100 focus:ring-2 focus:ring-blue-500 dark:text-slate-300 dark:hover:bg-gray-700 dark:hover:text-white transition-colors">
<svg class="flex-shrink-0 size-4 text-emerald-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{{ __('切換帳號') }}
</button>
@endif
<div class="my-2 border-t border-slate-100 dark:border-slate-700"></div>
<form method="POST" action="{{ route('logout') }}">
@csrf
@ -420,6 +449,10 @@
</div>
<!-- End Content -->
@if(auth()->user() && auth()->user()->isSystemAdmin())
@include('admin.impersonate.modal')
@endif
<x-toast />
@yield('scripts')

View File

@ -283,6 +283,12 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 主題設定
Route::post('/theme', [App\Http\Controllers\Admin\ThemeController::class, 'update'])->name('theme.update');
// 16. 切換帳號 (Impersonation)
Route::post('/impersonate', [App\Http\Controllers\Admin\ImpersonateController::class, 'store'])->name('impersonate.store');
Route::post('/impersonate/leave', [App\Http\Controllers\Admin\ImpersonateController::class, 'leave'])->name('impersonate.leave');
Route::get('/impersonate/companies', [App\Http\Controllers\Admin\ImpersonateController::class, 'companies'])->name('impersonate.companies');
Route::get('/impersonate/users', [App\Http\Controllers\Admin\ImpersonateController::class, 'users'])->name('impersonate.users');
});
Route::middleware('auth')->group(function () {