feat(profile): self-service name/email/password + desktop installers in CI
Self-service profile: any signed-in user edits their OWN fullName/email and changes their OWN password (proving the current one), without any user:* permission. New routes PUT /api/auth/profile + /api/auth/password act only on req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile reachable from the header username chip. email added to the session view + SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green. Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage on every push to dev/main and uploads them as unsigned workflow artifacts (per-commit test build). Signed/versioned release stays on release.yml (tag v*). Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI split), log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -29,6 +29,33 @@ interface ThemeBody {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
||||
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
||||
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
|
||||
interface ProfileBody {
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
// Self-service password change: the user proves they hold the CURRENT password before
|
||||
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
|
||||
// why it lives here and not behind a permission: it's account-self-care, not admin power.
|
||||
interface PasswordBody {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
|
||||
* Returns undefined for an absent key so an update only touches what was sent. */
|
||||
function cleanProfileField(v: string | null | undefined): string | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
const trimmed = typeof v === "string" ? v.trim() : "";
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||
* permissions are the source of truth. */
|
||||
@@ -41,6 +68,7 @@ function sessionView(
|
||||
language: string;
|
||||
theme: string;
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
},
|
||||
) {
|
||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||
@@ -54,6 +82,7 @@ function sessionView(
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
fullName: user.fullName ?? null,
|
||||
email: user.email ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return { theme };
|
||||
},
|
||||
);
|
||||
|
||||
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
||||
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||
app.put<{ Body: ProfileBody }>(
|
||||
"/api/auth/profile",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const fullName = cleanProfileField(req.body?.fullName);
|
||||
const email = cleanProfileField(req.body?.email);
|
||||
const patch: Record<string, string | null> = {};
|
||||
if (fullName !== undefined) patch.fullName = fullName;
|
||||
if (email !== undefined) patch.email = email;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return reply.code(400).send({ error: "nothing to update" });
|
||||
}
|
||||
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
|
||||
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||
if (!row) return reply.code(401).send({ error: "session no longer valid" });
|
||||
return sessionView(db, row);
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
|
||||
// already-logged-in booth: a passerby can't silently re-key the account). New password
|
||||
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
|
||||
app.put<{ Body: PasswordBody }>(
|
||||
"/api/auth/password",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const currentPassword = req.body?.currentPassword ?? "";
|
||||
const newPassword = req.body?.newPassword ?? "";
|
||||
if (newPassword.length < MIN_PASSWORD) {
|
||||
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||
}
|
||||
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||
if (!row) {
|
||||
clearAuthCookies(reply);
|
||||
return reply.code(401).send({ error: "session no longer valid" });
|
||||
}
|
||||
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
|
||||
if (!ok) {
|
||||
return reply.code(403).send({ error: "current password is incorrect" });
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user