diff --git a/pocketbase/pb_migrations/1789601100_added_user_active.js b/pocketbase/pb_migrations/1789601100_added_user_active.js new file mode 100644 index 0000000..0e849f3 --- /dev/null +++ b/pocketbase/pb_migrations/1789601100_added_user_active.js @@ -0,0 +1,38 @@ +/// +/** + * `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); + } +); \ No newline at end of file diff --git a/scripts/setup-remote.ts b/scripts/setup-remote.ts index 45777f4..f6135cb 100644 --- a/scripts/setup-remote.ts +++ b/scripts/setup-remote.ts @@ -80,12 +80,33 @@ async function ensureUsersFields(pb: PocketBase): Promise { 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"'; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 2ba0f8c..41d2908 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -31,6 +31,13 @@ export async function POST(request: Request): Promise { 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, diff --git a/src/lib/pb/types.ts b/src/lib/pb/types.ts index 10990d8..f16256c 100644 --- a/src/lib/pb/types.ts +++ b/src/lib/pb/types.ts @@ -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 {