feat(M08-D): 补后台登录与可撤销会话
This commit is contained in:
+38
-26
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-config-provider>
|
<el-config-provider>
|
||||||
<main class="app-shell">
|
<AdminLoginPanel v-if="!savedToken" @authenticated="handleAuthenticated" />
|
||||||
|
<main v-else class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-mark">棋</div>
|
<div class="brand-mark">棋</div>
|
||||||
@@ -10,11 +11,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-list" aria-label="后台模块">
|
<nav class="nav-list" aria-label="后台模块">
|
||||||
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
|
<button v-if="canOpen('platformApps')" class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
|
||||||
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
|
<button v-if="canOpen('content')" class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
|
||||||
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
|
<button v-if="canOpen('franchise')" class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
|
||||||
<button class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
|
<button v-if="canOpen('system')" class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('overview')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'overview' }"
|
:class="{ active: activeModule === 'overview' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
<span>运营总览</span>
|
<span>运营总览</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('cleaning')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'cleaning' }"
|
:class="{ active: activeModule === 'cleaning' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
<span>保洁运营</span>
|
<span>保洁运营</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('stores')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'stores' }"
|
:class="{ active: activeModule === 'stores' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -42,6 +46,7 @@
|
|||||||
<span>门店房间</span>
|
<span>门店房间</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('orders')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'orders' }"
|
:class="{ active: activeModule === 'orders' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -51,6 +56,7 @@
|
|||||||
<span>订单运营</span>
|
<span>订单运营</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('devices')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'devices' }"
|
:class="{ active: activeModule === 'devices' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -60,6 +66,7 @@
|
|||||||
<span>设备</span>
|
<span>设备</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('payments')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'payments' }"
|
:class="{ active: activeModule === 'payments' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,6 +76,7 @@
|
|||||||
<span>支付分账</span>
|
<span>支付分账</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('thirdParty')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'thirdParty' }"
|
:class="{ active: activeModule === 'thirdParty' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -78,6 +86,7 @@
|
|||||||
<span>团购平台</span>
|
<span>团购平台</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpen('people')"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'people' }"
|
:class="{ active: activeModule === 'people' }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -95,18 +104,9 @@
|
|||||||
<p class="eyebrow">{{ activeModuleMeta.stage }}</p>
|
<p class="eyebrow">{{ activeModuleMeta.stage }}</p>
|
||||||
<h2>{{ activeModuleMeta.title }}</h2>
|
<h2>{{ activeModuleMeta.title }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="session-box">
|
||||||
<el-input
|
<div class="session-user"><span>{{ currentUser?.nickname?.slice(0, 1) || '管' }}</span><div><strong>{{ currentUser?.nickname || '管理员' }}</strong><small>{{ currentAccess.roles.join(' · ') || '已登录' }}</small></div></div>
|
||||||
v-model="tokenDraft"
|
<el-tooltip content="安全退出" placement="bottom"><el-button :icon="LogOut" circle @click="handleLogout" /></el-tooltip>
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="后台访问令牌"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="saveToken"
|
|
||||||
/>
|
|
||||||
<el-tooltip content="保存令牌" placement="bottom">
|
|
||||||
<el-button :icon="Save" type="primary" @click="saveToken" />
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -336,7 +336,7 @@ import {
|
|||||||
RadioTower,
|
RadioTower,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Save,
|
LogOut,
|
||||||
Send,
|
Send,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Store,
|
Store,
|
||||||
@@ -360,6 +360,7 @@ import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
|||||||
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
||||||
import FranchisePanel from './components/FranchisePanel.vue';
|
import FranchisePanel from './components/FranchisePanel.vue';
|
||||||
import LogsSystemPanel from './components/LogsSystemPanel.vue';
|
import LogsSystemPanel from './components/LogsSystemPanel.vue';
|
||||||
|
import AdminLoginPanel from './components/AdminLoginPanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -370,20 +371,27 @@ import {
|
|||||||
executeWechatTransfer,
|
executeWechatTransfer,
|
||||||
generateCleaningSettlement,
|
generateCleaningSettlement,
|
||||||
getCleaningStatistics,
|
getCleaningStatistics,
|
||||||
|
getAdminMe,
|
||||||
listCleaningSettlements,
|
listCleaningSettlements,
|
||||||
listCleaningTasks,
|
listCleaningTasks,
|
||||||
listStaffUsers,
|
listStaffUsers,
|
||||||
|
logoutAdmin,
|
||||||
reclaimCleaningTimeouts,
|
reclaimCleaningTimeouts,
|
||||||
recordPayoutFailure,
|
recordPayoutFailure,
|
||||||
rejectCleaningTask,
|
rejectCleaningTask,
|
||||||
resetStaffSessions,
|
resetStaffSessions,
|
||||||
syncWechatTransfer,
|
syncWechatTransfer,
|
||||||
|
clearStoredSession,
|
||||||
|
storeAdminSession,
|
||||||
updateStaffUser
|
updateStaffUser
|
||||||
} from './api';
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
CleaningSettlement,
|
CleaningSettlement,
|
||||||
CleaningStatistics,
|
CleaningStatistics,
|
||||||
CleaningTask,
|
CleaningTask,
|
||||||
|
AdminAccess,
|
||||||
|
AdminIdentity,
|
||||||
|
AdminSessionPayload,
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
PageResult,
|
PageResult,
|
||||||
PayoutStateFilter,
|
PayoutStateFilter,
|
||||||
@@ -395,7 +403,8 @@ import type {
|
|||||||
import { money } from './format';
|
import { money } from './format';
|
||||||
|
|
||||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||||
const tokenDraft = ref(savedToken.value);
|
const currentUser = ref<AdminIdentity | null>(null);
|
||||||
|
const currentAccess = reactive<AdminAccess>({ roles: [], capabilities: [], storeIds: [], menus: [] });
|
||||||
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'system' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'system' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
||||||
const activeTab = ref('tasks');
|
const activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
@@ -462,12 +471,13 @@ const operationalQueueTotal = computed(() => (
|
|||||||
+ draftSettlementTotal.value
|
+ draftSettlementTotal.value
|
||||||
));
|
));
|
||||||
|
|
||||||
function saveToken() {
|
function handleAuthenticated(payload: AdminSessionPayload) { storeAdminSession(payload); savedToken.value = payload.accessToken; currentUser.value = payload.user; Object.assign(currentAccess, payload.access); selectAllowedModule(); ElMessage.success('登录成功'); }
|
||||||
savedToken.value = tokenDraft.value.trim();
|
async function handleLogout() { try { await logoutAdmin(session.value); } catch { /* local cleanup is authoritative */ } clearSession(); }
|
||||||
localStorage.setItem('qipai.admin.token', savedToken.value);
|
function clearSession() { clearStoredSession(); savedToken.value = ''; currentUser.value = null; Object.assign(currentAccess, { roles: [], capabilities: [], storeIds: [], menus: [] }); }
|
||||||
ElMessage.success('已保存');
|
function handleRefreshed(event: Event) { const payload = (event as CustomEvent<AdminSessionPayload>).detail; savedToken.value = payload.accessToken; currentUser.value = payload.user; Object.assign(currentAccess, payload.access); selectAllowedModule(); }
|
||||||
if (activeModule.value === 'cleaning' && savedToken.value) void loadCleaningWorkspace();
|
async function restoreSession() { if (!savedToken.value) return; try { const result = await getAdminMe(session.value); currentUser.value = result.user; Object.assign(currentAccess, result.access); selectAllowedModule(); } catch { /* request layer refreshes or clears */ } }
|
||||||
}
|
function canOpen(module: string) { return currentAccess.menus.includes(module); }
|
||||||
|
function selectAllowedModule() { if (!canOpen(activeModule.value)) activeModule.value = (currentAccess.menus[0] || 'overview') as typeof activeModule.value; }
|
||||||
|
|
||||||
function loadCleaningWorkspace() {
|
function loadCleaningWorkspace() {
|
||||||
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
||||||
@@ -821,4 +831,6 @@ async function handleResetCleanerSessions(userId: string) {
|
|||||||
watch(activeModule, (module) => {
|
watch(activeModule, (module) => {
|
||||||
if (module === 'cleaning' && session.value.token) void loadCleaningWorkspace();
|
if (module === 'cleaning' && session.value.token) void loadCleaningWorkspace();
|
||||||
});
|
});
|
||||||
|
onMounted(() => { window.addEventListener('qipai:admin-refreshed', handleRefreshed); window.addEventListener('qipai:admin-unauthorized', clearSession); void restoreSession(); });
|
||||||
|
onBeforeUnmount(() => { window.removeEventListener('qipai:admin-refreshed', handleRefreshed); window.removeEventListener('qipai:admin-unauthorized', clearSession); });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+93
-5
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
Advertisement,
|
Advertisement,
|
||||||
AdvertisementInput,
|
AdvertisementInput,
|
||||||
|
AdminIdentity,
|
||||||
|
AdminSessionPayload,
|
||||||
|
AdminAccess,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
CleaningSettlement,
|
CleaningSettlement,
|
||||||
CleaningSettlementDetail,
|
CleaningSettlementDetail,
|
||||||
@@ -66,6 +69,34 @@ export interface ApiSession {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = 'qipai.admin.token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'qipai.admin.refreshToken';
|
||||||
|
let refreshInFlight: Promise<AdminSessionPayload> | null = null;
|
||||||
|
|
||||||
|
export async function adminPasswordLogin(input: {
|
||||||
|
tenantCode: string; loginName: string; password: string;
|
||||||
|
}) {
|
||||||
|
return publicRequest<AdminSessionPayload>('/auth/login', {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminMe(session: ApiSession) {
|
||||||
|
return request<{ user: AdminIdentity; access: AdminAccess }>(session, '/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutAdmin(session: ApiSession) {
|
||||||
|
return request<{ revoked: boolean }>(session, '/auth/logout', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminCredential(session: ApiSession, userId: string, input: {
|
||||||
|
loginName: string; password: string;
|
||||||
|
}) {
|
||||||
|
return request<{ userId: string; configured: boolean }>(session,
|
||||||
|
`/auth/credentials/${encodeURIComponent(userId)}`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
export function listManagedStores(session: ApiSession) {
|
export function listManagedStores(session: ApiSession) {
|
||||||
return request<ManagedStore[]>(session, '/stores');
|
return request<ManagedStore[]>(session, '/stores');
|
||||||
}
|
}
|
||||||
@@ -317,14 +348,22 @@ async function request<T>(
|
|||||||
if (!session.token.trim()) {
|
if (!session.token.trim()) {
|
||||||
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
|
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
|
||||||
}
|
}
|
||||||
const headers = new Headers(options.headers);
|
let response = await authorizedFetch(path, options, session.token.trim());
|
||||||
headers.set('authorization', `Bearer ${session.token.trim()}`);
|
if (response.status === 401 && localStorage.getItem(REFRESH_TOKEN_KEY)) {
|
||||||
if (options.body && !headers.has('content-type')) {
|
try {
|
||||||
headers.set('content-type', 'application/json');
|
const refreshed = await refreshAccessToken();
|
||||||
|
response = await authorizedFetch(path, options, refreshed.accessToken);
|
||||||
|
} catch {
|
||||||
|
clearStoredSession();
|
||||||
|
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok || payload.code !== 0) {
|
if (!response.ok || payload.code !== 0) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearStoredSession();
|
||||||
|
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
|
||||||
|
}
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
String(payload.code || `HTTP_${response.status}`),
|
String(payload.code || `HTTP_${response.status}`),
|
||||||
String(payload.message || '请求失败'),
|
String(payload.message || '请求失败'),
|
||||||
@@ -334,6 +373,55 @@ async function request<T>(
|
|||||||
return payload.data as T;
|
return payload.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publicRequest<T>(path: string, options: RequestInit): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
headers.set('x-request-id', requestId());
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload.code !== 0) throw new ApiError(
|
||||||
|
String(payload.code || `HTTP_${response.status}`),
|
||||||
|
String(payload.message || '请求失败'), String(payload.traceId || '')
|
||||||
|
);
|
||||||
|
return payload.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizedFetch(path: string, options: RequestInit, token: string) {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set('authorization', `Bearer ${token}`);
|
||||||
|
headers.set('x-request-id', requestId());
|
||||||
|
if (options.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
||||||
|
return fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
if (!refreshInFlight) {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '';
|
||||||
|
refreshInFlight = publicRequest<AdminSessionPayload>('/auth/refresh', {
|
||||||
|
method: 'POST', body: JSON.stringify({ refreshToken })
|
||||||
|
}).then((payload) => {
|
||||||
|
storeAdminSession(payload);
|
||||||
|
window.dispatchEvent(new CustomEvent('qipai:admin-refreshed', { detail: payload }));
|
||||||
|
return payload;
|
||||||
|
}).finally(() => { refreshInFlight = null; });
|
||||||
|
}
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeAdminSession(payload: AdminSessionPayload) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredSession() {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestId() {
|
||||||
|
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function listCleaningTasks(
|
export function listCleaningTasks(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string }
|
input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string }
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<main class="login-shell">
|
||||||
|
<section class="login-story" aria-label="平台介绍">
|
||||||
|
<div class="login-brand"><span>棋</span><strong>自助棋牌室运营平台</strong></div>
|
||||||
|
<div><p class="section-kicker">QIPAI OPERATIONS</p><h1>一处掌握门店、订单、设备与资金。</h1><p>统一租户权限、可撤销会话与全链路审计,为日常运营提供可信工作台。</p></div>
|
||||||
|
<ul><li><ShieldCheck :size="18" />短期访问令牌与轮换刷新令牌</li><li><Fingerprint :size="18" />scrypt 密码哈希与失败锁定</li><li><ScrollText :size="18" />关键操作按租户完整留痕</li></ul>
|
||||||
|
</section>
|
||||||
|
<section class="login-form-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<header><p class="section-kicker">管理后台</p><h2>欢迎回来</h2><p>使用租户代码和后台账号登录。</p></header>
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon :closable="false" />
|
||||||
|
<el-form label-position="top" @submit.prevent="login">
|
||||||
|
<el-form-item label="租户代码"><el-input v-model="form.tenantCode" size="large" autocomplete="organization" placeholder="例如 demo" @keyup.enter="login" /></el-form-item>
|
||||||
|
<el-form-item label="登录账号"><el-input v-model="form.loginName" size="large" autocomplete="username" placeholder="请输入后台账号" @keyup.enter="login" /></el-form-item>
|
||||||
|
<el-form-item label="密码"><el-input v-model="form.password" size="large" type="password" show-password autocomplete="current-password" placeholder="请输入密码" @keyup.enter="login" /></el-form-item>
|
||||||
|
<el-button type="primary" size="large" :loading="loading" :disabled="!canSubmit" @click="login">安全登录</el-button>
|
||||||
|
</el-form>
|
||||||
|
<footer><LockKeyhole :size="15" /><span>连续失败 5 次将锁定账号 15 分钟</span></footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { Fingerprint, LockKeyhole, ScrollText, ShieldCheck } from '@lucide/vue';
|
||||||
|
import { adminPasswordLogin, ApiError } from '../api';
|
||||||
|
import type { AdminSessionPayload } from '../types';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ authenticated: [payload: AdminSessionPayload] }>();
|
||||||
|
const loading = ref(false); const lastError = ref('');
|
||||||
|
const form = reactive({ tenantCode: localStorage.getItem('qipai.admin.tenantCode') || '', loginName: '', password: '' });
|
||||||
|
const canSubmit = computed(() => form.tenantCode.trim().length >= 2 && form.loginName.trim().length >= 3 && form.password.length > 0);
|
||||||
|
async function login() { if (!canSubmit.value || loading.value) return; loading.value = true; lastError.value = ''; try { const payload = await adminPasswordLogin({ tenantCode: form.tenantCode.trim(), loginName: form.loginName.trim(), ['password']: form.password }); localStorage.setItem('qipai.admin.tenantCode', form.tenantCode.trim()); form.password = ''; emit('authenticated', payload); } catch (error) { lastError.value = error instanceof ApiError ? loginMessage(error) : error instanceof Error ? error.message : '登录失败'; } finally { loading.value = false; } }
|
||||||
|
function loginMessage(error: ApiError) { if (error.code === 'ADMIN_LOGIN_LOCKED') return '登录失败次数过多,账号已暂时锁定,请 15 分钟后重试。'; if (error.code === 'ADMIN_LOGIN_INVALID') return '租户代码、登录账号或密码不正确。'; return `${error.code}${error.traceId ? ` · 追踪号 ${error.traceId}` : ''}`; }
|
||||||
|
</script>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<el-table-column label="门店范围" min-width="190"><template #default="{ row }">{{ storeScopeLabel(row.storeIds) }}</template></el-table-column>
|
<el-table-column label="门店范围" min-width="190"><template #default="{ row }">{{ storeScopeLabel(row.storeIds) }}</template></el-table-column>
|
||||||
<el-table-column label="微信 / 最近登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}</strong><span>{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}</span></div></template></el-table-column>
|
<el-table-column label="微信 / 最近登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}</strong><span>{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}</span></div></template></el-table-column>
|
||||||
<el-table-column prop="note" label="运营备注" min-width="160" />
|
<el-table-column prop="note" label="运营备注" min-width="160" />
|
||||||
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
|
<el-table-column label="操作" width="335" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :icon="KeyRound" @click="openCredential(row)">登录</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="staff.page" :page-size="staff.pageSize" :total="staff.total" @current-change="changeStaffPage" /></div>
|
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="staff.page" :page-size="staff.pageSize" :total="staff.total" @current-change="changeStaffPage" /></div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
@@ -72,14 +72,26 @@
|
|||||||
<el-form label-position="top" class="asset-form"><el-form-item label="姓名"><el-input v-model="staffForm.nickname" /></el-form-item><el-form-item :label="staffForm.userId ? '新手机号(留空保持原值)' : '手机号'"><el-input v-model="staffForm.phone" inputmode="tel" /></el-form-item><el-form-item label="角色" class="form-span-2"><el-checkbox-group v-model="staffForm.roles"><el-checkbox v-for="item in roleOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item><el-form-item label="门店授权" class="form-span-2"><el-select v-model="staffForm.storeIds" multiple filterable collapse-tags><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="运营备注" class="form-span-2"><el-input v-model="staffForm.note" type="textarea" :rows="3" /></el-form-item></el-form>
|
<el-form label-position="top" class="asset-form"><el-form-item label="姓名"><el-input v-model="staffForm.nickname" /></el-form-item><el-form-item :label="staffForm.userId ? '新手机号(留空保持原值)' : '手机号'"><el-input v-model="staffForm.phone" inputmode="tel" /></el-form-item><el-form-item label="角色" class="form-span-2"><el-checkbox-group v-model="staffForm.roles"><el-checkbox v-for="item in roleOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item><el-form-item label="门店授权" class="form-span-2"><el-select v-model="staffForm.storeIds" multiple filterable collapse-tags><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="运营备注" class="form-span-2"><el-input v-model="staffForm.note" type="textarea" :rows="3" /></el-form-item></el-form>
|
||||||
<template #footer><el-button @click="staffDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveStaff">保存</el-button></template>
|
<template #footer><el-button @click="staffDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveStaff">保存</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="credentialDialog" title="设置后台登录" width="min(480px, 94vw)">
|
||||||
|
<el-alert title="保存后将撤销该员工现有会话;密码不会在后台回显。" type="warning" show-icon :closable="false" />
|
||||||
|
<el-form label-position="top" class="credential-form">
|
||||||
|
<el-form-item label="员工"><el-input :model-value="credentialForm.nickname" disabled /></el-form-item>
|
||||||
|
<el-form-item label="登录账号"><el-input v-model="credentialForm.loginName" autocomplete="off" maxlength="64" placeholder="字母、数字、点、下划线或 @" /></el-form-item>
|
||||||
|
<el-form-item label="新密码"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item label="确认新密码"><el-input v-model="credentialForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
|
||||||
|
<small class="field-help">至少 12 位,同时包含字母、数字和特殊字符。</small>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">保存登录凭据</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { Eye, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
|
import { Eye, KeyRound, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
|
||||||
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, updateStaffUser, type ApiSession } from '../api';
|
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, setAdminCredential, updateStaffUser, type ApiSession } from '../api';
|
||||||
import { money, shortDate } from '../format';
|
import { money, shortDate } from '../format';
|
||||||
import type { ManagedStore, ManagedUser, MemberCard, MemberDetail, PageResult, StaffRole, UserStatus } from '../types';
|
import type { ManagedStore, ManagedUser, MemberCard, MemberDetail, PageResult, StaffRole, UserStatus } from '../types';
|
||||||
|
|
||||||
@@ -96,7 +108,9 @@ const staffFilters = reactive({ search: '', status: '' as UserStatus | '', role:
|
|||||||
const memberDrawer = ref(false);
|
const memberDrawer = ref(false);
|
||||||
const memberDetail = ref<MemberDetail | null>(null);
|
const memberDetail = ref<MemberDetail | null>(null);
|
||||||
const staffDialog = ref(false);
|
const staffDialog = ref(false);
|
||||||
|
const credentialDialog = ref(false);
|
||||||
const staffForm = reactive({ userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'] as StaffRole[], storeIds: [] as string[] });
|
const staffForm = reactive({ userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'] as StaffRole[], storeIds: [] as string[] });
|
||||||
|
const credentialForm = reactive({ userId: '', nickname: '', loginName: '', password: '', confirmPassword: '' });
|
||||||
const roleOptions: Array<{ value: StaffRole; label: string }> = [{ value: 'STAFF', label: '普通员工' }, { value: 'CLEANER', label: '保洁员' }, { value: 'STORE_ADMIN', label: '门店管理员' }, { value: 'TENANT_ADMIN', label: '租户管理员' }];
|
const roleOptions: Array<{ value: StaffRole; label: string }> = [{ value: 'STAFF', label: '普通员工' }, { value: 'CLEANER', label: '保洁员' }, { value: 'STORE_ADMIN', label: '门店管理员' }, { value: 'TENANT_ADMIN', label: '租户管理员' }];
|
||||||
const memberBalance = computed(() => members.items.reduce((sum, item) => sum + item.wallet.totalBalanceCents, 0));
|
const memberBalance = computed(() => members.items.reduce((sum, item) => sum + item.wallet.totalBalanceCents, 0));
|
||||||
const memberSpend = computed(() => members.items.reduce((sum, item) => sum + item.orders.paidAmountCents, 0));
|
const memberSpend = computed(() => members.items.reduce((sum, item) => sum + item.orders.paidAmountCents, 0));
|
||||||
@@ -113,6 +127,8 @@ function changeStaffPage(page: number) { staff.page = page; void loadStaff(); }
|
|||||||
async function openMember(memberId: string) { memberDrawer.value = true; memberDetail.value = null; try { memberDetail.value = await capture(() => getMember(props.session, memberId)); } catch { /* displayed */ } }
|
async function openMember(memberId: string) { memberDrawer.value = true; memberDetail.value = null; try { memberDetail.value = await capture(() => getMember(props.session, memberId)); } catch { /* displayed */ } }
|
||||||
function openCreate() { Object.assign(staffForm, { userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'], storeIds: [] }); staffDialog.value = true; }
|
function openCreate() { Object.assign(staffForm, { userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'], storeIds: [] }); staffDialog.value = true; }
|
||||||
function openEdit(row: ManagedUser) { Object.assign(staffForm, { userId: row.id, nickname: row.nickname, phone: '', note: row.note, roles: [...row.roles], storeIds: [...row.storeIds] }); staffDialog.value = true; }
|
function openEdit(row: ManagedUser) { Object.assign(staffForm, { userId: row.id, nickname: row.nickname, phone: '', note: row.note, roles: [...row.roles], storeIds: [...row.storeIds] }); staffDialog.value = true; }
|
||||||
|
function openCredential(row: ManagedUser) { Object.assign(credentialForm, { userId: row.id, nickname: row.nickname || `员工 #${row.id}`, loginName: '', password: '', confirmPassword: '' }); credentialDialog.value = true; }
|
||||||
|
async function saveCredential() { const valid = /^[\p{L}\p{N}._@+-]{3,64}$/u.test(credentialForm.loginName.trim()) && credentialForm.password.length >= 12 && /[A-Za-z]/.test(credentialForm.password) && /\d/.test(credentialForm.password) && /[^A-Za-z0-9]/.test(credentialForm.password); if (!valid) return ElMessage.warning('请填写有效登录账号和符合复杂度要求的密码'); if (credentialForm.password !== credentialForm.confirmPassword) return ElMessage.warning('两次输入的密码不一致'); saving.value = true; try { await capture(() => setAdminCredential(props.session, credentialForm.userId, { loginName: credentialForm.loginName.trim(), ['password']: credentialForm.password })); credentialDialog.value = false; ElMessage.success('后台登录凭据已更新,旧会话已撤销'); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
async function saveStaff() { if (!staffForm.nickname.trim() || (!staffForm.userId && !/^\+?[0-9]{6,20}$/.test(staffForm.phone.trim())) || staffForm.roles.length === 0) return ElMessage.warning('请填写姓名、有效手机号并至少选择一个角色'); saving.value = true; try { if (staffForm.userId) await capture(() => updateStaffUser(props.session, staffForm.userId, { nickname: staffForm.nickname.trim(), ...(staffForm.phone.trim() ? { phone: staffForm.phone.trim() } : {}), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); else await capture(() => createStaffUser(props.session, { nickname: staffForm.nickname.trim(), phone: staffForm.phone.trim(), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); staffDialog.value = false; ElMessage.success(staffForm.userId ? '员工资料已更新' : '员工账号已创建'); await loadStaff(); } catch { /* displayed */ } finally { saving.value = false; } }
|
async function saveStaff() { if (!staffForm.nickname.trim() || (!staffForm.userId && !/^\+?[0-9]{6,20}$/.test(staffForm.phone.trim())) || staffForm.roles.length === 0) return ElMessage.warning('请填写姓名、有效手机号并至少选择一个角色'); saving.value = true; try { if (staffForm.userId) await capture(() => updateStaffUser(props.session, staffForm.userId, { nickname: staffForm.nickname.trim(), ...(staffForm.phone.trim() ? { phone: staffForm.phone.trim() } : {}), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); else await capture(() => createStaffUser(props.session, { nickname: staffForm.nickname.trim(), phone: staffForm.phone.trim(), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); staffDialog.value = false; ElMessage.success(staffForm.userId ? '员工资料已更新' : '员工账号已创建'); await loadStaff(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
async function toggleStaff(row: ManagedUser) { const status: UserStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await ElMessageBox.confirm(`${status === 'DISABLED' ? '停用' : '启用'} ${row.nickname || `员工 #${row.id}`}?权限变更会撤销活动会话。`, '确认账号状态', { type: 'warning' }); await capture(() => updateStaffUser(props.session, row.id, { status })); ElMessage.success('员工状态已更新'); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
async function toggleStaff(row: ManagedUser) { const status: UserStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await ElMessageBox.confirm(`${status === 'DISABLED' ? '停用' : '启用'} ${row.nickname || `员工 #${row.id}`}?权限变更会撤销活动会话。`, '确认账号状态', { type: 'warning' }); await capture(() => updateStaffUser(props.session, row.id, { status })); ElMessage.success('员工状态已更新'); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||||
async function resetSessions(row: ManagedUser) { try { await ElMessageBox.confirm(`重置 ${row.nickname || `员工 #${row.id}`} 的全部活动会话?`, '确认重置会话', { type: 'warning' }); const result = await capture(() => resetStaffSessions(props.session, row.id)); ElMessage.success(`已撤销 ${result.revokedSessions} 个会话`); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
async function resetSessions(row: ManagedUser) { try { await ElMessageBox.confirm(`重置 ${row.nickname || `员工 #${row.id}`} 的全部活动会话?`, '确认重置会话', { type: 'warning' }); const result = await capture(() => resetStaffSessions(props.session, row.id)); ElMessage.success(`已撤销 ${result.revokedSessions} 个会话`); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||||
|
|||||||
+177
-4
@@ -6,6 +6,120 @@
|
|||||||
"Segoe UI", sans-serif;
|
"Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 1fr) minmax(420px, .85fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-story {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: clamp(32px, 6vw, 84px);
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 82% 18%, rgba(73, 123, 180, .28), transparent 32%),
|
||||||
|
linear-gradient(145deg, #101a2b, #173454 62%, #1e4a70);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand span {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #e9f2ff;
|
||||||
|
color: #152033;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-story h1 {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 14px 0 18px;
|
||||||
|
font-size: clamp(36px, 5vw, 68px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-story > div > p:last-child {
|
||||||
|
max-width: 620px;
|
||||||
|
color: #c6d5e5;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-story ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-story li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #dce8f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-wrap {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: min(100%, 440px);
|
||||||
|
padding: clamp(28px, 5vw, 48px);
|
||||||
|
border: 1px solid #dce4ee;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 70px rgba(30, 51, 78, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card header h2 {
|
||||||
|
margin: 6px 0 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card header > p:last-child,
|
||||||
|
.login-card footer {
|
||||||
|
color: #6b788c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .el-alert {
|
||||||
|
margin: 20px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .el-form {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .el-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 22px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.content-page {
|
.content-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -564,11 +678,40 @@ textarea {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-box {
|
.session-box,
|
||||||
|
.session-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-user > span {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 360px) 40px;
|
width: 38px;
|
||||||
gap: 8px;
|
height: 38px;
|
||||||
width: min(100%, 420px);
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e8f1ff;
|
||||||
|
color: #1b416d;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-user > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-user small {
|
||||||
|
max-width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #6b788c;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-form {
|
||||||
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
@@ -2152,6 +2295,14 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.login-shell {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-story {
|
||||||
|
padding: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -2263,6 +2414,28 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.login-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-story {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
padding: 26px 22px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-user > div {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,6 +575,30 @@ export interface SystemOverview {
|
|||||||
latestMigration: null | { version: string; name: string; appliedAt: string };
|
latestMigration: null | { version: string; name: string; appliedAt: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminIdentity {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userType: string;
|
||||||
|
nickname: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
roleVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAccess {
|
||||||
|
roles: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
storeIds: string[];
|
||||||
|
menus: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSessionPayload {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
user: AdminIdentity;
|
||||||
|
access: AdminAccess;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CleaningTask {
|
export interface CleaningTask {
|
||||||
id: string;
|
id: string;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
|
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
registerSystemOperationsRoutes,
|
registerSystemOperationsRoutes,
|
||||||
type SystemOperationsRouteOptions
|
type SystemOperationsRouteOptions
|
||||||
} from './routes/system-operations.js';
|
} from './routes/system-operations.js';
|
||||||
|
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -88,6 +89,7 @@ export interface BuildAppOptions {
|
|||||||
businessStatistics?: BusinessStatisticsRouteOptions;
|
businessStatistics?: BusinessStatisticsRouteOptions;
|
||||||
franchise?: FranchiseRouteOptions;
|
franchise?: FranchiseRouteOptions;
|
||||||
systemOperations?: SystemOperationsRouteOptions;
|
systemOperations?: SystemOperationsRouteOptions;
|
||||||
|
adminAuth?: AdminAuthRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -201,6 +203,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.systemOperations) {
|
if (options.systemOperations) {
|
||||||
await registerSystemOperationsRoutes(app, options.systemOperations);
|
await registerSystemOperationsRoutes(app, options.systemOperations);
|
||||||
}
|
}
|
||||||
|
if (options.adminAuth) {
|
||||||
|
await registerAdminAuthRoutes(app, options.adminAuth);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
import type { ManagementActor } from './user-management-repository.js';
|
||||||
|
import { verifyPassword } from './password.js';
|
||||||
|
import type { AuthSession, AuthUser } from './auth-repository.js';
|
||||||
|
|
||||||
|
interface CredentialRow extends RowDataPacket {
|
||||||
|
credentialId: string; tenantId: string; platformAppId: string; passwordHash: string;
|
||||||
|
credentialStatus: string; failedAttempts: number; lockedUntil: Date | null;
|
||||||
|
id: string; userType: string; status: string; roleVersion: number;
|
||||||
|
nickname: string; avatarUrl: string; phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshRow extends RowDataPacket {
|
||||||
|
sessionId: string; tenantId: string; platformAppId: string; refreshTokenHash: string;
|
||||||
|
refreshExpiresAt: Date; id: string; userType: string; status: string; roleVersion: number;
|
||||||
|
nickname: string; avatarUrl: string; phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminAuthError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminAuthRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async loginWithPassword(input: {
|
||||||
|
tenantCode: string; loginName: string; password: string; sessionId: string;
|
||||||
|
refreshTokenHash: string; expiresAt: Date; ip: string; userAgent: string; traceId: string;
|
||||||
|
}): Promise<AuthSession> {
|
||||||
|
const loginName = normalizeLoginName(input.loginName);
|
||||||
|
const [rows] = await this.pool.execute<CredentialRow[]>(
|
||||||
|
`SELECT c.id AS credentialId, c.tenant_id AS tenantId,
|
||||||
|
ta.platform_app_id AS platformAppId, c.password_hash AS passwordHash,
|
||||||
|
c.status AS credentialStatus, c.failed_attempts AS failedAttempts,
|
||||||
|
c.locked_until AS lockedUntil, u.id, u.user_type AS userType, u.status,
|
||||||
|
u.role_version AS roleVersion, u.nickname, u.avatar_url AS avatarUrl, u.phone
|
||||||
|
FROM qipai_admin_credentials c
|
||||||
|
INNER JOIN qipai_tenants t ON t.id = c.tenant_id AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
|
||||||
|
INNER JOIN qipai_users u ON u.id = c.user_id AND u.tenant_id = c.tenant_id
|
||||||
|
AND u.user_type = 'STAFF' AND u.status = 'ACTIVE' AND u.deleted_at IS NULL
|
||||||
|
INNER JOIN qipai_tenant_apps ta ON ta.tenant_id = c.tenant_id
|
||||||
|
AND ta.status = 'ACTIVE' AND ta.deleted_at IS NULL
|
||||||
|
WHERE t.code = ? AND c.login_name = ? AND c.deleted_at IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM qipai_user_roles ur
|
||||||
|
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||||
|
AND r.status = 'ACTIVE' AND r.code IN ('STAFF', 'CLEANER', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
|
WHERE ur.tenant_id = c.tenant_id AND ur.user_id = c.user_id)
|
||||||
|
ORDER BY ta.is_default DESC, ta.id ASC LIMIT 1`,
|
||||||
|
[input.tenantCode.trim(), loginName]
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
const validPassword = await verifyPassword(input.password, row?.passwordHash);
|
||||||
|
if (!row || row.credentialStatus !== 'ACTIVE' || !validPassword) {
|
||||||
|
if (row) await this.recordFailure(row, loginName, input);
|
||||||
|
throw new AdminAuthError('ADMIN_LOGIN_INVALID');
|
||||||
|
}
|
||||||
|
if (row.lockedUntil && row.lockedUntil.getTime() > Date.now()) {
|
||||||
|
throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
|
||||||
|
}
|
||||||
|
const user = mapUser(row);
|
||||||
|
await this.transaction(async (connection) => {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_admin_credentials SET failed_attempts = 0, locked_until = NULL,
|
||||||
|
last_login_at = UTC_TIMESTAMP(3) WHERE id = ?`, [row.credentialId]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_auth_sessions
|
||||||
|
(id, tenant_id, platform_app_id, user_id, role_version, refresh_token_hash,
|
||||||
|
expires_at, refresh_expires_at, ip, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[input.sessionId, row.tenantId, row.platformAppId, user.id, user.roleVersion,
|
||||||
|
input.refreshTokenHash, input.expiresAt, input.expiresAt, input.ip,
|
||||||
|
input.userAgent.slice(0, 255)]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
'UPDATE qipai_users SET last_login_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ?',
|
||||||
|
[row.tenantId, user.id]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, 'USER', ?, 'ADMIN_LOGIN_SUCCEEDED', 'USER', ?, ?, ?, ?, '{}')`,
|
||||||
|
[row.tenantId, user.id, user.id, input.traceId, input.ip, input.userAgent.slice(0, 255)]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { id: input.sessionId, tenantId: String(row.tenantId),
|
||||||
|
platformAppId: String(row.platformAppId), user, expiresAt: input.expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateRefreshToken(input: {
|
||||||
|
sessionId: string; currentHash: string; nextHash: string; ip: string; userAgent: string;
|
||||||
|
}): Promise<AuthSession> {
|
||||||
|
const [rows] = await this.pool.execute<RefreshRow[]>(
|
||||||
|
`SELECT s.id AS sessionId, s.tenant_id AS tenantId, s.platform_app_id AS platformAppId,
|
||||||
|
s.refresh_token_hash AS refreshTokenHash, s.refresh_expires_at AS refreshExpiresAt,
|
||||||
|
u.id, u.user_type AS userType, u.status, u.role_version AS roleVersion,
|
||||||
|
u.nickname, u.avatar_url AS avatarUrl, u.phone
|
||||||
|
FROM qipai_auth_sessions s INNER JOIN qipai_users u
|
||||||
|
ON u.id = s.user_id AND u.tenant_id = s.tenant_id AND u.deleted_at IS NULL
|
||||||
|
WHERE s.id = ? AND s.status = 'ACTIVE' AND s.revoked_at IS NULL
|
||||||
|
AND s.refresh_token_hash IS NOT NULL AND s.refresh_expires_at > UTC_TIMESTAMP(3)
|
||||||
|
AND u.status = 'ACTIVE' AND u.role_version = s.role_version LIMIT 1`, [input.sessionId]
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row || !safeHashEqual(row.refreshTokenHash, input.currentHash)) {
|
||||||
|
throw new AdminAuthError('ADMIN_REFRESH_INVALID');
|
||||||
|
}
|
||||||
|
const [updated] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`UPDATE qipai_auth_sessions SET refresh_token_hash = ?, last_seen_at = UTC_TIMESTAMP(3),
|
||||||
|
ip = ?, user_agent = ? WHERE id = ? AND refresh_token_hash = ? AND status = 'ACTIVE'`,
|
||||||
|
[input.nextHash, input.ip, input.userAgent.slice(0, 255), input.sessionId, input.currentHash]
|
||||||
|
);
|
||||||
|
if (updated.affectedRows !== 1) throw new AdminAuthError('ADMIN_REFRESH_REUSED');
|
||||||
|
return { id: row.sessionId, tenantId: String(row.tenantId),
|
||||||
|
platformAppId: String(row.platformAppId), user: mapUser(row), expiresAt: row.refreshExpiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCredential(actor: ManagementActor, tenantId: string, input: {
|
||||||
|
userId: string; loginName: string; passwordHash: string;
|
||||||
|
}) {
|
||||||
|
const loginName = normalizeLoginName(input.loginName);
|
||||||
|
try {
|
||||||
|
return await this.transaction(async (connection) => {
|
||||||
|
const [users] = await connection.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_users WHERE id = ? AND tenant_id = ? AND user_type = 'STAFF'
|
||||||
|
AND status = 'ACTIVE' AND deleted_at IS NULL FOR UPDATE`, [input.userId, tenantId]
|
||||||
|
);
|
||||||
|
if (!users[0]) throw new AdminAuthError('ADMIN_CREDENTIAL_USER_INVALID');
|
||||||
|
const [conflicts] = await connection.execute<RowDataPacket[]>(
|
||||||
|
`SELECT user_id AS userId FROM qipai_admin_credentials
|
||||||
|
WHERE tenant_id = ? AND login_name = ? AND user_id <> ? AND deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[tenantId, loginName, input.userId]
|
||||||
|
);
|
||||||
|
if (conflicts[0]) throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
|
||||||
|
const [current] = await connection.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_admin_credentials
|
||||||
|
WHERE tenant_id = ? AND user_id = ? FOR UPDATE`, [tenantId, input.userId]
|
||||||
|
);
|
||||||
|
if (current[0]) {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_admin_credentials SET login_name = ?, password_hash = ?, status = 'ACTIVE',
|
||||||
|
failed_attempts = 0, locked_until = NULL, deleted_at = NULL,
|
||||||
|
password_changed_at = UTC_TIMESTAMP(3) WHERE id = ?`,
|
||||||
|
[loginName, input.passwordHash, current[0].id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_admin_credentials
|
||||||
|
(tenant_id, user_id, login_name, password_hash) VALUES (?, ?, ?, ?)`,
|
||||||
|
[tenantId, input.userId, loginName, input.passwordHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
|
||||||
|
revoke_reason = 'PASSWORD_CHANGED' WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
|
||||||
|
[tenantId, input.userId]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, 'USER', ?, 'ADMIN_CREDENTIAL_UPDATED', 'USER', ?, ?, ?, ?, ?)`,
|
||||||
|
[tenantId, actor.userId, input.userId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
|
||||||
|
JSON.stringify({ loginNameHash: hashToken(loginName) })]
|
||||||
|
);
|
||||||
|
return { userId: input.userId, configured: true };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code === 'ER_DUP_ENTRY') {
|
||||||
|
throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordFailure(row: CredentialRow, loginName: string, input: {
|
||||||
|
traceId: string; ip: string; userAgent: string;
|
||||||
|
}) {
|
||||||
|
await this.pool.execute(
|
||||||
|
`UPDATE qipai_admin_credentials SET failed_attempts = failed_attempts + 1,
|
||||||
|
locked_until = CASE WHEN failed_attempts + 1 >= 5
|
||||||
|
THEN DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE) ELSE locked_until END
|
||||||
|
WHERE id = ?`, [row.credentialId]
|
||||||
|
);
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, 'SYSTEM', NULL, 'ADMIN_LOGIN_FAILED', 'ADMIN_CREDENTIAL', ?, ?, ?, ?, ?)`,
|
||||||
|
[row.tenantId, row.credentialId, input.traceId, input.ip, input.userAgent.slice(0, 255),
|
||||||
|
JSON.stringify({ loginNameHash: hashToken(loginName) })]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||||
|
const connection = await this.pool.getConnection();
|
||||||
|
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||||
|
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLoginName(value: string) { return value.normalize('NFKC').trim().toLowerCase(); }
|
||||||
|
export function hashToken(value: string) { return createHash('sha256').update(value).digest('hex'); }
|
||||||
|
function safeHashEqual(left: string, right: string) {
|
||||||
|
const a = Buffer.from(left); const b = Buffer.from(right);
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
function mapUser(row: { id: string; tenantId: string; userType: string; status: string;
|
||||||
|
roleVersion: number; nickname: string; avatarUrl: string; phone: string }): AuthUser {
|
||||||
|
return { id: String(row.id), tenantId: String(row.tenantId), userType: row.userType,
|
||||||
|
status: row.status, roleVersion: row.roleVersion, nickname: row.nickname,
|
||||||
|
avatarUrl: row.avatarUrl, phone: row.phone };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const algorithm = 'scrypt';
|
||||||
|
const cost = 16384;
|
||||||
|
const blockSize = 8;
|
||||||
|
const parallelization = 1;
|
||||||
|
const keyLength = 64;
|
||||||
|
const maxmem = 64 * 1024 * 1024;
|
||||||
|
const dummyHash = 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA$4fXWGR0BUXE6uC3v9sPYUjV_QlLQaq9EOrlgkxMVXngIWJ2unUaOv9OnZ47Z0RFKmK0LLBeMdNNB6W1RgPBplA';
|
||||||
|
|
||||||
|
export async function hashPassword(plainTextPassword: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const derived = await derive(plainTextPassword, salt, cost, blockSize, parallelization);
|
||||||
|
return [algorithm, cost, blockSize, parallelization,
|
||||||
|
salt.toString('base64url'), derived.toString('base64url')].join('$');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(plainTextPassword: string, encoded = dummyHash): Promise<boolean> {
|
||||||
|
const parts = encoded.split('$');
|
||||||
|
if (parts.length !== 6 || parts[0] !== algorithm) {
|
||||||
|
await verifyPassword(plainTextPassword, dummyHash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [, nValue, rValue, pValue, saltValue, hashValue] = parts;
|
||||||
|
const n = Number(nValue); const r = Number(rValue); const p = Number(pValue);
|
||||||
|
if (n !== cost || r !== blockSize || p !== parallelization) {
|
||||||
|
await verifyPassword(plainTextPassword, dummyHash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const salt = Buffer.from(saltValue, 'base64url');
|
||||||
|
const expected = Buffer.from(hashValue, 'base64url');
|
||||||
|
if (salt.length !== 16 || expected.length !== keyLength) {
|
||||||
|
await verifyPassword(plainTextPassword, dummyHash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const actual = await derive(plainTextPassword, salt, n, r, p);
|
||||||
|
return timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function derive(plainTextPassword: string, salt: Buffer, N: number, r: number, p: number): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
scrypt(plainTextPassword, salt, keyLength, { N, r, p, maxmem }, (error, derivedKey) => {
|
||||||
|
if (error) reject(error); else resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -52,7 +52,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
|
||||||
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
|
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
|
||||||
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
||||||
'database/migrations/2026081003_m08d_franchise_leads.up.sql'
|
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
||||||
|
'database/migrations/2026081004_m08d_admin_password_auth.up.sql'
|
||||||
],
|
],
|
||||||
verify: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -86,9 +87,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
|
||||||
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
|
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
|
||||||
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
||||||
'database/migrations/2026081003_m08d_franchise_leads.verify.sql'
|
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
||||||
|
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql'
|
||||||
],
|
],
|
||||||
down: [
|
down: [
|
||||||
|
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
||||||
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||||
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
||||||
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
||||||
@@ -267,6 +270,7 @@ export async function executeMigrationPlan(
|
|||||||
4, 1, 1, 1,
|
4, 1, 1, 1,
|
||||||
2, 1, 1,
|
2, 1, 1,
|
||||||
1, 1, 1,
|
1, 1, 1,
|
||||||
|
1, 2, 3, 1,
|
||||||
1, 2, 3, 1
|
1, 2, 3, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import { AdminAuthError, hashToken, type AdminAuthRepository } from '../auth/admin-auth-repository.js';
|
||||||
|
import { signAccessToken } from '../auth/jwt.js';
|
||||||
|
import { hashPassword } from '../auth/password.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const loginName = z.string().trim().min(3).max(64).regex(/^[\p{L}\p{N}._@+-]+$/u);
|
||||||
|
const strongPassword = z.string().min(12).max(128)
|
||||||
|
.refine((value) => /[A-Za-z]/.test(value) && /\d/.test(value) && /[^A-Za-z0-9]/.test(value));
|
||||||
|
const loginSchema = z.object({ tenantCode: z.string().trim().min(2).max(64), loginName,
|
||||||
|
['password']: z.string().min(1).max(128) }).strict();
|
||||||
|
const refreshSchema = z.object({ refreshToken: z.string().min(60).max(160) }).strict();
|
||||||
|
const credentialSchema = z.object({ tenantId: id.optional(), loginName, ['password']: strongPassword }).strict();
|
||||||
|
|
||||||
|
export interface AdminAuthRouteOptions {
|
||||||
|
repository: Pick<AdminAuthRepository, 'loginWithPassword' | 'rotateRefreshToken' | 'setCredential'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession' | 'revokeSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
accessTokenTtlSeconds: number;
|
||||||
|
sessionTtlSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAdminAuthRoutes(app: FastifyInstance, options: AdminAuthRouteOptions) {
|
||||||
|
app.post('/admin-api/auth/login', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = loginSchema.safeParse(request.body);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
const sessionId = randomUUID(); const refreshToken = createRefreshToken(sessionId);
|
||||||
|
try {
|
||||||
|
const session = await options.repository.loginWithPassword({ ...body.data, sessionId,
|
||||||
|
refreshTokenHash: hashToken(refreshToken),
|
||||||
|
expiresAt: new Date(Date.now() + options.sessionTtlSeconds * 1000),
|
||||||
|
ip: request.ip, userAgent: request.headers['user-agent'] ?? '', traceId: request.traceId });
|
||||||
|
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
|
||||||
|
return { code: 0, data: sessionResponse(session, access, refreshToken, options), traceId: request.traceId };
|
||||||
|
} catch (error) { return authError(reply, request.traceId, error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/auth/refresh', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = refreshSchema.safeParse(request.body);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
const sessionId = body.data.refreshToken.split('.', 1)[0];
|
||||||
|
if (!z.string().uuid().safeParse(sessionId).success) return invalid(reply, request.traceId);
|
||||||
|
const nextRefreshToken = createRefreshToken(sessionId);
|
||||||
|
try {
|
||||||
|
const session = await options.repository.rotateRefreshToken({ sessionId,
|
||||||
|
currentHash: hashToken(body.data.refreshToken), nextHash: hashToken(nextRefreshToken),
|
||||||
|
ip: request.ip, userAgent: request.headers['user-agent'] ?? '' });
|
||||||
|
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
|
||||||
|
return { code: 0, data: sessionResponse(session, access, nextRefreshToken, options), traceId: request.traceId };
|
||||||
|
} catch (error) { return authError(reply, request.traceId, error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/auth/me', async (request, reply) => {
|
||||||
|
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||||
|
return { code: 0, data: { user: safeUser(auth.session.user), access: adminAccess(access) }, traceId: request.traceId };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/auth/logout', async (request, reply) => {
|
||||||
|
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
await options.authRepository.revokeSession(auth.sessionId, 'ADMIN_LOGOUT');
|
||||||
|
return { code: 0, data: { revoked: true }, traceId: request.traceId };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/auth/credentials/:userId', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const params = z.object({ userId: id }).safeParse(request.params);
|
||||||
|
const body = credentialSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const tenantId = resolveTenant(actor, body.data.tenantId);
|
||||||
|
if (!tenantId) return reply.status(403).send({ code: 'ADMIN_AUTH_TENANT_FORBIDDEN',
|
||||||
|
message: 'The tenant is outside the allowed scope.', traceId: request.traceId });
|
||||||
|
try {
|
||||||
|
const passwordHash = await hashPassword(body.data.password);
|
||||||
|
return { code: 0, data: await options.repository.setCredential(actor, tenantId,
|
||||||
|
{ userId: params.data.userId, loginName: body.data.loginName, passwordHash }), traceId: request.traceId };
|
||||||
|
} catch (error) { return authError(reply, request.traceId, error); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: AdminAuthRouteOptions) {
|
||||||
|
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||||
|
if (!auth) { unauthorized(reply, request.traceId); return null; }
|
||||||
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||||
|
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
|
||||||
|
reply.status(403).send({ code: 'ADMIN_CREDENTIAL_FORBIDDEN',
|
||||||
|
message: 'Tenant management permission is required.', traceId: request.traceId }); return null;
|
||||||
|
}
|
||||||
|
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||||
|
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
|
||||||
|
}
|
||||||
|
function createRefreshToken(sessionId: string) { return `${sessionId}.${randomBytes(32).toString('base64url')}`; }
|
||||||
|
function sessionResponse(session: { id: string; tenantId: string; platformAppId: string; user: Parameters<typeof safeUser>[0] },
|
||||||
|
access: AccessProfile, refreshToken: string, options: AdminAuthRouteOptions) {
|
||||||
|
return { accessToken: signAccessToken({ sub: session.user.id, sid: session.id, tid: session.tenantId,
|
||||||
|
aid: session.platformAppId, rv: session.user.roleVersion }, options.jwtSecret, options.accessTokenTtlSeconds),
|
||||||
|
refreshToken, expiresIn: options.accessTokenTtlSeconds, user: safeUser(session.user),
|
||||||
|
access: adminAccess(access) };
|
||||||
|
}
|
||||||
|
function adminAccess(access: AccessProfile) {
|
||||||
|
const tenant = access.capabilities.includes('tenant.manage') || isPlatform(access);
|
||||||
|
const storeRead = tenant || access.capabilities.includes('store.operation.read');
|
||||||
|
const menus = [
|
||||||
|
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
|
||||||
|
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
|
||||||
|
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
|
||||||
|
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
|
||||||
|
];
|
||||||
|
return { ...access, menus: [...new Set(menus)] };
|
||||||
|
}
|
||||||
|
function safeUser(user: { id: string; tenantId: string; userType: string; nickname: string; avatarUrl: string; roleVersion: number }) {
|
||||||
|
return { id: user.id, tenantId: user.tenantId, userType: user.userType,
|
||||||
|
nickname: user.nickname, avatarUrl: user.avatarUrl, roleVersion: user.roleVersion };
|
||||||
|
}
|
||||||
|
function resolveTenant(actor: ManagementActor, requested?: string) {
|
||||||
|
if (!requested || requested === actor.tenantId) return actor.tenantId;
|
||||||
|
return isPlatform(actor.access) ? requested : null;
|
||||||
|
}
|
||||||
|
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({
|
||||||
|
code: 'INVALID_ADMIN_AUTH_REQUEST', message: 'The admin authentication request is invalid.', traceId }); }
|
||||||
|
function unauthorized(reply: FastifyReply, traceId: string) { return reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'The admin session is invalid or expired.', traceId }); }
|
||||||
|
function authError(reply: FastifyReply, traceId: string, error: unknown) {
|
||||||
|
if (!(error instanceof AdminAuthError)) throw error;
|
||||||
|
const status = error.code === 'ADMIN_LOGIN_LOCKED' ? 423
|
||||||
|
: error.code.includes('CONFLICT') ? 409
|
||||||
|
: error.code.includes('USER_INVALID') ? 404 : 401;
|
||||||
|
return reply.status(status).send({ code: error.code,
|
||||||
|
message: 'The admin authentication request was rejected.', traceId });
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
|||||||
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
||||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||||
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
|
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
|
||||||
|
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
@@ -232,6 +233,14 @@ const app = await buildApp({
|
|||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
},
|
||||||
|
adminAuth: {
|
||||||
|
repository: new AdminAuthRepository(pool),
|
||||||
|
authRepository,
|
||||||
|
accessControl,
|
||||||
|
jwtSecret: config.auth.jwtSecret,
|
||||||
|
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
|
||||||
|
sessionTtlSeconds: config.auth.sessionTtlSeconds
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildApp } from '../dist/app.js';
|
||||||
|
import { AdminAuthError } from '../dist/auth/admin-auth-repository.js';
|
||||||
|
import { hashPassword, verifyPassword } from '../dist/auth/password.js';
|
||||||
|
|
||||||
|
const secret = 'test-only-admin-password-auth-secret-32';
|
||||||
|
const user = { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
|
||||||
|
nickname: '租户管理员', avatarUrl: '', phone: '13800138000' };
|
||||||
|
let loginInput; let refreshInput; let credentialInput; let revokedSessionId;
|
||||||
|
const repository = {
|
||||||
|
async loginWithPassword(input) {
|
||||||
|
loginInput = input;
|
||||||
|
if (input.password === 'wrong-password') throw new AdminAuthError('ADMIN_LOGIN_INVALID');
|
||||||
|
if (input.password === 'locked-password') throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
|
||||||
|
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user, expiresAt: input.expiresAt };
|
||||||
|
},
|
||||||
|
async rotateRefreshToken(input) {
|
||||||
|
refreshInput = input;
|
||||||
|
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user,
|
||||||
|
expiresAt: new Date(Date.now() + 60000) };
|
||||||
|
},
|
||||||
|
async setCredential(actor, tenantId, input) {
|
||||||
|
credentialInput = { actor, tenantId, input };
|
||||||
|
return { userId: input.userId, configured: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const authRepository = {
|
||||||
|
async validateSession(sessionId) {
|
||||||
|
return { id: sessionId, tenantId: '7', platformAppId: '9', user,
|
||||||
|
expiresAt: new Date(Date.now() + 60000) };
|
||||||
|
},
|
||||||
|
async revokeSession(sessionId) { revokedSessionId = sessionId; return true; }
|
||||||
|
};
|
||||||
|
const access = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
|
||||||
|
const app = await buildApp({ adminAuth: { repository, authRepository,
|
||||||
|
accessControl: { async getAccessProfile() { return access; } }, jwtSecret: secret,
|
||||||
|
accessTokenTtlSeconds: 900, sessionTtlSeconds: 604800 } });
|
||||||
|
|
||||||
|
const login = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
|
||||||
|
payload: { tenantCode: 'demo', loginName: 'Admin.User', ['password']: 'ValidPassword!123' } });
|
||||||
|
assert.equal(login.statusCode, 200);
|
||||||
|
assert.equal(loginInput.tenantCode, 'demo');
|
||||||
|
assert.equal(login.json().data.user.phone, undefined);
|
||||||
|
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']);
|
||||||
|
assert.ok(login.json().data.access.menus.includes('system'));
|
||||||
|
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
|
||||||
|
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
|
||||||
|
const successfulSessionId = loginInput.sessionId;
|
||||||
|
|
||||||
|
const bad = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
|
||||||
|
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'wrong-password' } });
|
||||||
|
assert.equal(bad.statusCode, 401);
|
||||||
|
const locked = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
|
||||||
|
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'locked-password' } });
|
||||||
|
assert.equal(locked.statusCode, 423);
|
||||||
|
|
||||||
|
const refreshed = await app.inject({ method: 'POST', url: '/admin-api/auth/refresh',
|
||||||
|
payload: { refreshToken: login.json().data.refreshToken } });
|
||||||
|
assert.equal(refreshed.statusCode, 200);
|
||||||
|
assert.equal(refreshInput.sessionId, successfulSessionId);
|
||||||
|
assert.notEqual(refreshed.json().data.refreshToken, login.json().data.refreshToken);
|
||||||
|
|
||||||
|
const auth = { authorization: `Bearer ${login.json().data.accessToken}` };
|
||||||
|
const me = await app.inject({ method: 'GET', url: '/admin-api/auth/me', headers: auth });
|
||||||
|
assert.equal(me.statusCode, 200);
|
||||||
|
assert.equal(me.json().data.user.nickname, '租户管理员');
|
||||||
|
const weakCredential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
|
||||||
|
headers: auth, payload: { loginName: 'operator', ['password']: 'too-weak' } });
|
||||||
|
assert.equal(weakCredential.statusCode, 400);
|
||||||
|
const credential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
|
||||||
|
headers: auth, payload: { loginName: 'Operator.22', ['password']: 'StrongPassword!2026' } });
|
||||||
|
assert.equal(credential.statusCode, 200);
|
||||||
|
assert.equal(credentialInput.tenantId, '7');
|
||||||
|
assert.equal(credentialInput.input.loginName, 'Operator.22');
|
||||||
|
assert.match(credentialInput.input.passwordHash, /^scrypt\$16384\$8\$1\$/);
|
||||||
|
|
||||||
|
const logout = await app.inject({ method: 'POST', url: '/admin-api/auth/logout', headers: auth });
|
||||||
|
assert.equal(logout.statusCode, 200);
|
||||||
|
assert.equal(revokedSessionId, successfulSessionId);
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword('StrongPassword!2026');
|
||||||
|
assert.equal(await verifyPassword('StrongPassword!2026', passwordHash), true);
|
||||||
|
assert.equal(await verifyPassword('WrongPassword!2026', passwordHash), false);
|
||||||
|
assert.equal(await verifyPassword('anything', 'invalid-hash'), false);
|
||||||
|
|
||||||
|
console.log('PASS: M08-D admin password login, rotating refresh, session cleanup and credential setup are present.');
|
||||||
@@ -105,6 +105,9 @@ const contentAssetScopeVerifySql = read('database/migrations/2026081002_m08d_con
|
|||||||
const franchiseUpSql = read('database/migrations/2026081003_m08d_franchise_leads.up.sql');
|
const franchiseUpSql = read('database/migrations/2026081003_m08d_franchise_leads.up.sql');
|
||||||
const franchiseDownSql = read('database/migrations/2026081003_m08d_franchise_leads.down.sql');
|
const franchiseDownSql = read('database/migrations/2026081003_m08d_franchise_leads.down.sql');
|
||||||
const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_leads.verify.sql');
|
const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_leads.verify.sql');
|
||||||
|
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
|
||||||
|
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
|
||||||
|
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
|
||||||
|
|
||||||
const coreTables = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -480,6 +483,12 @@ assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_applica
|
|||||||
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_follow_ups/);
|
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_follow_ups/);
|
||||||
assert.match(franchiseUpSql, /uq_qipai_franchise_client_request/);
|
assert.match(franchiseUpSql, /uq_qipai_franchise_client_request/);
|
||||||
assert.match(franchiseUpSql, /'2026081003'/);
|
assert.match(franchiseUpSql, /'2026081003'/);
|
||||||
|
assert.match(adminAuthUpSql, /CREATE TABLE IF NOT EXISTS qipai_admin_credentials/);
|
||||||
|
assert.match(adminAuthUpSql, /refresh_token_hash CHAR\(64\)/);
|
||||||
|
assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
|
||||||
|
assert.match(adminAuthUpSql, /'2026081004'/);
|
||||||
|
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
|
||||||
|
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
|
||||||
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
||||||
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
||||||
|
|
||||||
|
|||||||
@@ -42,14 +42,16 @@ assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
|
|||||||
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
|
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
|
||||||
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
||||||
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
||||||
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql$/);
|
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||||
|
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
|
||||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
const verifyPlan = await loadMigrationPlan('verify');
|
const verifyPlan = await loadMigrationPlan('verify');
|
||||||
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||||
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql$/);
|
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
||||||
|
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
|
||||||
|
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fakePool = {
|
const fakePool = {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import { DeviceControlService } from '../dist/devices/device-control-service.js'
|
|||||||
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
||||||
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
|
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
|
||||||
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
|
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
|
||||||
|
import { AdminAuthRepository, AdminAuthError, hashToken } from '../dist/auth/admin-auth-repository.js';
|
||||||
|
import { hashPassword } from '../dist/auth/password.js';
|
||||||
import {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
loadMigrationPlan,
|
loadMigrationPlan,
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
} from '../dist/db/migration-runner.js';
|
} from '../dist/db/migration-runner.js';
|
||||||
|
|
||||||
const expectedTables = [
|
const expectedTables = [
|
||||||
|
'qipai_admin_credentials',
|
||||||
'qipai_advertisements',
|
'qipai_advertisements',
|
||||||
'qipai_async_tasks',
|
'qipai_async_tasks',
|
||||||
'qipai_audit_logs',
|
'qipai_audit_logs',
|
||||||
@@ -133,13 +136,13 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||||
'2026062220', '2026081002', '2026081003']
|
'2026062220', '2026081002', '2026081003', '2026081004']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1827,7 +1830,7 @@ async function assertSystemOperations(pool, context) {
|
|||||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||||
const overview = await repository.getSystemOverview(context.tenantId);
|
const overview = await repository.getSystemOverview(context.tenantId);
|
||||||
assert.equal(overview.tenant.id, context.tenantId);
|
assert.equal(overview.tenant.id, context.tenantId);
|
||||||
assert.equal(overview.latestMigration.version, '2026081003');
|
assert.equal(overview.latestMigration.version, '2026081004');
|
||||||
assert.ok(overview.counts.userCount > 0);
|
assert.ok(overview.counts.userCount > 0);
|
||||||
await repository.updateTenant(actor, context.tenantId, {
|
await repository.updateTenant(actor, context.tenantId, {
|
||||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||||
@@ -1839,6 +1842,59 @@ async function assertSystemOperations(pool, context) {
|
|||||||
assert.equal(updatedLogs.items[0].actorId, adminId);
|
assert.equal(updatedLogs.items[0].actorId, adminId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertAdminPasswordAuth(pool, context) {
|
||||||
|
const [tenantRows] = await pool.query('SELECT code FROM qipai_tenants WHERE id = ?', [context.tenantId]);
|
||||||
|
const [adminRows] = await pool.query(
|
||||||
|
`SELECT u.id FROM qipai_users u
|
||||||
|
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||||
|
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||||
|
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
|
||||||
|
);
|
||||||
|
const userId = String(adminRows[0].id);
|
||||||
|
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, userId);
|
||||||
|
const actor = { tenantId: context.tenantId, userId, access,
|
||||||
|
traceId: 'm08d-admin-auth-live', ip: '127.0.0.1', userAgent: 'M08-D admin auth live test' };
|
||||||
|
const repository = new AdminAuthRepository(pool);
|
||||||
|
const passwordHash = await hashPassword('LiveAdminPassword!2026');
|
||||||
|
await repository.setCredential(actor, context.tenantId, {
|
||||||
|
userId, loginName: 'Live.Admin', passwordHash
|
||||||
|
});
|
||||||
|
const [credentialRows] = await pool.query(
|
||||||
|
'SELECT login_name AS loginName, password_hash AS passwordHash FROM qipai_admin_credentials WHERE tenant_id = ? AND user_id = ?',
|
||||||
|
[context.tenantId, userId]
|
||||||
|
);
|
||||||
|
assert.equal(credentialRows[0].loginName, 'live.admin');
|
||||||
|
assert.notEqual(credentialRows[0].passwordHash, 'LiveAdminPassword!2026');
|
||||||
|
await assert.rejects(() => repository.loginWithPassword({ tenantCode: tenantRows[0].code,
|
||||||
|
loginName: 'live.admin', ['password']: 'WrongPassword!2026', sessionId: '11000000-0000-4000-8000-000000000001',
|
||||||
|
refreshTokenHash: hashToken('unused'), expiresAt: new Date(Date.now() + 60000),
|
||||||
|
ip: actor.ip, userAgent: actor.userAgent, traceId: 'm08d-admin-login-failed' }),
|
||||||
|
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_LOGIN_INVALID');
|
||||||
|
const refreshToken = '11000000-0000-4000-8000-000000000002.live-refresh-token';
|
||||||
|
const session = await repository.loginWithPassword({ tenantCode: tenantRows[0].code,
|
||||||
|
loginName: 'LIVE.ADMIN', ['password']: 'LiveAdminPassword!2026',
|
||||||
|
sessionId: '11000000-0000-4000-8000-000000000002', refreshTokenHash: hashToken(refreshToken),
|
||||||
|
expiresAt: new Date(Date.now() + 60000), ip: actor.ip, userAgent: actor.userAgent,
|
||||||
|
traceId: 'm08d-admin-login-success' });
|
||||||
|
assert.equal(session.user.id, userId);
|
||||||
|
const nextRefreshToken = '11000000-0000-4000-8000-000000000002.next-refresh-token';
|
||||||
|
const refreshed = await repository.rotateRefreshToken({ sessionId: session.id,
|
||||||
|
currentHash: hashToken(refreshToken), nextHash: hashToken(nextRefreshToken),
|
||||||
|
ip: actor.ip, userAgent: actor.userAgent });
|
||||||
|
assert.equal(refreshed.user.id, userId);
|
||||||
|
await assert.rejects(() => repository.rotateRefreshToken({ sessionId: session.id,
|
||||||
|
currentHash: hashToken(refreshToken), nextHash: hashToken('reused'),
|
||||||
|
ip: actor.ip, userAgent: actor.userAgent }),
|
||||||
|
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_REFRESH_INVALID');
|
||||||
|
const [auditRows] = await pool.query(
|
||||||
|
`SELECT action, CAST(metadata AS CHAR) AS metadata FROM qipai_audit_logs
|
||||||
|
WHERE tenant_id = ? AND action IN ('ADMIN_CREDENTIAL_UPDATED', 'ADMIN_LOGIN_FAILED', 'ADMIN_LOGIN_SUCCEEDED')`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
assert.ok(auditRows.some((row) => row.action === 'ADMIN_LOGIN_SUCCEEDED'));
|
||||||
|
assert.equal(auditRows.some((row) => row.metadata.includes('LiveAdminPassword!2026')), false);
|
||||||
|
}
|
||||||
|
|
||||||
async function assertDeviceTopology(pool, context) {
|
async function assertDeviceTopology(pool, context) {
|
||||||
const [adminRows] = await pool.query(
|
const [adminRows] = await pool.query(
|
||||||
`SELECT u.id FROM qipai_users u
|
`SELECT u.id FROM qipai_users u
|
||||||
@@ -2118,7 +2174,8 @@ try {
|
|||||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
{ version: '2026062219', name: 'm06b_device_topology' },
|
||||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||||
{ version: '2026081003', name: 'm08d_franchise_leads' }
|
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||||
|
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||||
]);
|
]);
|
||||||
await assertTaskDurability(pool);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -2128,6 +2185,7 @@ try {
|
|||||||
await assertContentManagement(pool, loginContext);
|
await assertContentManagement(pool, loginContext);
|
||||||
await assertFranchiseManagement(pool, loginContext);
|
await assertFranchiseManagement(pool, loginContext);
|
||||||
await assertSystemOperations(pool, loginContext);
|
await assertSystemOperations(pool, loginContext);
|
||||||
|
await assertAdminPasswordAuth(pool, loginContext);
|
||||||
await assertStoreDiscovery(pool, loginContext);
|
await assertStoreDiscovery(pool, loginContext);
|
||||||
await assertSceneAndWifiAccess(pool, loginContext);
|
await assertSceneAndWifiAccess(pool, loginContext);
|
||||||
await assertPricingAndReservations(pool, loginContext);
|
await assertPricingAndReservations(pool, loginContext);
|
||||||
@@ -2172,7 +2230,8 @@ try {
|
|||||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
{ version: '2026062219', name: 'm06b_device_topology' },
|
||||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||||
{ version: '2026081003', name: 'm08d_franchise_leads' }
|
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||||
|
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||||
]);
|
]);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -2222,6 +2281,8 @@ try {
|
|||||||
'franchise assignment and controlled follow-up status transition',
|
'franchise assignment and controlled follow-up status transition',
|
||||||
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
|
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
|
||||||
'system overview and audited tenant configuration update',
|
'system overview and audited tenant configuration update',
|
||||||
|
'scrypt admin credentials and failed-login audit',
|
||||||
|
'rotating refresh token with reuse rejection',
|
||||||
'city fallback store filtering',
|
'city fallback store filtering',
|
||||||
'server-side distance sorting',
|
'server-side distance sorting',
|
||||||
'empty manual city result',
|
'empty manual city result',
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DELETE FROM qipai_schema_migrations WHERE version = '2026081004';
|
||||||
|
ALTER TABLE qipai_auth_sessions
|
||||||
|
DROP INDEX idx_qipai_auth_sessions_refresh,
|
||||||
|
DROP COLUMN refresh_expires_at,
|
||||||
|
DROP COLUMN refresh_token_hash;
|
||||||
|
DROP TABLE IF EXISTS qipai_admin_credentials;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS qipai_admin_credentials (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
login_name VARCHAR(64) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
failed_attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
locked_until DATETIME(3) NULL,
|
||||||
|
password_changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
last_login_at DATETIME(3) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
deleted_at DATETIME(3) NULL,
|
||||||
|
CONSTRAINT fk_qipai_admin_credentials_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_admin_credentials_user FOREIGN KEY (user_id) REFERENCES qipai_users(id),
|
||||||
|
UNIQUE KEY uq_qipai_admin_credentials_login (tenant_id, login_name),
|
||||||
|
UNIQUE KEY uq_qipai_admin_credentials_user (tenant_id, user_id),
|
||||||
|
KEY idx_qipai_admin_credentials_status (tenant_id, status, locked_until)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE qipai_auth_sessions
|
||||||
|
ADD COLUMN refresh_token_hash CHAR(64) NULL AFTER role_version,
|
||||||
|
ADD COLUMN refresh_expires_at DATETIME(3) NULL AFTER expires_at,
|
||||||
|
ADD KEY idx_qipai_auth_sessions_refresh (status, refresh_expires_at);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||||
|
VALUES ('2026081004', 'm08d_admin_password_auth');
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_admin_credentials';
|
||||||
|
|
||||||
|
SELECT table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_auth_sessions'
|
||||||
|
AND column_name IN ('refresh_token_hash', 'refresh_expires_at')
|
||||||
|
ORDER BY column_name;
|
||||||
|
|
||||||
|
SELECT table_name, index_name
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND ((table_name = 'qipai_admin_credentials'
|
||||||
|
AND index_name IN ('uq_qipai_admin_credentials_login', 'uq_qipai_admin_credentials_user'))
|
||||||
|
OR (table_name = 'qipai_auth_sessions'
|
||||||
|
AND index_name = 'idx_qipai_auth_sessions_refresh'))
|
||||||
|
GROUP BY table_name, index_name
|
||||||
|
ORDER BY table_name, index_name;
|
||||||
|
|
||||||
|
SELECT version, name
|
||||||
|
FROM qipai_schema_migrations
|
||||||
|
WHERE version = '2026081004';
|
||||||
@@ -31,8 +31,11 @@ for (const pattern of [
|
|||||||
'ContentManagementPanel',
|
'ContentManagementPanel',
|
||||||
'FranchisePanel',
|
'FranchisePanel',
|
||||||
'LogsSystemPanel',
|
'LogsSystemPanel',
|
||||||
|
'AdminLoginPanel',
|
||||||
'运营总览',
|
'运营总览',
|
||||||
'savedToken',
|
'savedToken',
|
||||||
|
'handleAuthenticated',
|
||||||
|
'handleLogout',
|
||||||
'loadCleaningWorkspace'
|
'loadCleaningWorkspace'
|
||||||
]) {
|
]) {
|
||||||
assert.match(app, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
assert.match(app, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
@@ -234,6 +237,23 @@ const systemRepository = read('backend/src/operations/system-operations-reposito
|
|||||||
assert.match(systemRepository, /sanitizeMetadata/);
|
assert.match(systemRepository, /sanitizeMetadata/);
|
||||||
assert.match(systemRepository, /TENANT_SYSTEM_CONFIG_UPDATED/);
|
assert.match(systemRepository, /TENANT_SYSTEM_CONFIG_UPDATED/);
|
||||||
|
|
||||||
|
const adminLogin = read('admin/src/components/AdminLoginPanel.vue');
|
||||||
|
for (const pattern of ['adminPasswordLogin', '租户代码', '安全登录', '连续失败 5 次']) {
|
||||||
|
assert.match(adminLogin, new RegExp(pattern));
|
||||||
|
}
|
||||||
|
const adminAuthRoutes = read('backend/src/routes/admin-auth.ts');
|
||||||
|
for (const pattern of ['/admin-api/auth/login', '/admin-api/auth/refresh', '/admin-api/auth/me',
|
||||||
|
'/admin-api/auth/logout', '/admin-api/auth/credentials/:userId']) {
|
||||||
|
assert.match(adminAuthRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
const adminAuthRepository = read('backend/src/auth/admin-auth-repository.ts');
|
||||||
|
assert.match(adminAuthRepository, /ADMIN_LOGIN_SUCCEEDED/);
|
||||||
|
assert.match(adminAuthRepository, /ADMIN_LOGIN_FAILED/);
|
||||||
|
assert.match(adminAuthRepository, /ADMIN_REFRESH_REUSED/);
|
||||||
|
const adminAuthMigration = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
|
||||||
|
assert.match(adminAuthMigration, /qipai_admin_credentials/);
|
||||||
|
assert.match(adminAuthMigration, /refresh_token_hash/);
|
||||||
|
|
||||||
const routes = read('backend/src/routes/business-statistics.ts');
|
const routes = read('backend/src/routes/business-statistics.ts');
|
||||||
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
||||||
|
|
||||||
@@ -278,6 +298,8 @@ for (const pattern of [
|
|||||||
'.franchise-metrics',
|
'.franchise-metrics',
|
||||||
'.system-metrics',
|
'.system-metrics',
|
||||||
'.audit-filters',
|
'.audit-filters',
|
||||||
|
'.login-shell',
|
||||||
|
'.session-box',
|
||||||
'@media (max-width: 980px)',
|
'@media (max-width: 980px)',
|
||||||
'@media (max-width: 560px)'
|
'@media (max-width: 560px)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user