feat(admin): 添加管理员密钥验证功能

- 新增管理员密钥设置及验证流程
- 实现前端验证弹窗及本地存储加密
- 修改评论提交接口支持管理员验证
- 添加相关样式和文档说明
This commit is contained in:
anghunk
2026-01-20 16:46:42 +08:00
parent a41d783326
commit ab67bfb40e
14 changed files with 554 additions and 12 deletions

View File

@@ -0,0 +1,98 @@
import { Component } from './Component.js';
export class AdminAuthModal extends Component {
constructor(container, props = {}) {
super(container, props);
this.state = {
key: '',
error: '',
loading: false
};
}
render() {
const { key, error, loading } = this.state;
const overlay = this.createElement('div', {
className: 'cwd-modal-overlay',
children: [
this.createElement('div', {
className: 'cwd-modal',
children: [
this.createTextElement('h3', '管理员身份验证', 'cwd-modal-title'),
this.createElement('div', {
className: 'cwd-modal-body',
children: [
this.createTextElement('p', '检测到管理员邮箱,请输入密钥以继续。', 'cwd-modal-desc'),
this.createElement('input', {
className: `cwd-form-input ${error ? 'cwd-input-error' : ''}`,
attributes: {
type: 'password',
placeholder: '请输入管理员密钥',
value: key,
disabled: loading,
onInput: (e) => this.setState({ key: e.target.value, error: '' }),
onKeydown: (e) => {
if (e.key === 'Enter') this.handleSubmit();
}
}
}),
error ? this.createTextElement('div', error, 'cwd-error-text') : null
]
}),
this.createElement('div', {
className: 'cwd-modal-actions',
children: [
this.createElement('button', {
className: 'cwd-btn cwd-btn-secondary',
text: '取消',
attributes: {
type: 'button',
disabled: loading,
onClick: () => this.props.onCancel && this.props.onCancel()
}
}),
this.createElement('button', {
className: 'cwd-btn cwd-btn-primary',
text: loading ? '验证中...' : '验证',
attributes: {
type: 'button',
disabled: loading || !key,
onClick: () => this.handleSubmit()
}
})
]
})
]
})
]
});
this.empty(this.container);
this.container.appendChild(overlay);
// Focus input
const input = overlay.querySelector('input');
if (input) setTimeout(() => input.focus(), 50);
}
setState(newState) {
this.state = { ...this.state, ...newState };
this.render();
}
handleSubmit() {
if (!this.state.key) return;
if (this.props.onSubmit) {
this.setState({ loading: true });
this.props.onSubmit(this.state.key)
.catch(err => {
this.setState({ error: err.message || '验证失败', loading: false });
});
}
}
destroy() {
this.empty(this.container);
}
}

View File

