feat: 添加密码管理功能,包括 API、数据库支持和前端界面
This commit is contained in:
@@ -2,6 +2,16 @@ import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { requireAdmin, isAdminEmail } from "../lib/admin.js";
|
||||
import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js";
|
||||
import { encryptPassword, decryptPassword } from "../lib/crypto.js";
|
||||
|
||||
function normalizeOrigin(input) {
|
||||
try {
|
||||
const u = new URL(String(input || "").trim());
|
||||
return u.origin;
|
||||
} catch {
|
||||
throw httpError(400, "siteOrigin invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function toUserDtoWithAdminOverride(app, row) {
|
||||
const dto = userRowToDto(row);
|
||||
@@ -65,6 +75,125 @@ export async function adminRoutes(app) {
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/users/:id/credentials",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.id;
|
||||
if (!userId) throw httpError(400, "user id required");
|
||||
|
||||
const includePassword = String(req.query?.includePassword || "").toLowerCase() === "true";
|
||||
const res = await app.pg.query(
|
||||
"select * from credentials where user_id=$1 order by updated_at desc",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!includePassword) {
|
||||
return res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
siteOrigin: r.site_origin,
|
||||
username: r.username,
|
||||
password: null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
return res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
siteOrigin: r.site_origin,
|
||||
username: r.username,
|
||||
password: decryptPassword({
|
||||
cipherText: r.password_enc,
|
||||
iv: r.password_iv,
|
||||
tag: r.password_tag
|
||||
}, key),
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/admin/users/:userId/credentials/:credentialId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const credentialId = req.params?.credentialId;
|
||||
if (!userId || !credentialId) throw httpError(400, "userId and credentialId required");
|
||||
|
||||
const body = req.body || {};
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "siteOrigin")) {
|
||||
const origin = normalizeOrigin(body.siteOrigin);
|
||||
sets.push(`site_origin=$${i++}`);
|
||||
params.push(origin);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "username")) {
|
||||
const name = String(body.username || "").trim();
|
||||
if (!name) throw httpError(400, "username required");
|
||||
sets.push(`username=$${i++}`);
|
||||
params.push(name);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "password")) {
|
||||
const pwd = String(body.password || "");
|
||||
if (!pwd) throw httpError(400, "password required");
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const enc = encryptPassword(pwd, key);
|
||||
sets.push(`password_enc=$${i++}`);
|
||||
params.push(enc.cipherText);
|
||||
sets.push(`password_iv=$${i++}`);
|
||||
params.push(enc.iv);
|
||||
sets.push(`password_tag=$${i++}`);
|
||||
params.push(enc.tag);
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
params.push(credentialId, userId);
|
||||
const res = await app.pg.query(
|
||||
`update credentials set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
|
||||
params
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
|
||||
return {
|
||||
id: res.rows[0].id,
|
||||
userId: res.rows[0].user_id,
|
||||
siteOrigin: res.rows[0].site_origin,
|
||||
username: res.rows[0].username,
|
||||
password: null,
|
||||
createdAt: res.rows[0].created_at,
|
||||
updatedAt: res.rows[0].updated_at
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/credentials/:credentialId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const credentialId = req.params?.credentialId;
|
||||
if (!userId || !credentialId) throw httpError(400, "userId and credentialId required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"delete from credentials where id=$1 and user_id=$2 returning id",
|
||||
[credentialId, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/bookmarks/:bookmarkId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
|
||||
Reference in New Issue
Block a user