@ddj-v2/user-management 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -42
- package/index.ts +139 -1
- package/package.json +1 -1
- package/templates/domain_user.html +109 -0
- package/templates/user_manage_main.html +58 -8
package/README.md
CHANGED
|
@@ -1,77 +1,80 @@
|
|
|
1
|
-
# HydroOJ插件
|
|
1
|
+
# HydroOJ插件 使用者管理面板
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
修改自 https://github.com/SummerofOrange/hydrooj-user-management
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
這是一個為 HydroOJ 提供可視化使用者管理功能的插件,讓管理員能在控制面板中方便地管理使用者資訊、權限與狀態。
|
|
6
|
+
|
|
7
|
+
> 程式碼很簡單,佛系不定期更新~
|
|
6
8
|
>
|
|
7
|
-
>
|
|
9
|
+
> 如果覺得好用請幫我點個 star,感激不盡。
|
|
8
10
|
|
|
9
|
-
##
|
|
11
|
+
## 安裝方法
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
14
|
sudo su
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
hydrooj addon add /root/.hydro/hydrooj-user-management
|
|
15
|
+
yarn global add @ddj-v2/user-management
|
|
16
|
+
hydrooj addon add @ddj-v2/user-management
|
|
16
17
|
pm2 restart hydrooj
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
## 使用方法
|
|
20
21
|
|
|
21
|
-
1.
|
|
22
|
-
2.
|
|
23
|
-
3.
|
|
24
|
-
4.
|
|
25
|
-
5.
|
|
26
|
-
6.
|
|
22
|
+
1. **進入使用者管理**:登入 HydroOJ 後,在控制面板側邊欄找到「使用者管理」選單項
|
|
23
|
+
2. **搜尋使用者**:在使用者列表頁面使用搜尋框查找特定使用者
|
|
24
|
+
3. **編輯使用者**:點擊使用者列表中的「編輯」按鈕進入使用者詳細頁面
|
|
25
|
+
4. **管理權限**:在使用者詳細頁面的「權限管理」部分設定使用者權限
|
|
26
|
+
5. **重設密碼**:在「密碼管理」部分為使用者重設新密碼
|
|
27
|
+
6. **封鎖使用者**:在「使用者狀態」部分封鎖或解封使用者
|
|
28
|
+
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
1. **域用戶管理**: 可看見 default role
|
|
31
|
+
## 權限說明
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
插件使用以下權限等級:
|
|
31
34
|
|
|
32
|
-
- **-1
|
|
33
|
-
- **0
|
|
34
|
-
- **4
|
|
35
|
-
- **8
|
|
36
|
-
- **16842756
|
|
37
|
-
-
|
|
35
|
+
- **-1**:root(超級管理員)
|
|
36
|
+
- **0**:已封鎖使用者
|
|
37
|
+
- **4**:系統保留
|
|
38
|
+
- **8**:訪客使用者
|
|
39
|
+
- **16842756**:預設使用者權限
|
|
40
|
+
- **其他值**:自訂權限
|
|
38
41
|
|
|
39
|
-
##
|
|
42
|
+
## 介面展示
|
|
40
43
|
|
|
41
|
-
###
|
|
44
|
+
### 使用者列表頁面
|
|
42
45
|
|
|
43
|
-

|
|
44
47
|
|
|
45
|
-
###
|
|
48
|
+
### 使用者詳細頁面
|
|
46
49
|
|
|
47
|
-

