server: fix project_shares audit logging and add share transfer
- Fix PATCH /api/projects/:id/shares/:userId: query SELECT 1 instead of SELECT id (project_shares has a composite PK, no id column) - Add audit logging to existing share grant/revoke routes - Add POST /api/projects/:id/transfer for ownership handoff - Remove inline admin user/audit routes from routes.ts (now in admin.ts) - Remove unused requireAdmin import and userPatch schema from routes.ts
This commit is contained in:
+34
-36
@@ -3,9 +3,9 @@ import { z } from "zod";
|
||||
import { ulid } from "ulid";
|
||||
import {
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
canAccessProject,
|
||||
} from "./auth.js";
|
||||
import { auditLog } from "./db.js";
|
||||
import {
|
||||
toProject,
|
||||
toProjectMeta,
|
||||
@@ -36,6 +36,8 @@ const projectPatch = z.object({
|
||||
cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(),
|
||||
});
|
||||
const shareAdd = z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) });
|
||||
const sharePatch = z.object({ perm: z.enum(["read", "edit"]) });
|
||||
const transferReq = z.object({ userId: z.string() });
|
||||
const bookCreate = z.object({ title: z.string().min(1).max(200) });
|
||||
const bookPatch = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
@@ -98,11 +100,6 @@ const aiSessionCreate = z.object({
|
||||
});
|
||||
const aiMessageAppend = z.object({ role: z.string(), content: z.string() });
|
||||
const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() });
|
||||
const userPatch = z.object({
|
||||
role: z.enum(["admin", "author"]).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
// ---- helpers ----
|
||||
function requireProjectAccess(app: FastifyInstance, req: any, projectId: string, needWrite = false): void {
|
||||
@@ -192,6 +189,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
requireProjectAccess(app, req, id, true);
|
||||
const { userId, perm } = shareAdd.parse(req.body);
|
||||
db.prepare("INSERT OR REPLACE INTO project_shares (project_id, user_id, perm) VALUES (?, ?, ?)").run(id, userId, perm);
|
||||
auditLog(db, "share_grant", req.user!.id, { projectId: id, userId, perm });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -199,6 +197,36 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
const { id, userId } = req.params as { id: string; userId: string };
|
||||
requireProjectAccess(app, req, id, true);
|
||||
db.prepare("DELETE FROM project_shares WHERE project_id = ? AND user_id = ?").run(id, userId);
|
||||
auditLog(db, "share_revoke", req.user!.id, { projectId: id, userId });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.patch("/api/projects/:id/shares/:userId", async (req) => {
|
||||
const { id, userId } = req.params as { id: string; userId: string };
|
||||
requireProjectAccess(app, req, id, true);
|
||||
const { perm } = sharePatch.parse(req.body);
|
||||
const existing = db
|
||||
.prepare("SELECT 1 FROM project_shares WHERE project_id = ? AND user_id = ?")
|
||||
.get(id, userId) as { id: string } | undefined;
|
||||
if (!existing) throw app.httpErrors.create(404, { error: "share_not_found" });
|
||||
db.prepare("UPDATE project_shares SET perm = ? WHERE project_id = ? AND user_id = ?").run(perm, id, userId);
|
||||
auditLog(db, "share_update", req.user!.id, { projectId: id, userId, perm });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/api/projects/:id/transfer", async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { userId } = transferReq.parse(req.body);
|
||||
requireAuth(req);
|
||||
const row = projectRow(db, id);
|
||||
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
||||
if (row.owner_id !== req.user!.id) throw app.httpErrors.create(403, { error: "forbidden" });
|
||||
const target = db
|
||||
.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0")
|
||||
.get(userId) as { id: string } | undefined;
|
||||
if (!target) throw app.httpErrors.create(404, { error: "user_not_found" });
|
||||
db.prepare("UPDATE projects SET owner_id = ? WHERE id = ?").run(userId, id);
|
||||
auditLog(db, "project_transfer", req.user!.id, { projectId: id, newOwnerId: userId });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -620,34 +648,4 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
|
||||
return { session: toAISession(updated) };
|
||||
});
|
||||
|
||||
// ---------- Admin ----------
|
||||
app.get("/api/admin/users", async (req) => {
|
||||
requireAdmin(req);
|
||||
const rows = db.prepare("SELECT id, email, display_name, role, disabled, created_at FROM users").all() as any[];
|
||||
return { users: rows };
|
||||
});
|
||||
|
||||
app.patch("/api/admin/users/:id", async (req) => {
|
||||
requireAdmin(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const body = userPatch.parse(req.body);
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any;
|
||||
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
||||
const role = body.role ?? row.role;
|
||||
const disabled = body.disabled ? 1 : row.disabled;
|
||||
const displayName = body.displayName ?? row.display_name;
|
||||
db.prepare("UPDATE users SET role = ?, disabled = ?, display_name = ? WHERE id = ?").run(role, disabled, displayName, id);
|
||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.user_update', ?)").run(
|
||||
ulid(), now(), req.user!.id, JSON.stringify({ target: id, role, disabled: !!disabled }),
|
||||
);
|
||||
return { user: { id, email: row.email, displayName, role, disabled: !!disabled, createdAt: row.created_at } };
|
||||
});
|
||||
|
||||
app.get("/api/admin/audit", async (req) => {
|
||||
requireAdmin(req);
|
||||
const limit = Math.min(Number((req.query as any).limit ?? 100), 500);
|
||||
const rows = db.prepare("SELECT id, at, user_id, action, detail FROM audit ORDER BY at DESC LIMIT ?").all(limit) as any[];
|
||||
return { audit: rows.map((r) => ({ ...r, detail: JSON.parse(r.detail) })) };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user