feat(28-01): add account management routes for password, email, and deletion
Creates /api/account routes with password change (verifies current first), email update, has-password check, and account deletion with public setup anonymization. Adds Zod validation schemas and registers routes in index. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { seedDefaults } from "../db/seed.ts";
|
|||||||
import { mcpRoutes } from "./mcp/index.ts";
|
import { mcpRoutes } from "./mcp/index.ts";
|
||||||
import { requireAuth } from "./middleware/auth.ts";
|
import { requireAuth } from "./middleware/auth.ts";
|
||||||
import { createRateLimit } from "./middleware/rateLimit.ts";
|
import { createRateLimit } from "./middleware/rateLimit.ts";
|
||||||
|
import { accountRoutes } from "./routes/account.ts";
|
||||||
import { authRoutes } from "./routes/auth.ts";
|
import { authRoutes } from "./routes/auth.ts";
|
||||||
import { categoryRoutes } from "./routes/categories.ts";
|
import { categoryRoutes } from "./routes/categories.ts";
|
||||||
import { discoveryRoutes } from "./routes/discovery.ts";
|
import { discoveryRoutes } from "./routes/discovery.ts";
|
||||||
@@ -178,6 +179,7 @@ app.use("/api/*", async (c, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
app.route("/api/account", accountRoutes);
|
||||||
app.route("/api/auth", authRoutes);
|
app.route("/api/auth", authRoutes);
|
||||||
app.route("/api/items", itemRoutes);
|
app.route("/api/items", itemRoutes);
|
||||||
app.route("/api/categories", categoryRoutes);
|
app.route("/api/categories", categoryRoutes);
|
||||||
|
|||||||
183
src/server/routes/account.ts
Normal file
183
src/server/routes/account.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { db as prodDb } from "../../db/index.ts";
|
||||||
|
import {
|
||||||
|
apiKeys,
|
||||||
|
categories,
|
||||||
|
items,
|
||||||
|
oauthCodes,
|
||||||
|
oauthTokens,
|
||||||
|
settings,
|
||||||
|
setupItems,
|
||||||
|
setups,
|
||||||
|
threadCandidates,
|
||||||
|
threads,
|
||||||
|
users,
|
||||||
|
} from "../../db/schema.ts";
|
||||||
|
import {
|
||||||
|
changeEmailSchema,
|
||||||
|
changePasswordSchema,
|
||||||
|
deleteAccountSchema,
|
||||||
|
} from "../../shared/schemas.ts";
|
||||||
|
import { requireAuth } from "../middleware/auth.ts";
|
||||||
|
import { logtoClient } from "../services/logto.service.ts";
|
||||||
|
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
type Env = { Variables: { db?: any; userId?: number } };
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
// All account management routes require authentication (per T-28-05)
|
||||||
|
app.use("*", requireAuth);
|
||||||
|
|
||||||
|
// ── Helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getLogtoSub(db: Db, userId: number): Promise<string> {
|
||||||
|
const [user] = await db
|
||||||
|
.select({ logtoSub: users.logtoSub })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
return user.logtoSub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change Password (D-05) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post("/password", zValidator("json", changePasswordSchema), async (c) => {
|
||||||
|
const db = c.get("db") as Db;
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
const { currentPassword, newPassword } = c.req.valid("json");
|
||||||
|
|
||||||
|
const logtoSub = await getLogtoSub(db, userId);
|
||||||
|
|
||||||
|
// Per T-28-03: ALWAYS verify current password before setting new one
|
||||||
|
const isValid = await logtoClient.verifyPassword(logtoSub, currentPassword);
|
||||||
|
if (!isValid) {
|
||||||
|
return c.json({ error: "Current password is incorrect" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logtoClient.updatePassword(logtoSub, newPassword);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Change Email (D-05) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post("/email", zValidator("json", changeEmailSchema), async (c) => {
|
||||||
|
const db = c.get("db") as Db;
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
const { newEmail } = c.req.valid("json");
|
||||||
|
|
||||||
|
const logtoSub = await getLogtoSub(db, userId);
|
||||||
|
await logtoClient.updateEmail(logtoSub, newEmail);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Has Password ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get("/has-password", async (c) => {
|
||||||
|
const db = c.get("db") as Db;
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
|
||||||
|
const logtoSub = await getLogtoSub(db, userId);
|
||||||
|
const hasPassword = await logtoClient.hasPassword(logtoSub);
|
||||||
|
return c.json({ hasPassword });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete Account (D-05, D-06) ─────────────────────────────────────
|
||||||
|
|
||||||
|
app.post("/delete", zValidator("json", deleteAccountSchema), async (c) => {
|
||||||
|
const db = c.get("db") as Db;
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
|
||||||
|
const logtoSub = await getLogtoSub(db, userId);
|
||||||
|
|
||||||
|
// Per T-28-06: Run deletion in a single transaction
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// 1. Get or create sentinel "Deleted User" for public setup attribution (D-06)
|
||||||
|
let [sentinel] = await tx
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.logtoSub, "deleted-user"));
|
||||||
|
|
||||||
|
if (!sentinel) {
|
||||||
|
[sentinel] = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
logtoSub: "deleted-user",
|
||||||
|
displayName: "Deleted User",
|
||||||
|
})
|
||||||
|
.returning({ id: users.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reassign public setups to sentinel user
|
||||||
|
await tx
|
||||||
|
.update(setups)
|
||||||
|
.set({ userId: sentinel.id })
|
||||||
|
.where(and(eq(setups.userId, userId), eq(setups.isPublic, true)));
|
||||||
|
|
||||||
|
// 3. Get private setup IDs for cleanup
|
||||||
|
const privateSetups = await tx
|
||||||
|
.select({ id: setups.id })
|
||||||
|
.from(setups)
|
||||||
|
.where(eq(setups.userId, userId));
|
||||||
|
|
||||||
|
if (privateSetups.length > 0) {
|
||||||
|
const privateSetupIds = privateSetups.map((s) => s.id);
|
||||||
|
// Delete setup items for private setups
|
||||||
|
await tx
|
||||||
|
.delete(setupItems)
|
||||||
|
.where(inArray(setupItems.setupId, privateSetupIds));
|
||||||
|
// Delete private setups
|
||||||
|
await tx.delete(setups).where(eq(setups.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Get thread IDs for candidate cleanup
|
||||||
|
const userThreads = await tx
|
||||||
|
.select({ id: threads.id })
|
||||||
|
.from(threads)
|
||||||
|
.where(eq(threads.userId, userId));
|
||||||
|
|
||||||
|
if (userThreads.length > 0) {
|
||||||
|
const threadIds = userThreads.map((t) => t.id);
|
||||||
|
// Delete thread candidates (cascade should handle this, but be explicit)
|
||||||
|
await tx
|
||||||
|
.delete(threadCandidates)
|
||||||
|
.where(inArray(threadCandidates.threadId, threadIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Delete threads
|
||||||
|
await tx.delete(threads).where(eq(threads.userId, userId));
|
||||||
|
|
||||||
|
// 6. Delete items
|
||||||
|
await tx.delete(items).where(eq(items.userId, userId));
|
||||||
|
|
||||||
|
// 7. Delete categories
|
||||||
|
await tx.delete(categories).where(eq(categories.userId, userId));
|
||||||
|
|
||||||
|
// 8. Delete API keys
|
||||||
|
await tx.delete(apiKeys).where(eq(apiKeys.userId, userId));
|
||||||
|
|
||||||
|
// 9. Delete settings
|
||||||
|
await tx.delete(settings).where(eq(settings.userId, userId));
|
||||||
|
|
||||||
|
// 10. Delete OAuth codes and tokens
|
||||||
|
await tx.delete(oauthCodes).where(eq(oauthCodes.userId, userId));
|
||||||
|
await tx.delete(oauthTokens).where(eq(oauthTokens.userId, userId));
|
||||||
|
|
||||||
|
// 11. Delete user record
|
||||||
|
await tx.delete(users).where(eq(users.id, userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user from Logto (outside transaction — external service)
|
||||||
|
try {
|
||||||
|
await logtoClient.deleteUser(logtoSub);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[account] Failed to delete Logto user:", err);
|
||||||
|
// Don't fail the request — local data is already cleaned up
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, redirectTo: "/logout" });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accountRoutes = app;
|
||||||
@@ -127,3 +127,17 @@ export const updateProfileSchema = z.object({
|
|||||||
avatarUrl: z.string().optional(),
|
avatarUrl: z.string().optional(),
|
||||||
bio: z.string().max(500).optional(),
|
bio: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Account management schemas (per D-05)
|
||||||
|
export const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changeEmailSchema = z.object({
|
||||||
|
newEmail: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteAccountSchema = z.object({
|
||||||
|
confirmation: z.literal("DELETE"),
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import type {
|
|||||||
} from "../db/schema.ts";
|
} from "../db/schema.ts";
|
||||||
import type {
|
import type {
|
||||||
bulkUpsertGlobalItemsSchema,
|
bulkUpsertGlobalItemsSchema,
|
||||||
|
changeEmailSchema,
|
||||||
|
changePasswordSchema,
|
||||||
createCandidateSchema,
|
createCandidateSchema,
|
||||||
createCategorySchema,
|
createCategorySchema,
|
||||||
createItemSchema,
|
createItemSchema,
|
||||||
createSetupSchema,
|
createSetupSchema,
|
||||||
createThreadSchema,
|
createThreadSchema,
|
||||||
|
deleteAccountSchema,
|
||||||
reorderCandidatesSchema,
|
reorderCandidatesSchema,
|
||||||
resolveThreadSchema,
|
resolveThreadSchema,
|
||||||
searchGlobalItemsSchema,
|
searchGlobalItemsSchema,
|
||||||
@@ -67,3 +70,8 @@ export type SetupItem = typeof setupItems.$inferSelect;
|
|||||||
export type GlobalItem = typeof globalItems.$inferSelect;
|
export type GlobalItem = typeof globalItems.$inferSelect;
|
||||||
export type Tag = typeof tags.$inferSelect;
|
export type Tag = typeof tags.$inferSelect;
|
||||||
export type GlobalItemTag = typeof globalItemTags.$inferSelect;
|
export type GlobalItemTag = typeof globalItemTags.$inferSelect;
|
||||||
|
|
||||||
|
// Account management types
|
||||||
|
export type ChangePassword = z.infer<typeof changePasswordSchema>;
|
||||||
|
export type ChangeEmail = z.infer<typeof changeEmailSchema>;
|
||||||
|
export type DeleteAccount = z.infer<typeof deleteAccountSchema>;
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ describe("LogtoManagementClient", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET user
|
// GET user
|
||||||
if (url.includes("/api/users/") && options.method === "GET" && !url.includes("has-password")) {
|
if (
|
||||||
|
url.includes("/api/users/") &&
|
||||||
|
options.method === "GET" &&
|
||||||
|
!url.includes("has-password")
|
||||||
|
) {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -112,9 +116,7 @@ describe("LogtoManagementClient", () => {
|
|||||||
await client.getAccessToken();
|
await client.getAccessToken();
|
||||||
|
|
||||||
// Only one call to token endpoint — second call uses cache
|
// Only one call to token endpoint — second call uses cache
|
||||||
const tokenCalls = fetchCalls.filter((c) =>
|
const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token"));
|
||||||
c.url.includes("/oidc/token"),
|
|
||||||
);
|
|
||||||
expect(tokenCalls.length).toBe(1);
|
expect(tokenCalls.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,9 +130,7 @@ describe("LogtoManagementClient", () => {
|
|||||||
|
|
||||||
await client.getAccessToken();
|
await client.getAccessToken();
|
||||||
|
|
||||||
const tokenCalls = fetchCalls.filter((c) =>
|
const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token"));
|
||||||
c.url.includes("/oidc/token"),
|
|
||||||
);
|
|
||||||
expect(tokenCalls.length).toBe(2);
|
expect(tokenCalls.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ describe("LogtoManagementClient", () => {
|
|||||||
|
|
||||||
test("verifyPassword returns false on 422", async () => {
|
test("verifyPassword returns false on 422", async () => {
|
||||||
// Override fetch for this specific test
|
// Override fetch for this specific test
|
||||||
globalThis.fetch = mock((url: string, options: RequestInit) => {
|
globalThis.fetch = mock((url: string, _options: RequestInit) => {
|
||||||
if (url.includes("/oidc/token")) {
|
if (url.includes("/oidc/token")) {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
@@ -218,8 +218,7 @@ describe("LogtoManagementClient", () => {
|
|||||||
|
|
||||||
const deleteCall = fetchCalls.find(
|
const deleteCall = fetchCalls.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.url.includes("/api/users/sub-123") &&
|
c.url.includes("/api/users/sub-123") && c.options.method === "DELETE",
|
||||||
c.options.method === "DELETE",
|
|
||||||
);
|
);
|
||||||
expect(deleteCall).toBeDefined();
|
expect(deleteCall).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -251,16 +250,13 @@ describe("LogtoManagementClient", () => {
|
|||||||
|
|
||||||
// Token call should go to https://logto.example.com/oidc/token
|
// Token call should go to https://logto.example.com/oidc/token
|
||||||
const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token"));
|
const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token"));
|
||||||
expect(tokenCall!.url).toBe(
|
expect(tokenCall!.url).toBe("https://logto.example.com/oidc/token");
|
||||||
"https://logto.example.com/oidc/token",
|
|
||||||
);
|
|
||||||
|
|
||||||
// API call should go to https://logto.example.com/api/users/test-sub
|
// API call should go to https://logto.example.com/api/users/test-sub
|
||||||
const apiCall = fetchCalls.find(
|
const apiCall = fetchCalls.find(
|
||||||
(c) => c.url.includes("/api/users/test-sub") && c.options.method === "GET",
|
(c) =>
|
||||||
);
|
c.url.includes("/api/users/test-sub") && c.options.method === "GET",
|
||||||
expect(apiCall!.url).toBe(
|
|
||||||
"https://logto.example.com/api/users/test-sub",
|
|
||||||
);
|
);
|
||||||
|
expect(apiCall!.url).toBe("https://logto.example.com/api/users/test-sub");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user