|
|
48
51
|
|
|
49
52
|
## 安全特性
|
|
50
53
|
|
|
51
|
-
- ✅
|
|
52
|
-
- ✅
|
|
53
|
-
- ✅
|
|
54
|
-
- ✅
|
|
55
|
-
- ✅
|
|
54
|
+
- ✅ 權限驗證:只有具有系統管理權限的使用者才能訪問
|
|
55
|
+
- ✅ 操作確認:重要操作(如重設密碼、封鎖使用者)需確認
|
|
56
|
+
- ✅ 權限保護:防止非超級管理員修改超級管理員帳戶
|
|
57
|
+
- ✅ 資料驗證:自動驗證使用者名稱與電子郵件唯一性
|
|
58
|
+
- ✅ 輸入驗證:前端與後端雙重驗證使用者輸入
|
|
56
59
|
|
|
57
|
-
##
|
|
60
|
+
## 開發說明
|
|
58
61
|
|
|
59
|
-
###
|
|
62
|
+
### 貢獻程式碼
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
歡迎提交 Issue 與 Pull Request 改進此插件。
|
|
62
65
|
|
|
63
|
-
##
|
|
66
|
+
## 授權
|
|
64
67
|
|
|
65
68
|
MIT License
|
|
66
69
|
|
|
67
|
-
##
|
|
70
|
+
## 支援
|
|
68
71
|
|
|
69
|
-
|
|
72
|
+
若您在使用過程中遇到問題,請:
|
|
70
73
|
|
|
71
|
-
1. 查看 [Issues](https://github.com/SummerofOrange/hydrooj-user-management/issues)
|
|
72
|
-
2. 提交新的 Issue
|
|
73
|
-
3.
|
|
74
|
+
1. 查看 [Issues](https://github.com/SummerofOrange/hydrooj-user-management/issues) 頁面
|
|
75
|
+
2. 提交新的 Issue 描述您的問題
|
|
76
|
+
3. 聯絡作者取得技術支援
|
|
74
77
|
|
|
75
78
|
---
|
|
76
79
|
|
|
77
|
-
|
|
80
|
+
**注意**:此插件需 HydroOJ v5.0.0-beta.6 或更高版本。使用前請確保您有足夠的系統管理權限。
|
package/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Context, Handler, param, PRIV, Types, UserModel, DomainModel,
|
|
3
|
-
ValidationError, UserNotFoundError, PermissionError, Time, SystemModel, moment
|
|
3
|
+
ValidationError, UserNotFoundError, PermissionError, Time, SystemModel, moment,
|
|
4
|
+
PERM
|
|
4
5
|
} from 'hydrooj';
|
|
6
|
+
import domain from 'hydrooj/src/model/domain';
|
|
5
7
|
|
|
6
8
|
declare module 'hydrooj' {
|
|
7
9
|
interface Collections {
|
|
@@ -210,6 +212,77 @@ export async function apply(ctx: Context) {
|
|
|
210
212
|
// 在控制面板侧边栏添加用户管理菜单项
|
|
211
213
|
ctx.injectUI('ControlPanel', 'user_manage_main', { icon: 'user' });
|
|
212
214
|
|
|
215
|
+
ctx.withHandlerClass('DomainUser', (DomainUserHandler: { prototype: any }) => {
|
|
216
|
+
const originalGet = DomainUserHandler.prototype.get;
|
|
217
|
+
|
|
218
|
+
// 包裝原方法
|
|
219
|
+
DomainUserHandler.prototype.get = async function() {
|
|
220
|
+
const { domainId } = this.args;
|
|
221
|
+
const format = this.args.format || 'default';
|
|
222
|
+
console.log('DomainUserHandler get called with domainId:', domainId, 'format:', format);
|
|
223
|
+
const [dudocs, roles] = await Promise.all([
|
|
224
|
+
domain.collUser.aggregate([
|
|
225
|
+
{
|
|
226
|
+
$match: {
|
|
227
|
+
// TODO: add a page to display users who joined but with default role
|
|
228
|
+
role: {
|
|
229
|
+
$nin: ['guest'],
|
|
230
|
+
$ne: null,
|
|
231
|
+
},
|
|
232
|
+
domainId,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
$lookup: {
|
|
237
|
+
from: 'user',
|
|
238
|
+
let: { uid: '$uid' },
|
|
239
|
+
pipeline: [
|
|
240
|
+
{
|
|
241
|
+
$match: {
|
|
242
|
+
$expr: { $eq: ['$_id', '$$uid'] },
|
|
243
|
+
priv: { $bitsAllSet: PRIV.PRIV_USER_PROFILE },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
$project: {
|
|
248
|
+
_id: 1,
|
|
249
|
+
uname: 1,
|
|
250
|
+
avatar: 1,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
as: 'user',
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{ $unwind: '$user' },
|
|
258
|
+
{
|
|
259
|
+
$project: {
|
|
260
|
+
user: 1,
|
|
261
|
+
role: 1,
|
|
262
|
+
join: 1,
|
|
263
|
+
...(this.user.hasPerm(PERM.PERM_VIEW_USER_PRIVATE_INFO) ? { displayName: 1 } : {}),
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
]).toArray(),
|
|
267
|
+
domain.getRoles(domainId),
|
|
268
|
+
]);
|
|
269
|
+
const users = dudocs.map((dudoc) => {
|
|
270
|
+
const u = {
|
|
271
|
+
...dudoc,
|
|
272
|
+
...dudoc.user,
|
|
273
|
+
};
|
|
274
|
+
delete u.user;
|
|
275
|
+
return u;
|
|
276
|
+
});
|
|
277
|
+
const rudocs: Record<string, any[]> = {};
|
|
278
|
+
for (const role of roles) rudocs[role._id] = users.filter((udoc) => udoc.role === role._id);
|
|
279
|
+
this.response.template = format === 'raw' ? 'domain_user_raw.html' : 'domain_user.html';
|
|
280
|
+
this.response.body = {
|
|
281
|
+
roles, rudocs, domain: this.domain,
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
return DomainUserHandler;
|
|
285
|
+
});
|
|
213
286
|
// 添加国际化支持
|
|
214
287
|
ctx.i18n.load('zh', {
|
|
215
288
|
'user_manage_main': '用户管理',
|
|
@@ -275,6 +348,71 @@ export async function apply(ctx: Context) {
|
|
|
275
348
|
'Copy User ID': '复制用户ID'
|
|
276
349
|
});
|
|
277
350
|
|
|
351
|
+
// 添加国际化支持
|
|
352
|
+
ctx.i18n.load('zh_TW', {
|
|
353
|
+
'user_manage_main': '用戶管理',
|
|
354
|
+
'user_manage_detail': '用戶詳情',
|
|
355
|
+
|
|
356
|
+
'User Management': '用戶管理',
|
|
357
|
+
'User List': '用戶列表',
|
|
358
|
+
'Search Users': '搜尋用戶',
|
|
359
|
+
'Search by': '搜尋方式',
|
|
360
|
+
'Username': '用戶名',
|
|
361
|
+
'Email': '電子郵件',
|
|
362
|
+
'User ID': '用戶ID',
|
|
363
|
+
'Keyword': '關鍵字',
|
|
364
|
+
'Sort by': '排序方式',
|
|
365
|
+
'Registration Time': '註冊時間',
|
|
366
|
+
'Last Login': '最後登入',
|
|
367
|
+
'Privilege': '權限',
|
|
368
|
+
'Order': '順序',
|
|
369
|
+
'Ascending': '升序',
|
|
370
|
+
'Descending': '降序',
|
|
371
|
+
'Search': '搜尋',
|
|
372
|
+
'Clear': '清除',
|
|
373
|
+
'Refresh': '刷新',
|
|
374
|
+
|
|
375
|
+
'Normal User': '普通用戶',
|
|
376
|
+
'Admin': '管理員',
|
|
377
|
+
'Banned': '已封禁',
|
|
378
|
+
'Super Admin': '超級管理員',
|
|
379
|
+
'Active': '活躍',
|
|
380
|
+
'Inactive': '不活躍',
|
|
381
|
+
'Actions': '操作',
|
|
382
|
+
'View': '查看',
|
|
383
|
+
'Edit': '編輯',
|
|
384
|
+
'Ban': '封禁',
|
|
385
|
+
'Unban': '解封',
|
|
386
|
+
'Set Privilege': '設置權限',
|
|
387
|
+
'Status': '狀態',
|
|
388
|
+
'School': '學校',
|
|
389
|
+
'Bio': '個人簡介',
|
|
390
|
+
'Never': '從未',
|
|
391
|
+
'Not set': '未設置',
|
|
392
|
+
'Previous': '上一頁',
|
|
393
|
+
'Next': '下一頁',
|
|
394
|
+
'Page': '頁',
|
|
395
|
+
'of': '共',
|
|
396
|
+
'users': '用戶',
|
|
397
|
+
'Total': '總計',
|
|
398
|
+
'Showing': '顯示',
|
|
399
|
+
'to': '到',
|
|
400
|
+
'User Details': '用戶詳情',
|
|
401
|
+
'Basic Information': '基本資訊',
|
|
402
|
+
'User Statistics': '用戶統計',
|
|
403
|
+
'Privilege Management': '權限管理',
|
|
404
|
+
'Password Management': '密碼管理',
|
|
405
|
+
'User Status': '用戶狀態',
|
|
406
|
+
'Back to List': '返回列表',
|
|
407
|
+
'Save Changes': '保存更改',
|
|
408
|
+
'Cancel': '取消',
|
|
409
|
+
'Reset Password': '重設密碼',
|
|
410
|
+
'Current Privilege': '當前權限',
|
|
411
|
+
'Ban User': '封禁用戶',
|
|
412
|
+
'Unban User': '解封用戶',
|
|
413
|
+
'Copy User ID': '複製用戶ID'
|
|
414
|
+
});
|
|
415
|
+
|
|
278
416
|
ctx.i18n.load('en', {
|
|
279
417
|
'user_manage_main': 'User Management',
|
|
280
418
|
'user_manage_detail': 'User Detail',
|
package/package.json
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
{% extends "domain_base.html" %}
|
|
2
|
+
{% block domain_content %}
|
|
3
|
+
{{ set(UiContext, 'roles', roles.map(eval('(i) => i._id'))) }}
|
|
4
|
+
{{ set(UiContext, 'canForceJoin', model.system.get('server.allowInvite') or handler.user.hasPriv(PRIV.PRIV_MANAGE_ALL_DOMAIN)) }}
|
|
5
|
+
{% set _rolesSelect = roles.filter(eval("(i) => i._id !== 'guest'")).map(eval('(role) => [role._id, role._id]')) %}
|
|
6
|
+
<div class="section">
|
|
7
|
+
<div class="section__header">
|
|
8
|
+
<h1 class="section__title">{{ _('{0}: Users').format(domain.name) }}</h1>
|
|
9
|
+
<div class="section__tools">
|
|
10
|
+
<button class="primary rounded button" name="add_user">{{ _('Add User') }}</button>
|
|
11
|
+
</div>
|
|
12
|
+
<!-- Search bar for Role -->
|
|
13
|
+
<div class="section__search" style="margin-top: 10px;">
|
|
14
|
+
<input type="text" id="roleSearch" placeholder="{{ _('Search by Role') }}" class="input" style="width: 200px;">
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
{{ noscript_note.render() }}
|
|
18
|
+
<div class="section__body no-padding domain-users">
|
|
19
|
+
<table class="data-table" id="userTable">
|
|
20
|
+
<colgroup>
|
|
21
|
+
<col class="col--checkbox">
|
|
22
|
+
<col class="col--uid">
|
|
23
|
+
<col class="col--user">
|
|
24
|
+
<col class="col--role">
|
|
25
|
+
</colgroup>
|
|
26
|
+
<thead>
|
|
27
|
+
<tr>
|
|
28
|
+
<th class="col--checkbox">
|
|
29
|
+
<label class="compact checkbox">
|
|
30
|
+
<input type="checkbox" name="select_all" data-checkbox-toggle="user">
|
|
31
|
+
</label>
|
|
32
|
+
</th>
|
|
33
|
+
<th class="col--uid">{{ _('User ID') }}</th>
|
|
34
|
+
<th class="col--user">{{ _('Username') }}</th>
|
|
35
|
+
<th class="col--role">{{ _('Role') }}</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
{%- for role, udocs in rudocs -%}
|
|
40
|
+
{%- if udocs|length > 50 -%}
|
|
41
|
+
<tr data-role="{{ role }}">
|
|
42
|
+
<td colspan="3" style="text-wrap: wrap;"><div style="max-height: 300px; overflow-y: auto">
|
|
43
|
+
{%- for udoc in udocs -%}
|
|
44
|
+
{% set is_disabled=(rudoc._id == handler.user._id) %}
|
|
45
|
+
<input type="checkbox" data-uid="{{udoc._id}}" data-checkbox-group="user" {% if is_disabled %}disabled{% else %}data-checkbox-range{% endif %}>
|
|
46
|
+
[{{udoc._id}}]{{ user.render_inline(udoc, avatar=false, badge=false) }}{% if not udoc.join %}
|
|
47
|
+
<span class="not-joined">({{ _('Not joined yet') }})</span>
|
|
48
|
+
{% endif %}
|
|
49
|
+
{%- endfor -%}
|
|
50
|
+
</div></td>
|
|
51
|
+
<td>{{ role }}</td>
|
|
52
|
+
</tr>
|
|
53
|
+
{%- else -%}
|
|
54
|
+
{%- for rudoc in udocs -%}
|
|
55
|
+
{% set is_disabled=(rudoc._id == handler.user._id) %}
|
|
56
|
+
<tr data-role="{{ rudoc.role }}" data-uid="{{ rudoc._id }}">
|
|
57
|
+
<td class="col--checkbox">
|
|
58
|
+
<label class="compact checkbox">
|
|
59
|
+
<input type="checkbox" data-checkbox-group="user" {% if is_disabled %}disabled{% else %}data-checkbox-range{% endif %}>
|
|
60
|
+
</label>
|
|
61
|
+
</td>
|
|
62
|
+
<td class="col--uid">
|
|
63
|
+
{{ rudoc._id }}
|
|
64
|
+
</td>
|
|
65
|
+
<td class="col--user">
|
|
66
|
+
{{ user.render_inline(rudoc, badge=false) }}
|
|
67
|
+
{% if not rudoc.join %}<span class="text-orange">({{ _('Not joined yet') }})</span>{% endif %}
|
|
68
|
+
</td>
|
|
69
|
+
<td class="col--role">
|
|
70
|
+
{{ form.select({
|
|
71
|
+
options:_rolesSelect,
|
|
72
|
+
name:'role',
|
|
73
|
+
value:rudoc.role,
|
|
74
|
+
extra_class:'compact',
|
|
75
|
+
disabled:is_disabled
|
|
76
|
+
}) }}
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
{%- endfor -%}
|
|
80
|
+
{%- endif -%}
|
|
81
|
+
{%- endfor -%}
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="section__body">
|
|
86
|
+
<button class="rounded button" name="remove_selected">{{ _('Remove Selected User') }}</button>
|
|
87
|
+
<button class="rounded button" name="set_roles">{{ _('Set Roles for Selected User') }}</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<script>
|
|
91
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
92
|
+
var searchInput = document.getElementById('roleSearch');
|
|
93
|
+
searchInput.addEventListener('input', function() {
|
|
94
|
+
var filter = searchInput.value.toLowerCase();
|
|
95
|
+
var rows = document.querySelectorAll('#userTable tbody tr');
|
|
96
|
+
rows.forEach(function(row) {
|
|
97
|
+
var role = row.getAttribute('data-role');
|
|
98
|
+
if (role && role.toLowerCase().indexOf(filter) !== -1) {
|
|
99
|
+
row.style.display = '';
|
|
100
|
+
} else if (filter === '') {
|
|
101
|
+
row.style.display = '';
|
|
102
|
+
} else {
|
|
103
|
+
row.style.display = 'none';
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
</script>
|
|
109
|
+
{% endblock %}
|
|
@@ -134,14 +134,43 @@
|
|
|
134
134
|
</div>
|
|
135
135
|
|
|
136
136
|
<style>
|
|
137
|
-
.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
.section__body.no-padding {
|
|
138
|
+
max-width: 100%;
|
|
139
|
+
overflow-x: hidden;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.data-table {
|
|
143
|
+
width: 100%;
|
|
144
|
+
table-layout: fixed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.data-table th,
|
|
148
|
+
.data-table td {
|
|
149
|
+
word-break: break-word;
|
|
150
|
+
overflow-wrap: anywhere;
|
|
151
|
+
white-space: normal;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.data-table td {
|
|
155
|
+
max-width: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.data-table td a,
|
|
159
|
+
.data-table td span,
|
|
160
|
+
.data-table td small {
|
|
161
|
+
word-break: break-word;
|
|
162
|
+
overflow-wrap: anywhere;
|
|
163
|
+
white-space: normal;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.col--uid { width: 8%; }
|
|
167
|
+
.col--user { width: 17%; }
|
|
168
|
+
.col--email { width: 19%; }
|
|
169
|
+
.col--regat { width: 13%; }
|
|
170
|
+
.col--loginat { width: 13%; }
|
|
171
|
+
.col--priv { width: 10%; }
|
|
172
|
+
.col--status { width: 8%; }
|
|
173
|
+
.col--actions { width: 12%; }
|
|
145
174
|
|
|
146
175
|
.badge {
|
|
147
176
|
display: inline-block;
|
|
@@ -171,6 +200,27 @@
|
|
|
171
200
|
padding: 4px 8px;
|
|
172
201
|
font-size: 12px;
|
|
173
202
|
}
|
|
203
|
+
|
|
204
|
+
.col--actions .button--small {
|
|
205
|
+
display: block;
|
|
206
|
+
width: 100%;
|
|
207
|
+
max-width: 100%;
|
|
208
|
+
min-width: 0;
|
|
209
|
+
box-sizing: border-box;
|
|
210
|
+
padding: 4px 4px;
|
|
211
|
+
line-height: 1.2;
|
|
212
|
+
white-space: normal !important;
|
|
213
|
+
overflow-wrap: anywhere;
|
|
214
|
+
word-break: break-word;
|
|
215
|
+
text-align: center;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@media (max-width: 768px) {
|
|
219
|
+
.button--small {
|
|
220
|
+
padding: 3px 6px;
|
|
221
|
+
font-size: 11px;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
174
224
|
</style>
|
|
175
225
|
|
|
176
226
|
<script>
|