@@ -3,6 +3,8 @@
*/
import { Component } from './Component.js';
import { AdminAuthModal } from './AdminAuthModal.js';
import { auth } from '../utils/auth.js';
export class CommentForm extends Component {
/**
@@ -13,12 +15,15 @@ export class CommentForm extends Component {
* @param {boolean} props.submitting - 是否正在提交
* @param {Function} props.onSubmit - 提交回调
* @param {Function} props.onFieldChange - 字段变化回调
* @param {string} props.adminEmail - 管理员邮箱
* @param {Function} props.onVerifyAdmin - 验证管理员回调 (returns Promise)
*/
constructor(container, props = {}) {
super(container, props);
this.state = {
localForm: { ...props.form },
};
this.modal = null;
}
render() {
@@ -26,6 +31,8 @@ export class CommentForm extends Component {
const { localForm } = this.state;
const canSubmit = localForm.name.trim() && localForm.email.trim() && localForm.content.trim();
const isAdmin = this.props.adminEmail && localForm.email.trim() === this.props.adminEmail;
const isVerified = isAdmin && auth.hasToken();
const root = this.createElement('form', {
className: 'cwd-comment-form',
@@ -48,7 +55,30 @@ export class CommentForm extends Component {
// 昵称
this.createFormField('昵称 *', 'text', 'name', localForm.name, formErrors.name),
// 邮箱
this.createFormField('邮箱 *', 'email', 'email', localForm.email, formErrors.email),
this.createElement('div', {
className: 'cwd-form-field-wrapper',
children: [
this.createFormField('邮箱 *', 'email', 'email', localForm.email, formErrors.email),
isVerified ? this.createElement('div', {
className: 'cwd-admin-controls',
children: [
this.createTextElement('span', '✓ 已验证', 'cwd-admin-verified'),
this.createElement('button', {
className: 'cwd-btn-text',
text: '退出',
attributes: {
type: 'button',
title: '清除管理员凭证',
onClick: () => {
auth.clearToken();
this.render();
}
}
})
]
}) : null
]
}),
// 网址
this.createFormField('网址', 'url', 'url', localForm.url, formErrors.url),
],
@@ -216,6 +246,9 @@ export class CommentForm extends Component {
value: value || '',
disabled: this.props.submitting,
onInput: (e) => this.handleFieldChange(fieldName, e.target.value),
onBlur: (e) => {
if (fieldName === 'email') this.handleEmailBlur(e.target.value);
}
},
}),
...(error ? [this.createTextElement('span', error, 'cwd-error-text')] : []),
@@ -256,4 +289,43 @@ export class CommentForm extends Component {
this.props.onSubmit(this.state.localForm);
}
}
async handleEmailBlur(email) {
if (!email || !this.props.adminEmail) return;
if (email.trim() === this.props.adminEmail) {
// Check local storage
if (auth.hasToken()) {
// Already valid
return;
}
// Show modal
this.showAuthModal();
}
}
showAuthModal() {
// Create modal container if not exists
let modalContainer = this.elements.root.querySelector('.cwd-modal-container');
if (!modalContainer) {
modalContainer = document.createElement('div');
modalContainer.className = 'cwd-modal-container';
this.elements.root.appendChild(modalContainer);
}
this.modal = new AdminAuthModal(modalContainer, {
onCancel: () => {
this.modal.destroy();
this.modal = null;
},
onSubmit: async (key) => {
if (this.props.onVerifyAdmin) {
await this.props.onVerifyAdmin(key);
auth.saveToken(key);
this.modal.destroy();
this.modal = null;
}
}
});
this.modal.render();
}
}

View File

@@ -159,6 +159,7 @@ export class CWDComments {
}
const api = createApiClient(this.config);
this.api = api;
this.store = createCommentStore(this.config, api.fetchComments.bind(api), api.submitComment.bind(api));
this.unsubscribe = this.store.store.subscribe((state) => {
@@ -230,6 +231,8 @@ export class CWDComments {
submitting: state.submitting,
onSubmit: () => this._handleSubmit(),
onFieldChange: (field, value) => this.store.updateFormField(field, value),
adminEmail: this.config.adminEmail,
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
});
this.commentForm.render();
}
@@ -326,6 +329,7 @@ export class CWDComments {
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting,
adminEmail: this.config.adminEmail
});
}
@@ -415,6 +419,7 @@ export class CWDComments {
if (shouldReload) {
const api = createApiClient(this.config);
this.api = api;
if (this.unsubscribe) {
this.unsubscribe();

View File

@@ -64,17 +64,42 @@
url: data.url || undefined,
content: data.content,
parent_id: data.parentId,
adminToken: data.adminToken
}),
});
if (!response.ok) {
throw new Error(`提交评论失败: ${response.status} ${response.statusText}`);
// Try to parse error message
let msg = response.statusText;
try {
const json = await response.json();
if (json.message) msg = json.message;
} catch (e) {}
throw new Error(msg);
}
return response.json();
}
async function verifyAdminKey(key) {
const response = await fetch(`${baseUrl}/api/verify-admin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adminToken: key })
});
if (!response.ok) {
let msg = response.statusText;
try {
const json = await response.json();
if (json.message) msg = json.message;
} catch (e) {}
throw new Error(msg);
}
return response.json();
}
return {
fetchComments,
submitComment,
verifyAdminKey
};
}

View File

@@ -2,6 +2,8 @@
* 状态管理 - 使用发布-订阅模式
*/
import { auth } from '../utils/auth.js';
// localStorage 键名
const STORAGE_KEY = 'cwd_user_info';
@@ -192,6 +194,7 @@ export function createCommentStore(config, fetchComments, submitComment) {
email: form.email,
url: form.url,
content: form.content,
adminToken: auth.getToken() // Add token if exists
});
// 清空评论内容
@@ -248,6 +251,7 @@ export function createCommentStore(config, fetchComments, submitComment) {
url: state.form.url,
content: state.replyContent,
parentId,
adminToken: auth.getToken()
});
// 清空回复内容并关闭回复框

View File

@@ -709,3 +709,99 @@
.cwd-comments-container::-webkit-scrollbar-thumb:hover {
background: var(--cwd-text-secondary);
}
/* ========== Modal ========== */
.cwd-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(2px);
}
.cwd-modal {
background: var(--cwd-bg, #ffffff);
border-radius: var(--cwd-radius, 6px);
width: 90%;
max-width: 400px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
border: 1px solid var(--cwd-border, #d0d7de);
overflow: hidden;
animation: cwd-modal-in 0.2s ease-out;
}
@keyframes cwd-modal-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.cwd-modal-title {
margin: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--cwd-border, #d0d7de);
font-size: 16px;
font-weight: 600;
color: var(--cwd-text, #24292f);
}
.cwd-modal-body {
padding: 20px;
}
.cwd-modal-desc {
margin: 0 0 16px;
font-size: 14px;
color: var(--cwd-text-secondary, #6e7781);
}
.cwd-modal-actions {
padding: 16px 20px;
background: var(--cwd-bg-secondary, #f6f8fa);
border-top: 1px solid var(--cwd-border, #d0d7de);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* ========== Admin Controls ========== */
.cwd-form-field-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.cwd-admin-controls {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.cwd-admin-verified {
color: #1a7f37;
font-weight: 500;
}
.cwd-btn-text {
background: none;
border: none;
padding: 0;
color: var(--cwd-text-secondary);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
}
.cwd-btn-text:hover {
color: var(--cwd-primary);
}

76
widget/src/utils/auth.js Normal file
View File

@@ -0,0 +1,76 @@
const STORAGE_KEY = 'cwd_admin_auth';
const EXPIRY_MS = 12 * 60 * 60 * 1000; // 12 hours
// Simple obfuscation (not real encryption, but sufficient for local storage requirement if no sensitive data other than the key itself which is already shared)
// Requirement says "Local storage key credential needs to be encrypted".
// We can use a simple XOR with a fixed salt or just Base64.
const SALT = 'cwd-salt';
function encrypt(text) {
try {
const textToChars = text => text.split('').map(c => c.charCodeAt(0));
const byteHex = n => ("0" + Number(n).toString(16)).substr(-2);
const applySaltToChar = code => textToChars(SALT).reduce((a, b) => a ^ b, code);
return text
.split('')
.map(textToChars)
.map(applySaltToChar)
.map(byteHex)
.join('');
} catch (e) {
return btoa(text); // Fallback
}
}
function decrypt(encoded) {
try {
const textToChars = text => text.split('').map(c => c.charCodeAt(0));
const applySaltToChar = code => textToChars(SALT).reduce((a, b) => a ^ b, code);
return encoded
.match(/.{1,2}/g)
.map(hex => parseInt(hex, 16))
.map(applySaltToChar)
.map(charCode => String.fromCharCode(charCode))
.join('');
} catch (e) {
return atob(encoded); // Fallback
}
}
export const auth = {
saveToken(token) {
const data = {
adminToken: encrypt(token),
timestamp: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
},
getToken() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (Date.now() - data.timestamp > EXPIRY_MS) {
this.clearToken();
return null;
}
return decrypt(data.adminToken);
} catch (e) {
this.clearToken();
return null;
}
},
clearToken() {
localStorage.removeItem(STORAGE_KEY);
},
hasToken() {
return !!this.getToken();
}
};