feat(编辑角色): editor 只能写并发布自己的文章
- D1 users 加 role 列(''/editor/admin),与 is_admin 成对写入 - Worker routes/editor.ts 放行 admin+editor,反代注入 X-Editor-Uid/User/Role(昵称 encodeURIComponent) - editor-api 引入 identify():身份取自注入头或本机会话;文章归属记 frontmatter author_id - 编辑发布改精确 pathspec(只提交自己文章目录),管理员保持全量;空 pathspec 显式拦截 - 国内机直连登录改为转发 CF /user/access_token 校验,CF 不可达回退本机管理员 - handoff 签名覆盖身份(ts/uid/name/role),防编辑一键跳转变管理员 - admin.js:编辑只渲染「文章编辑」tab、作者框只读;用户管理加角色下拉 + 新建用户
This commit is contained in:
1 parent
66c27c7b62
commit
d899cd793b
14 files changed
+1172
-139
No files matched your search
@@ -13,6 +13,7 @@ import type {
|
||||
import { findPagesByKeys, findUsersByIds, siteFirstUrl } from './db';
|
||||
import { renderMarkdown } from './md';
|
||||
import { md5Lower } from './md5';
|
||||
import { roleOf } from './role';
|
||||
import { formatDateCN } from './util';
|
||||
|
||||
// ============================================================================
|
||||
@@ -126,6 +127,7 @@ export function cookUser(u: UserRow): CookedUser {
|
||||
badge_color: u.badge_color || '',
|
||||
title_name: u.title_name || '',
|
||||
is_admin: !!u.is_admin,
|
||||
role: roleOf(u),
|
||||
receive_email: !!u.receive_email,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 后台角色。
|
||||
*
|
||||
* 为什么单独一个小模块:`cook.ts`(输出用户结构)、`session.ts`(会话)、
|
||||
* `routes/editor.ts`(编辑器鉴权)、`routes/admin.ts`(用户管理)都要判角色,
|
||||
* 谁 import 谁都不合适 —— 放这里谁都能用,也不会绕出循环依赖。
|
||||
*
|
||||
* 两处事实源的关系:
|
||||
* · `users.is_admin` 是历史字段,评论/通知/设置等一大堆既有逻辑都在用它 —— 不动。
|
||||
* · `users.role` 是这次新增的('' 普通评论用户 | 'editor' 编辑 | 'admin' 管理员),
|
||||
* 只有它才能表达「编辑」这一级。
|
||||
* · 两者必须同步:role='admin' ⇔ is_admin=1。写入走 `normalizeRole()`,
|
||||
* 读取走 `roleOf()`(以 is_admin 优先,兜住没同步过的历史行)。
|
||||
*/
|
||||
import type { UserRole } from '../types';
|
||||
|
||||
/** 判角色只需要这两个字段,别把整个 UserRow 拖进来 */
|
||||
export interface RoleCarrier {
|
||||
is_admin?: number | boolean | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化读取:只认三种结果。
|
||||
* ★ is_admin 优先 —— 历史行可能没有 role 列的值(ALTER 之前建的账号),
|
||||
* 但 is_admin=1 是确定的,不能因为 role 是空串就把老管理员降级。
|
||||
*/
|
||||
export function roleOf(u: RoleCarrier | null | undefined): UserRole {
|
||||
if (!u) return 'user';
|
||||
if (u.is_admin) return 'admin';
|
||||
return String(u.role || '') === 'editor' ? 'editor' : 'user';
|
||||
}
|
||||
|
||||
/** 写入方向:把前端传来的一对(role / is_admin)折叠成一个规范值 */
|
||||
export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'editor' | '' {
|
||||
const r = String(role ?? '').trim().toLowerCase();
|
||||
if (r === 'admin' || r === 'editor') return r;
|
||||
if (r === '' && isAdmin === undefined) return '';
|
||||
// 没给 role(老前端只给 is_admin)时按 is_admin 推
|
||||
if (role === undefined || role === null) return isAdmin ? 'admin' : '';
|
||||
// 给了个不认识的值:不猜,退回 is_admin 的语义
|
||||
return isAdmin ? 'admin' : '';
|
||||
}
|
||||
|
||||
/** 能进写作后台的角色(管理员 + 编辑) */
|
||||
export function canWritePosts(u: RoleCarrier | null | undefined): boolean {
|
||||
const r = roleOf(u);
|
||||
return r === 'admin' || r === 'editor';
|
||||
}
|
||||
|
||||
/** 编辑角色在文章维度上只能碰自己的(管理员不受限) */
|
||||
export function isScopedEditor(u: RoleCarrier | null | undefined): boolean {
|
||||
return roleOf(u) === 'editor';
|
||||
}
|
||||
|
||||
/** 给前端展示的角色名(后台用户管理列表用) */
|
||||
export function roleLabel(role: UserRole): string {
|
||||
return role === 'admin' ? '管理员' : role === 'editor' ? '编辑' : '';
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Env, SessionUser, UserRow } from '../types';
|
||||
import { findUserByEmail, findUserById, getAdminUsers } from './db';
|
||||
import { md5 } from './md5';
|
||||
import { roleOf } from './role';
|
||||
import { now } from './util';
|
||||
|
||||
// ================================================================= 编解码
|
||||
@@ -166,7 +167,7 @@ export async function userFromToken(env: Env, authHeader: string | null): Promis
|
||||
|
||||
export function sessionOf(user: UserRow | null): SessionUser | null {
|
||||
if (!user) return null;
|
||||
return { id: user.id, name: user.name, email: user.email, isAdmin: !!user.is_admin };
|
||||
return { id: user.id, name: user.name, email: user.email, isAdmin: !!user.is_admin, role: roleOf(user) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findOrCreateSite,
|
||||
findOrCreateUser,
|
||||
findUserByNameEmail,
|
||||
getAdminUsers,
|
||||
getFrontendConf,
|
||||
isCaptchaEnabled,
|
||||
isIPRegionEnabled,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { isHumanCheckEnabled, setHumanCheck } from '../lib/human';
|
||||
import { cookComments, cookPage, cookSite, cookUser } from '../lib/cook';
|
||||
import { hashPassword, isAdminRequest } from '../lib/session';
|
||||
import { normalizeRole } from '../lib/role';
|
||||
import { fail, formatDateCN, now, ok, okMsg, qInt, qp, readBody, trimTo } from '../lib/util';
|
||||
import { parseSimpleYaml, toSimpleYaml } from '../lib/simple-yaml';
|
||||
import { extractForm, formComments, mergeForm, SETTINGS_FORM } from '../lib/settings-form';
|
||||
@@ -277,16 +279,18 @@ export async function userCreate(ctx: Ctx): Promise<Response> {
|
||||
if (existed) return fail(400, 'User already exists');
|
||||
|
||||
const t = now();
|
||||
const role = normalizeRole(body.role, body.is_admin);
|
||||
await ctx.env.DB.prepare(
|
||||
`INSERT INTO users (name, email, link, password, is_admin, badge_name, badge_color, title_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO users (name, email, link, password, is_admin, role, badge_name, badge_color, title_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
name,
|
||||
email,
|
||||
trimTo(body.link || '', 255),
|
||||
password ? await hashPassword(password) : '',
|
||||
body.is_admin ? 1 : 0,
|
||||
role === 'admin' ? 1 : 0,
|
||||
role,
|
||||
trimTo(body.badge_name || '', 60),
|
||||
trimTo(body.badge_color || '', 20),
|
||||
trimTo(body.title_name || '', 30),
|
||||
@@ -345,9 +349,33 @@ export async function userUpdateAdmin(ctx: Ctx): Promise<Response> {
|
||||
sets.push('password = ?', 'token_valid_from = ?');
|
||||
binds.push(await hashPassword(String(body.password)), now());
|
||||
}
|
||||
if (body.is_admin !== undefined) {
|
||||
sets.push('is_admin = ?');
|
||||
binds.push(body.is_admin ? 1 : 0);
|
||||
|
||||
// ---------------------------------------------------------------- 角色
|
||||
// role 与 is_admin 是一对(见 lib/role.ts),必须一起写,不能只写一个 ——
|
||||
// 否则会出现「role='editor' 但 is_admin=1」这种自相矛盾的行,
|
||||
// 而 is_admin 在评论/通知/设置那几十处旧逻辑里是权威。
|
||||
if (body.role !== undefined || body.is_admin !== undefined) {
|
||||
const next = normalizeRole(body.role, body.is_admin);
|
||||
|
||||
// ① 不能把自己降下去 —— 否则当场失去用户管理入口,只能去改数据库
|
||||
if (ctx.user && ctx.user.id === id && next !== 'admin') {
|
||||
return fail(400, '不能修改自己的角色:请让另一位管理员来操作,避免把自己锁在门外');
|
||||
}
|
||||
// ② 也不能把最后一个管理员降下去(全站就没人能管用户了)
|
||||
if (next !== 'admin') {
|
||||
const admins = await ctx.env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM users WHERE is_admin = 1 AND id != ?',
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ n: number }>();
|
||||
const confAdmins = await getAdminUsers(ctx.env);
|
||||
if ((admins?.n ?? 0) === 0 && !confAdmins.length) {
|
||||
return fail(400, '这是最后一个管理员,不能取消它的管理员身份');
|
||||
}
|
||||
}
|
||||
|
||||
sets.push('role = ?', 'is_admin = ?');
|
||||
binds.push(next, next === 'admin' ? 1 : 0);
|
||||
}
|
||||
if (!sets.length) return okMsg();
|
||||
|
||||
|
||||
@@ -4,16 +4,29 @@
|
||||
* 为什么要绕这一跳,而不是让浏览器直连后端:
|
||||
* 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到;
|
||||
* 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口;
|
||||
* 3. 复用后台已有的登录会话 —— 这里只需要 isAdminRequest,不用再造一套账号体系。
|
||||
* 3. 复用后台已有的登录会话 —— 这里只需要一套「谁在写文章」的判定。
|
||||
*
|
||||
* 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`,
|
||||
* 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。
|
||||
* 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。
|
||||
*
|
||||
* ★★ 本文件是「谁能写博客」的唯一闸门,动它之前先读完下面两段。
|
||||
*/
|
||||
import type { Ctx } from '../router';
|
||||
import { fail, json } from '../lib/util';
|
||||
import { getAdminUsers } from '../lib/db';
|
||||
import { userFromToken } from '../lib/session';
|
||||
import { roleOf } from '../lib/role';
|
||||
|
||||
/**
|
||||
* 操作者身份:后端 editor-api 靠它判断「这篇文章是不是你的」。
|
||||
* role 只有两档 —— admin(不受限)和 editor(只能碰自己的文章)。
|
||||
*/
|
||||
interface EditorIdentity {
|
||||
id: number;
|
||||
name: string;
|
||||
role: 'admin' | 'editor';
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 鉴权只认「真实登录会话(Bearer token)」,**绝不能**用 isAdminRequest。
|
||||
@@ -25,31 +38,55 @@ import { userFromToken } from '../lib/session';
|
||||
* 删文章 + 传图 + 触发 git 发布(真推到 CNB/GitHub)」的能力裸露在公网,
|
||||
* 任何人拼一个 query 就能删你的博客。这里必须硬性要求会话。
|
||||
*
|
||||
* 已登录用户的判定与 isAdminRequest 一致(is_admin 或邮箱命中配置管理员),
|
||||
* 只是砍掉了「匿名 + query 参数」那一条。
|
||||
* 放行两种人(与「用户管理」里的角色一一对应):
|
||||
* · is_admin=1,或邮箱命中配置里的管理员 → admin
|
||||
* · users.role = 'editor' → editor
|
||||
* 普通评论用户(role='')没有登录过的话根本走不到这里;走得到也一律 403。
|
||||
*/
|
||||
async function requireAdminSession(ctx: Ctx) {
|
||||
async function requireEditorSession(ctx: Ctx): Promise<EditorIdentity | null> {
|
||||
const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization')));
|
||||
if (!user) return null;
|
||||
if (user.is_admin) return user;
|
||||
|
||||
const role = roleOf(user);
|
||||
if (role === 'admin') return { id: user.id, name: user.name, role: 'admin' };
|
||||
|
||||
// 已登录但没打 is_admin 标的老账号:邮箱命中配置里的管理员也算
|
||||
const admins = await getAdminUsers(ctx.env);
|
||||
return admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase())
|
||||
? user
|
||||
: null;
|
||||
if (admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase())) {
|
||||
return { id: user.id, name: user.name, role: 'admin' };
|
||||
}
|
||||
|
||||
if (role === 'editor') return { id: user.id, name: user.name, role: 'editor' };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function guard(ctx: Ctx): Promise<Response | null> {
|
||||
if (!(await requireAdminSession(ctx))) {
|
||||
return fail(403, '需要管理员权限');
|
||||
}
|
||||
/** 鉴权 + 后端配置检查。返回身份,或一个可以直接返回给浏览器的错误响应 */
|
||||
async function auth(ctx: Ctx): Promise<{ ident: EditorIdentity } | { deny: Response }> {
|
||||
const ident = await requireEditorSession(ctx);
|
||||
if (!ident) return { deny: fail(403, '需要管理员或编辑权限') };
|
||||
if (!ctx.env.EDITOR_API_BASE) {
|
||||
return fail(503, '后端未配置:缺少 EDITOR_API_BASE');
|
||||
return { deny: fail(503, '后端未配置:缺少 EDITOR_API_BASE') };
|
||||
}
|
||||
if (!ctx.env.EDITOR_TOKEN) {
|
||||
return fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)');
|
||||
return { deny: fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)') };
|
||||
}
|
||||
return null;
|
||||
return { ident };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把身份放进请求头传给后端。
|
||||
*
|
||||
* ★ 名字必须 encodeURIComponent —— HTTP 头是 byte-string,
|
||||
* 中文昵称("群林")直接塞进去会被丢字符甚至整条请求报错。
|
||||
* 后端拿到后 decode,所以那边看到的是原文。
|
||||
*/
|
||||
function editorHeaders(env: Ctx['env'], ident: EditorIdentity): Record<string, string> {
|
||||
return {
|
||||
'X-Editor-Token': String(env.EDITOR_TOKEN),
|
||||
'X-Editor-Uid': String(ident.id),
|
||||
'X-Editor-User': encodeURIComponent(ident.name),
|
||||
'X-Editor-Role': ident.role,
|
||||
};
|
||||
}
|
||||
|
||||
function target(ctx: Ctx, path: string, withQuery = false): string {
|
||||
@@ -59,15 +96,15 @@ function target(ctx: Ctx, path: string, withQuery = false): string {
|
||||
|
||||
/** 发到后端并原样把 JSON 与状态码带回来 */
|
||||
async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise<Response> {
|
||||
const deny = await guard(ctx);
|
||||
if (deny) return deny;
|
||||
const a = await auth(ctx);
|
||||
if ('deny' in a) return a.deny;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(target(ctx, path, withQuery), {
|
||||
...init,
|
||||
headers: {
|
||||
'X-Editor-Token': String(ctx.env.EDITOR_TOKEN),
|
||||
...editorHeaders(ctx.env, a.ident),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
@@ -125,8 +162,8 @@ export const deletePost = (ctx: Ctx) =>
|
||||
* Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。
|
||||
*/
|
||||
export async function upload(ctx: Ctx): Promise<Response> {
|
||||
const deny = await guard(ctx);
|
||||
if (deny) return deny;
|
||||
const a = await auth(ctx);
|
||||
if ('deny' in a) return a.deny;
|
||||
|
||||
const ct = ctx.req.headers.get('Content-Type') || '';
|
||||
if (!ct.startsWith('image/')) return fail(400, '只接受图片');
|
||||
@@ -135,7 +172,7 @@ export async function upload(ctx: Ctx): Promise<Response> {
|
||||
const res = await fetch(target(ctx, '/upload', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Editor-Token': String(ctx.env.EDITOR_TOKEN),
|
||||
...editorHeaders(ctx.env, a.ident),
|
||||
'Content-Type': ct,
|
||||
},
|
||||
body: ctx.req.body,
|
||||
@@ -163,17 +200,6 @@ export const sync = (ctx: Ctx) => relay(ctx, '/git/sync', { method: 'POST' });
|
||||
|
||||
// ---------------------------------------------------------------- 一键跳转
|
||||
|
||||
/**
|
||||
* 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 URL。
|
||||
*
|
||||
* 怎么做到免登录:浏览器直接开 writeapi.usj.cc/admin/ 是要重新输账号密码的
|
||||
* (那边是独立的 Cookie 会话)。这里让 Worker 用共享令牌对当前时间戳做
|
||||
* HMAC 签名,前端拿着签名去 editor-api 的 /admin/handoff 换会话 Cookie ——
|
||||
* 两边本来就共享 EDITOR_TOKEN,等于用已登录的 CF 会话给国内机做了一次担保。
|
||||
*
|
||||
* 安全边界:签名 2 分钟内有效、同一签名只能换一次会话、且只有已登录管理员
|
||||
* 能从 Worker 拿到签名。URL 全程 HTTPS,不含任何账号密码。
|
||||
*/
|
||||
async function hmacHex(secret: string, msg: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
@@ -187,20 +213,40 @@ async function hmacHex(secret: string, msg: string): Promise<string> {
|
||||
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 URL。
|
||||
*
|
||||
* 怎么做到免登录:浏览器直接开 writeapi.usj.cc/admin/ 是要重新输账号密码的
|
||||
* (那边是独立的 Cookie 会话)。这里让 Worker 用共享令牌对当前时间戳做
|
||||
* HMAC 签名,前端拿着签名去 editor-api 的 /admin/handoff 换会话 Cookie ——
|
||||
* 两边本来就共享 EDITOR_TOKEN,等于用已登录的 CF 会话给国内机做了一次担保。
|
||||
*
|
||||
* ★ 签名载荷里必须带**身份**(uid/名字/角色)。否则编辑点一下这个按钮,
|
||||
* 到了国内机就变成那个唯一的管理员账号(cfg.adminUser)—— 角色直接越权。
|
||||
* 后端按载荷建会话,编辑过去还是编辑。
|
||||
*
|
||||
* 安全边界:签名 2 分钟内有效、同一签名只能换一次会话、且只有已登录的
|
||||
* 管理员/编辑能从 Worker 拿到签名。URL 全程 HTTPS,不含任何账号密码。
|
||||
*/
|
||||
export async function handoff(ctx: Ctx): Promise<Response> {
|
||||
const deny = await guard(ctx);
|
||||
if (deny) return deny;
|
||||
const a = await auth(ctx);
|
||||
if ('deny' in a) return a.deny;
|
||||
|
||||
const base = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
|
||||
const ts = Date.now().toString();
|
||||
const sig = await hmacHex(String(ctx.env.EDITOR_TOKEN), ts);
|
||||
return json({ url: `${base}/api/v2/admin/handoff?ts=${ts}&t=${sig}` });
|
||||
const uid = String(a.ident.id);
|
||||
const name = a.ident.name;
|
||||
const role = a.ident.role;
|
||||
const sig = await hmacHex(String(ctx.env.EDITOR_TOKEN), `${ts}\n${uid}\n${name}\n${role}`);
|
||||
|
||||
const qs = new URLSearchParams({ ts, u: uid, n: name, r: role, t: sig });
|
||||
return json({ url: `${base}/api/v2/admin/handoff?${qs.toString()}` });
|
||||
}
|
||||
|
||||
/** 健康检查:让后台能显示「编辑后端是否在线」(同样只认真实会话) */
|
||||
export async function health(ctx: Ctx): Promise<Response> {
|
||||
if (!(await requireAdminSession(ctx))) return fail(403, '需要管理员权限');
|
||||
if (!ctx.env.EDITOR_API_BASE) return json({ ok: false, reason: '缺少 EDITOR_API_BASE' });
|
||||
const a = await auth(ctx);
|
||||
if ('deny' in a) return a.deny;
|
||||
try {
|
||||
const res = await fetch(target(ctx, '/health'), { method: 'GET' });
|
||||
return json({ ...(await res.json() as object), reachable: true });
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Env, UserRow } from '../types';
|
||||
import { findUserByEmail, findUserByName, findUserByNameEmail, rateLimit } from '../lib/db';
|
||||
import { cookNotify, cookUser } from '../lib/cook';
|
||||
import { isAdminByNameEmail, signToken, verifyPassword } from '../lib/session';
|
||||
import { roleOf } from '../lib/role';
|
||||
import { fail, getClientIP, isEmail, now, ok, okMsg, qp, readBody, trimTo } from '../lib/util';
|
||||
import type { Ctx } from '../router';
|
||||
|
||||
@@ -97,7 +98,7 @@ export async function userStatus(ctx: Ctx): Promise<Response> {
|
||||
const email = qp(url, 'email');
|
||||
|
||||
if (user) {
|
||||
return ok({ is_admin: !!user.is_admin, is_login: true });
|
||||
return ok({ is_admin: !!user.is_admin, role: roleOf(user), is_login: true });
|
||||
}
|
||||
if (name && email) {
|
||||
return ok({ is_admin: await isAdminByNameEmail(env, name, email), is_login: false });
|
||||
|
||||
@@ -81,6 +81,14 @@ export interface PageRow {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台角色。
|
||||
* 'user' —— 普通评论用户(默认,没有后台权限)
|
||||
* 'editor' —— 编辑:只能写/发布自己的文章,后台只看得到「文章编辑」
|
||||
* 'admin' —— 管理员:全部权限
|
||||
*/
|
||||
export type UserRole = 'admin' | 'editor' | 'user';
|
||||
|
||||
export interface UserRow {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -93,6 +101,8 @@ export interface UserRow {
|
||||
last_ip: string;
|
||||
last_ua: string;
|
||||
is_admin: number;
|
||||
/** '' | 'editor' | 'admin'(历史行可能为 '',读取请走 roleOf()) */
|
||||
role: string;
|
||||
receive_email: number;
|
||||
is_in_conf: number;
|
||||
token_valid_from: number;
|
||||
@@ -188,6 +198,8 @@ export interface CookedUser {
|
||||
badge_color: string;
|
||||
title_name?: string;
|
||||
is_admin: boolean;
|
||||
/** 归一化角色:前端据它决定导航能不能看到「评论管理/用户管理」等 */
|
||||
role: UserRole;
|
||||
receive_email: boolean;
|
||||
}
|
||||
|
||||
@@ -213,4 +225,5 @@ export interface SessionUser {
|
||||
name: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
role: UserRole;
|
||||
}
|
||||
Reference in new issue
Block a user