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 {