feat(users): active flag, backfilled, enforced at sign-in

This commit is contained in:
Dave 2026-09-16 11:26:16 +02:00
parent ed048c5bb3
commit 287d70c42c
4 changed files with 69 additions and 1 deletions

View file

@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* `active` on users.
*
* REFERENCE ONLY — see pb_migrations/README.md. scripts/setup-remote.ts is
* what runs.
*
* Accounts are deactivated, never deleted: activity.actor and comments.author
* are relations to users, and removing a row turns every historical entry that
* referenced it into "Someone". An audit trail that forgets who did what is
* not an audit trail.
*
* Existing rows are backfilled to true so nobody is locked out by the upgrade.
*/
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
users.fields.add(
new Field({ name: "active", type: "bool", required: false })
);
app.save(users);
for (const user of app.findAllRecords("users")) {
user.set("active", true);
app.save(user);
}
return true;
},
(app) => {
const users = app.findCollectionByNameOrId("users");
const field = users.fields.find((f) => f.name === "active");
if (field) users.fields.removeById(field.id);
return app.save(users);
}
);

View file

@ -80,12 +80,33 @@ async function ensureUsersFields(pb: PocketBase): Promise<void> {
changed = true;
}
if (!have.has("active")) {
fields.push({
name: "active",
type: "bool",
required: false,
presentable: false,
hidden: false,
system: false,
});
changed = true;
}
if (changed) {
await pb.collections.update(users.id, { fields });
console.log(" + users fields: role, client");
console.log(" + users fields: role, client, active");
} else {
console.log(" = users fields already present");
}
// Backfill: an account with active unset must not be locked out by this
// field appearing.
const existing = await pb.collection("users").getFullList();
for (const user of existing) {
if (user.active !== true) {
await pb.collection("users").update(user.id, { active: true });
}
}
}
const OWNER = '@request.auth.role = "owner"';

View file

@ -31,6 +31,13 @@ export async function POST(request: Request): Promise<Response> {
return jsonError("Invalid email or password", 401);
}
// A deactivated account authenticates fine — PocketBase knows nothing about
// our `active` flag — so the check has to happen here. Same generic message
// as a bad password, so the response cannot be used to enumerate accounts.
if (pb.authStore.record?.active === false) {
return jsonError("Invalid email or password", 401);
}
const cookie = pb.authStore.exportToCookie(
{
httpOnly: true,

View file

@ -28,6 +28,8 @@ export interface UserRecord extends BaseRecord {
role: Role;
/** Empty string when role is "owner". */
client: string;
/** False disables sign-in without deleting the account or its history. */
active: boolean;
}
export interface ClientRecord extends BaseRecord {