feat: add system admin account management with RBAC

- AdminUser model: multi-admin accounts with bcrypt hashing (admin_users table)
- Three roles: superadmin / admin / viewer with granular permission bits
- auth.py: login migrated from env-var single admin to DB-backed accounts;
  seed_initial_admin() auto-creates first superadmin from SM_ADMIN_PASSWORD
- Web UI: /system/admins page (add/edit role/toggle/reset pwd/delete)
  + change-my-password; sidebar entry; permission-guarded routes
- REST API: /api/admins CRUD with protection checks
- Protections: cannot delete/disable self; keep >=1 enabled superadmin;
  disabled accounts fail permission checks immediately
- README: document roles, permission bits, API
This commit is contained in:
Your Name
2026-09-09 14:33:59 +08:00
parent 3232159d29
commit 9890cfc488
8 changed files with 719 additions and 51 deletions
+203
View File
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}管理员账户 · SOCKS Manager{% endblock %}
{% block content %}
<div class="page-header">
<h5><i class="bi bi-person-gear"></i> 管理员账户</h5>
</div>
{% set can_write = current_admin and current_admin.has_permission('admins:write') %}
<!-- 新增管理员 -->
<div class="card mb-3">
<div class="card-header"><span><i class="bi bi-person-plus"></i> 新增管理员</span></div>
<div class="card-body">
<form method="post" action="/system/admins/add" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">用户名</label>
<input name="username" class="form-control" placeholder="如: zhangsan" required>
</div>
<div class="col-md-3">
<label class="form-label small">密码</label>
<input name="password" type="password" class="form-control" placeholder="至少 4 位" required>
</div>
<div class="col-md-3">
<label class="form-label small">角色</label>
<select name="role" class="form-select">
{% for r, label in [('superadmin','超级管理员'), ('admin','管理员'), ('viewer','只读用户')] %}
<option value="{{ r }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus-lg"></i> 创建</button>
</div>
</form>
</div>
</div>
<!-- 角色说明 -->
<div class="card mb-3">
<div class="card-header"><span><i class="bi bi-info-circle"></i> 角色权限说明</span></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="card" style="background:var(--bg-tertiary)">
<div class="card-body py-2">
<span class="badge badge-danger">超级管理员</span>
<span class="text-muted small">全部权限,含管理员管理</span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card" style="background:var(--bg-tertiary)">
<div class="card-body py-2">
<span class="badge badge-info">管理员</span>
<span class="text-muted small">用户/实例/系统管理,可查看管理员</span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card" style="background:var(--bg-tertiary)">
<div class="card-body py-2">
<span class="badge badge-secondary">只读用户</span>
<span class="text-muted small">仅查看,不可修改</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 管理员列表 -->
<div class="card">
<div class="card-header"><span><i class="bi bi-people"></i> 账户列表</span></div>
<div class="card-body p-0">
<table class="table table-dark table-sm">
<thead><tr>
<th>用户名</th><th>角色</th><th>状态</th><th>最近登录</th><th>创建时间</th>
{% if can_write %}<th>操作</th>{% endif %}
</tr></thead>
<tbody>
{% for a in admins %}
<tr>
<td>
{{ a.username }}
{% if current_admin and current_admin.id == a.id %}
<span class="badge badge-info">当前</span>
{% endif %}
</td>
<td>
{% if can_write %}
<form method="post" action="/system/admins/{{ a.id }}/role" class="d-inline">
<select name="role" class="form-select d-inline-block" style="width:auto;padding:.15rem .4rem;font-size:12px"
onchange="this.form.submit()">
{% for r, label in [('superadmin','超级管理员'), ('admin','管理员'), ('viewer','只读用户')] %}
<option value="{{ r }}" {% if a.role == r %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</form>
{% else %}
<span class="badge {{ 'badge-danger' if a.role=='superadmin' else 'badge-info' if a.role=='admin' else 'badge-secondary' }}">{{ a.role_label }}</span>
{% endif %}
</td>
<td>
{% if a.enabled %}
<span class="badge badge-success">启用</span>
{% else %}
<span class="badge badge-danger">禁用</span>
{% endif %}
</td>
<td class="text-muted">
{% if a.last_login_at %}
{{ a.last_login_at.strftime('%Y-%m-%d %H:%M') }}<br>
<small>{{ a.last_login_ip or '-' }}</small>
{% else %}-{% endif %}
</td>
<td class="text-muted">{{ a.created_at.strftime('%Y-%m-%d') if a.created_at else '-' }}</td>
{% if can_write %}
<td>
{% if not (current_admin and current_admin.id == a.id) %}
<form method="post" action="/system/admins/{{ a.id }}/toggle" class="d-inline">
<button class="btn btn-sm {{ 'btn-outline-warning' if a.enabled else 'btn-outline-success' }}"
title="{{ '禁用' if a.enabled else '启用' }}">
<i class="bi {{ 'bi-pause-circle' if a.enabled else 'bi-play-circle' }}"></i></button>
</form>
<!-- 重置密码(模态框) -->
<button class="btn btn-sm btn-outline-primary" onclick="openPwdModal({{ a.id }}, '{{ a.username }}')">
<i class="bi bi-key"></i> 重置密码</button>
<form method="post" action="/system/admins/{{ a.id }}/delete" class="d-inline"
onsubmit="return confirm('确认删除管理员 {{ a.username }}')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 修改当前密码 -->
<div class="card mt-3">
<div class="card-header"><span><i class="bi bi-shield-check"></i> 修改我的密码</span></div>
<div class="card-body">
<form method="post" action="/system/change-password" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">原密码</label>
<input name="old_password" type="password" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label small">新密码</label>
<input name="new_password" type="password" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label small">确认新密码</label>
<input name="confirm_password" type="password" class="form-control" required>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-check-lg"></i> 修改密码</button>
</div>
</form>
</div>
</div>
<!-- 重置密码模态框 -->
<div class="modal fade" id="pwdModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="" id="pwdForm">
<div class="modal-header">
<h5 class="modal-title">重置密码</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small" id="pwdTarget"></p>
<label class="form-label small">新密码(至少 4 位)</label>
<input name="password" type="password" class="form-control" required>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">确认重置</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
function openPwdModal(id, username) {
document.getElementById('pwdForm').action = '/system/admins/' + id + '/password';
document.getElementById('pwdTarget').textContent = '账户: ' + username;
new bootstrap.Modal(document.getElementById('pwdModal')).show();
}
</script>
{% endblock %}