feat: 增加用户修改密码与管理员重置密码
This commit is contained in:
@@ -74,7 +74,9 @@ server/data/spanish_diary.db
|
|||||||
## 阶段2 功能
|
## 阶段2 功能
|
||||||
|
|
||||||
- 用户注册、登录、登出
|
- 用户注册、登录、登出
|
||||||
|
- 用户修改密码
|
||||||
- 管理员添加、禁用、删除用户
|
- 管理员添加、禁用、删除用户
|
||||||
|
- 管理员重置用户密码
|
||||||
- 用户日记数据隔离
|
- 用户日记数据隔离
|
||||||
- 共享知识库
|
- 共享知识库
|
||||||
- 每用户翻译配置(本地 / MyMemory / 自定义接口)
|
- 每用户翻译配置(本地 / MyMemory / 自定义接口)
|
||||||
|
|||||||
+12
-1
@@ -26,7 +26,7 @@
|
|||||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
th, td { border: 1px solid var(--border); padding: 0.5rem 0.6rem; text-align: left; }
|
th, td { border: 1px solid var(--border); padding: 0.5rem 0.6rem; text-align: left; }
|
||||||
th { background: var(--bg); }
|
th { background: var(--bg); }
|
||||||
.user-actions { display: flex; gap: 0.35rem; }
|
.user-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; max-width: 240px; }
|
||||||
.user-actions button {
|
.user-actions button {
|
||||||
border: 1px solid var(--border); background: none; border-radius: 4px;
|
border: 1px solid var(--border); background: none; border-radius: 4px;
|
||||||
padding: 0.3rem 0.5rem; cursor: pointer; color: var(--text-muted);
|
padding: 0.3rem 0.5rem; cursor: pointer; color: var(--text-muted);
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
'<td>' + esc(u.created_at) + '</td>' +
|
'<td>' + esc(u.created_at) + '</td>' +
|
||||||
'<td><div class="user-actions">' +
|
'<td><div class="user-actions">' +
|
||||||
'<button data-id="' + u.id + '" data-active="' + (u.is_active ? '0' : '1') + '" class="toggle-user">' + (u.is_active ? '禁用' : '启用') + '</button>' +
|
'<button data-id="' + u.id + '" data-active="' + (u.is_active ? '0' : '1') + '" class="toggle-user">' + (u.is_active ? '禁用' : '启用') + '</button>' +
|
||||||
|
'<button data-id="' + u.id + '" class="reset-password">重置密码</button>' +
|
||||||
'<button data-id="' + u.id + '" class="danger delete-user">删除</button>' +
|
'<button data-id="' + u.id + '" class="danger delete-user">删除</button>' +
|
||||||
'</div></td>' +
|
'</div></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
@@ -131,6 +132,16 @@
|
|||||||
adminMsg.textContent = '用户已删除';
|
adminMsg.textContent = '用户已删除';
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}).catch(function(err) { adminMsg.textContent = err.message; });
|
}).catch(function(err) { adminMsg.textContent = err.message; });
|
||||||
|
} else if (btn.classList.contains('reset-password')) {
|
||||||
|
var newPassword = prompt('请输入该用户的新密码(至少 6 位):');
|
||||||
|
if (newPassword == null) return;
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
adminMsg.textContent = '密码至少 6 位';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiAdminResetPassword(id, newPassword).then(function() {
|
||||||
|
adminMsg.textContent = '密码已重置';
|
||||||
|
}).catch(function(err) { adminMsg.textContent = err.message; });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ function apiLogout() {
|
|||||||
return apiFetch('/api/logout', { method: 'POST' });
|
return apiFetch('/api/logout', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiChangePassword(data) {
|
||||||
|
return apiFetch('/api/change-password', { method: 'POST', body: data });
|
||||||
|
}
|
||||||
|
|
||||||
function apiGetEntries() {
|
function apiGetEntries() {
|
||||||
return apiFetch('/api/entries');
|
return apiFetch('/api/entries');
|
||||||
}
|
}
|
||||||
@@ -99,3 +103,7 @@ function apiAdminSetStatus(id, isActive) {
|
|||||||
function apiAdminDeleteUser(id) {
|
function apiAdminDeleteUser(id) {
|
||||||
return apiFetch('/api/admin/users/' + id, { method: 'DELETE' });
|
return apiFetch('/api/admin/users/' + id, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiAdminResetPassword(id, password) {
|
||||||
|
return apiFetch('/api/admin/users/' + id + '/password', { method: 'PUT', body: { password: password } });
|
||||||
|
}
|
||||||
|
|||||||
@@ -380,6 +380,7 @@ function renderUserArea() {
|
|||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
document.getElementById('user-name').textContent = currentUser.displayName || currentUser.username;
|
document.getElementById('user-name').textContent = currentUser.displayName || currentUser.username;
|
||||||
document.getElementById('btn-logout').style.display = '';
|
document.getElementById('btn-logout').style.display = '';
|
||||||
|
document.getElementById('btn-change-password').style.display = '';
|
||||||
if (currentUser.isAdmin) {
|
if (currentUser.isAdmin) {
|
||||||
document.getElementById('admin-link').style.display = '';
|
document.getElementById('admin-link').style.display = '';
|
||||||
}
|
}
|
||||||
@@ -477,6 +478,47 @@ function closeSettingsModal() {
|
|||||||
document.getElementById('settings-modal-overlay').classList.remove('show');
|
document.getElementById('settings-modal-overlay').classList.remove('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPasswordModal() {
|
||||||
|
document.getElementById('password-msg').textContent = '';
|
||||||
|
document.getElementById('password-old').value = '';
|
||||||
|
document.getElementById('password-new').value = '';
|
||||||
|
document.getElementById('password-confirm').value = '';
|
||||||
|
document.getElementById('password-modal-overlay').classList.add('show');
|
||||||
|
setTimeout(function() { document.getElementById('password-old').focus(); }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePasswordModal() {
|
||||||
|
document.getElementById('password-modal-overlay').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePasswordModal() {
|
||||||
|
var oldPassword = document.getElementById('password-old').value;
|
||||||
|
var newPassword = document.getElementById('password-new').value;
|
||||||
|
var confirmPassword = document.getElementById('password-confirm').value;
|
||||||
|
var msg = document.getElementById('password-msg');
|
||||||
|
if (!oldPassword) {
|
||||||
|
msg.textContent = '请输入当前密码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
msg.textContent = '新密码至少 6 位';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
msg.textContent = '两次输入的新密码不一致';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiChangePassword({
|
||||||
|
oldPassword: oldPassword,
|
||||||
|
newPassword: newPassword
|
||||||
|
}).then(function() {
|
||||||
|
closePasswordModal();
|
||||||
|
alert('密码已修改');
|
||||||
|
}).catch(function(err) {
|
||||||
|
msg.textContent = err.message || '修改密码失败';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function saveSettingsModal() {
|
function saveSettingsModal() {
|
||||||
var extra = {};
|
var extra = {};
|
||||||
var rawExtra = document.getElementById('extra-config').value.trim();
|
var rawExtra = document.getElementById('extra-config').value.trim();
|
||||||
@@ -530,6 +572,9 @@ function bindAppEvents() {
|
|||||||
document.getElementById('btn-settings').addEventListener('click', openSettingsModal);
|
document.getElementById('btn-settings').addEventListener('click', openSettingsModal);
|
||||||
document.getElementById('settings-cancel').addEventListener('click', closeSettingsModal);
|
document.getElementById('settings-cancel').addEventListener('click', closeSettingsModal);
|
||||||
document.getElementById('settings-save').addEventListener('click', saveSettingsModal);
|
document.getElementById('settings-save').addEventListener('click', saveSettingsModal);
|
||||||
|
document.getElementById('btn-change-password').addEventListener('click', openPasswordModal);
|
||||||
|
document.getElementById('password-cancel').addEventListener('click', closePasswordModal);
|
||||||
|
document.getElementById('password-save').addEventListener('click', savePasswordModal);
|
||||||
document.getElementById('btn-logout').addEventListener('click', handleLogout);
|
document.getElementById('btn-logout').addEventListener('click', handleLogout);
|
||||||
|
|
||||||
document.querySelectorAll('#mobile-nav button').forEach(function(btn) {
|
document.querySelectorAll('#mobile-nav button').forEach(function(btn) {
|
||||||
|
|||||||
+19
@@ -50,6 +50,7 @@
|
|||||||
<span id="user-name">未登录</span>
|
<span id="user-name">未登录</span>
|
||||||
<button id="btn-settings" title="翻译设置">⚙️ 设置</button>
|
<button id="btn-settings" title="翻译设置">⚙️ 设置</button>
|
||||||
<a id="admin-link" href="admin.html" style="display:none">用户管理</a>
|
<a id="admin-link" href="admin.html" style="display:none">用户管理</a>
|
||||||
|
<button id="btn-change-password" style="display:none" title="修改密码">🔑 修改密码</button>
|
||||||
<button id="btn-logout" style="display:none">退出</button>
|
<button id="btn-logout" style="display:none">退出</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -155,6 +156,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Change password modal -->
|
||||||
|
<div id="password-modal-overlay">
|
||||||
|
<div id="password-modal-box">
|
||||||
|
<h3>修改密码</h3>
|
||||||
|
<label for="password-old">当前密码</label>
|
||||||
|
<input type="password" id="password-old" autocomplete="current-password">
|
||||||
|
<label for="password-new">新密码</label>
|
||||||
|
<input type="password" id="password-new" autocomplete="new-password">
|
||||||
|
<label for="password-confirm">确认新密码</label>
|
||||||
|
<input type="password" id="password-confirm" autocomplete="new-password">
|
||||||
|
<div id="password-msg"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" id="password-cancel">取消</button>
|
||||||
|
<button class="btn-confirm" id="password-save">确认修改</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="api.js"></script>
|
<script src="api.js"></script>
|
||||||
<script src="db.js"></script>
|
<script src="db.js"></script>
|
||||||
<script src="b1-vocab.js"></script>
|
<script src="b1-vocab.js"></script>
|
||||||
|
|||||||
+33
-1
@@ -7,7 +7,7 @@ ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
if ROOT_DIR not in sys.path:
|
if ROOT_DIR not in sys.path:
|
||||||
sys.path.insert(0, ROOT_DIR)
|
sys.path.insert(0, ROOT_DIR)
|
||||||
|
|
||||||
from server.auth import admin_required, authenticate, create_user, login_required
|
from server.auth import admin_required, authenticate, create_user, login_required, set_password
|
||||||
from server.db import BASE_DIR, get_db, init_db
|
from server.db import BASE_DIR, get_db, init_db
|
||||||
from server.entries_api import entries_bp
|
from server.entries_api import entries_bp
|
||||||
from server.settings_api import settings_bp
|
from server.settings_api import settings_bp
|
||||||
@@ -80,6 +80,19 @@ def create_app():
|
|||||||
session.clear()
|
session.clear()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
@app.post("/api/change-password")
|
||||||
|
@login_required
|
||||||
|
def change_password(user):
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
old_password = data.get("oldPassword") or ""
|
||||||
|
new_password = data.get("newPassword") or ""
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({"error": "新密码至少 6 位"}), 400
|
||||||
|
if authenticate(user["username"], old_password) is None:
|
||||||
|
return jsonify({"error": "当前密码不正确"}), 400
|
||||||
|
set_password(user["id"], new_password)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
@app.get("/api/me")
|
@app.get("/api/me")
|
||||||
@login_required
|
@login_required
|
||||||
def me(user):
|
def me(user):
|
||||||
@@ -185,6 +198,25 @@ def create_app():
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@app.put("/api/admin/users/<int:user_id>/password")
|
||||||
|
@admin_required
|
||||||
|
def admin_reset_password(user, user_id):
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
new_password = data.get("password") or data.get("newPassword") or ""
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({"error": "新密码至少 6 位"}), 400
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
target = conn.execute(
|
||||||
|
"SELECT id FROM users WHERE id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if target is None:
|
||||||
|
return jsonify({"error": "用户不存在"}), 404
|
||||||
|
set_password(user_id, new_password)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return send_from_directory(BASE_DIR, "index.html")
|
return send_from_directory(BASE_DIR, "index.html")
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ def create_user(username, password, display_name, is_admin=False, is_active=True
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(user_id, new_password):
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||||
|
(generate_password_hash(new_password), user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def authenticate(username, password):
|
def authenticate(username, password):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ body {
|
|||||||
|
|
||||||
/* User area and stats */
|
/* User area and stats */
|
||||||
#user-area {
|
#user-area {
|
||||||
display: flex; align-items: center; gap: 0.4rem;
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
#user-area #user-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
#user-area #user-name { flex: 1 1 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
#user-area button, #user-area a {
|
#user-area button, #user-area a {
|
||||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||||
padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer;
|
padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer;
|
||||||
@@ -338,6 +338,40 @@ body {
|
|||||||
}
|
}
|
||||||
#settings-modal-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
|
#settings-modal-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Change password modal */
|
||||||
|
#password-modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
z-index: 130;
|
||||||
|
}
|
||||||
|
#password-modal-overlay.show { display: flex; }
|
||||||
|
#password-modal-box {
|
||||||
|
background: var(--panel-bg); border-radius: var(--radius);
|
||||||
|
padding: 1.5rem; max-width: 420px; width: 90%;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
#password-modal-box h3 { font-size: 1rem; margin-bottom: 0.75rem; }
|
||||||
|
#password-modal-box label { display: block; font-size: 0.8rem; color: var(--text-muted); margin: 0.6rem 0 0.25rem; }
|
||||||
|
#password-modal-box input {
|
||||||
|
width: 100%; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.6rem; font-size: 0.85rem;
|
||||||
|
background: var(--bg); color: var(--text); outline: none;
|
||||||
|
}
|
||||||
|
#password-modal-box input:focus { border-color: var(--accent); }
|
||||||
|
#password-msg {
|
||||||
|
min-height: 1.4em; font-size: 0.8rem; color: var(--danger);
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
#password-modal-box .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
||||||
|
#password-modal-box .modal-actions button {
|
||||||
|
padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid var(--border);
|
||||||
|
cursor: pointer; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
#password-modal-box .btn-cancel { background: var(--panel-bg); color: var(--text); }
|
||||||
|
#password-modal-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body { padding: 0; }
|
body { padding: 0; }
|
||||||
#mobile-nav {
|
#mobile-nav {
|
||||||
|
|||||||
Reference in New Issue
Block a user