| … | … | @@ -570,6 +570,76 @@ export class IdentityRegistry { |
| 570 | 570 | return identities |
| 571 | 571 | } |
| 572 | 572 | |
| 573 | /** | |
| 574 | * Soft-delete a user. Marks the user deactivated and revokes every linked | |
| 575 | * identity so it can no longer authenticate (providers reject revoked | |
| 576 | * identities). NEVER hard-deletes — the user, identity, and username records | |
| 577 | * are all preserved for audit and reactivation. (Hard `db.del` of identity | |
| 578 | * rows previously destroyed 28 accounts; identity cleanup must be soft.) | |
| 579 | * @param {string} userId | |
| 580 | * @param {Object} options - { by, reason } | |
| 581 | * @returns {Promise<Object>} The deactivated user | |
| 582 | */ | |
| 583 | async deactivateUser(userId, options = {}) { | |
| 584 | const user = await this.getUser(userId) | |
| 585 | if (!user) throw new Error('User not found') | |
| 586 | if (user.deactivated) return user | |
| 587 | ||
| 588 | // Revoke every linked identity — this is what actually blocks auth. | |
| 589 | for (const identityId of (user.identities || [])) { | |
| 590 | try { | |
| 591 | if (!(await this.isRevoked(identityId))) { | |
| 592 | await this.revokeIdentity(identityId, { | |
| 593 | reason: options.reason || 'user deactivated', | |
| 594 | revokedBy: options.by || 'admin' | |
| 595 | }) | |
| 596 | } | |
| 597 | } catch (err) { | |
| 598 | console.error(`[Identity] deactivateUser: failed to revoke ${identityId}:`, err.message) | |
| 599 | } | |
| 600 | } | |
| 601 | ||
| 602 | user.deactivated = true | |
| 603 | user.deactivatedAt = Date.now() | |
| 604 | user.deactivatedBy = options.by || 'admin' | |
| 605 | user.deactivationReason = options.reason || null | |
| 606 | user.updatedAt = Date.now() | |
| 607 | await this.db.put(`user:${userId}`, user) | |
| 608 | ||
| 609 | console.log(`[Identity] Deactivated user ${userId} (by ${user.deactivatedBy})`) | |
| 610 | return user | |
| 611 | } | |
| 612 | ||
| 613 | /** | |
| 614 | * Reverse deactivateUser: clear the flag and restore the user's identities. | |
| 615 | * @param {string} userId | |
| 616 | * @returns {Promise<Object>} The reactivated user | |
| 617 | */ | |
| 618 | async reactivateUser(userId) { | |
| 619 | const user = await this.getUser(userId) | |
| 620 | if (!user) throw new Error('User not found') | |
| 621 | if (!user.deactivated) return user | |
| 622 | ||
| 623 | for (const identityId of (user.identities || [])) { | |
| 624 | try { | |
| 625 | if (await this.isRevoked(identityId)) await this.restoreIdentity(identityId) | |
| 626 | } catch (err) { | |
| 627 | console.error(`[Identity] reactivateUser: failed to restore ${identityId}:`, err.message) | |
| 628 | } | |
| 629 | } | |
| 630 | ||
| 631 | delete user.deactivated | |
| 632 | delete user.deactivatedAt | |
| 633 | delete user.deactivatedBy | |
| 634 | delete user.deactivationReason | |
| 635 | user.reactivatedAt = Date.now() | |
| 636 | user.updatedAt = Date.now() | |
| 637 | await this.db.put(`user:${userId}`, user) | |
| 638 | ||
| 639 | console.log(`[Identity] Reactivated user ${userId}`) | |
| 640 | return user | |
| 641 | } | |
| 642 | ||
| 573 | 643 | /** |
| 574 | 644 | * Merge two user accounts |
| 575 | 645 | * Transfers all identities, permissions, and data from secondary to primary |
| … | … | @@ -269,24 +269,28 @@ export function registerAuthRoutes (router, server) { |
| 269 | 269 | const user = await registry.getUser(targetUserId) |
| 270 | 270 | if (!user) throw new ValidationError('User not found') |
| 271 | 271 | |
| 272 | // Remove identity mappings | |
| 273 | for (const identityId of (user.identities || [])) { | |
| 274 | const identity = await registry.getIdentity(identityId) | |
| 275 | if (identity && identity.publicKey) { | |
| 276 | await registry.db.del(`pubkey:${identity.publicKey}`) | |
| 277 | } | |
| 278 | await registry.db.del(`identity:${identityId}`) | |
| 279 | } | |
| 272 | // Soft-delete only. Hard `db.del` of identity/user rows destroyed 28 | |
| 273 | // accounts historically — deactivateUser revokes the identities (blocking | |
| 274 | // auth) and flags the user while preserving every record for audit/restore. | |
| 275 | await registry.deactivateUser(targetUserId, { by: auth.userId, reason: 'admin deactivation' }) | |
| 280 | 276 | |
| 281 | // Remove username mapping | |
| 282 | if (user.username) { | |
| 283 | await registry.db.del(`username:${user.username}`) | |
| 277 | json(res, { ok: true, deactivated: targetUserId }) | |
| 278 | }) | |
| 279 | ||
| 280 | // POST /api/admin/users/:userId/reactivate — Reverse a soft-delete (admin only) | |
| 281 | router.post('/api/admin/users/:userId/reactivate', async (req, res) => { | |
| 282 | const auth = await requireAuth(req, server) | |
| 283 | if (!auth.permissions.admin) { | |
| 284 | json(res, { error: 'Admin permission required' }, 403) | |
| 285 | return | |
| 284 | 286 | } |
| 285 | 287 | |
| 286 | // Remove user | |
| 287 | await registry.db.del(`user:${targetUserId}`) | |
| 288 | const registry = server.authManager.identityRegistry | |
| 289 | const user = await registry.getUser(req.params.userId) | |
| 290 | if (!user) throw new ValidationError('User not found') | |
| 288 | 291 | |
| 289 | json(res, { ok: true, deleted: targetUserId }) | |
| 292 | await registry.reactivateUser(req.params.userId) | |
| 293 | json(res, { ok: true, reactivated: req.params.userId }) | |
| 290 | 294 | }) |
| 291 | 295 | |
| 292 | 296 | // POST /api/admin/notebooks/:notebook/transfer — Transfer notebook ownership (admin only) |
| … | … | @@ -337,7 +341,7 @@ export function registerAuthRoutes (router, server) { |
| 337 | 341 | userCount: users.length, |
| 338 | 342 | notebookCount: notebooks.length, |
| 339 | 343 | noteCount: totalNotes, |
| 340 | users: users.filter(u => !u.mergedInto).length, | |
| 344 | users: users.filter(u => !u.mergedInto && !u.deactivated).length, | |
| 341 | 345 | publicNotebooks: notebooks.filter(nb => nb.visibility !== 'private' && !nb.encrypted).length, |
| 342 | 346 | privateNotebooks: notebooks.filter(nb => nb.visibility === 'private').length |
| 343 | 347 | }) |
| … | … | @@ -365,6 +369,7 @@ export function registerAuthRoutes (router, server) { |
| 365 | 369 | for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) { |
| 366 | 370 | const u = entry.value |
| 367 | 371 | if (!u.username) continue // Only show users who have set a username |
| 372 | if (u.deactivated || u.mergedInto) continue // Hide soft-deleted/merged accounts | |
| 368 | 373 | users.push({ |
| 369 | 374 | userId: u.userId, |
| 370 | 375 | username: u.username, |
| … | … | @@ -162,3 +162,44 @@ test('IdentityRegistry - merge accounts transfers notebooks', async (t) => { |
| 162 | 162 | t.ok(result.primary.notebooks.find(r => r.name === 'personal'), 'secondary notebook merged') |
| 163 | 163 | t.alike(result.merged.notebooks, [], 'secondary notebooks cleared') |
| 164 | 164 | }) |
| 165 | ||
| 166 | test('IdentityRegistry - deactivateUser is a soft-delete (preserves records, blocks auth)', async (t) => { | |
| 167 | const db = new MockRegistry() | |
| 168 | const reg = new IdentityRegistry(db) | |
| 169 | ||
| 170 | const { user } = await reg.autoRegister({ | |
| 171 | identityId: 'pk_deact', | |
| 172 | method: 'web-identity', | |
| 173 | credentials: { publicKey: 'pk_deact' } | |
| 174 | }) | |
| 175 | ||
| 176 | await reg.deactivateUser(user.userId, { by: 'admin-1', reason: 'spam' }) | |
| 177 | ||
| 178 | // User record is PRESERVED — never hard-deleted | |
| 179 | const after = await reg.getUser(user.userId) | |
| 180 | t.ok(after, 'user record still exists (not hard-deleted)') | |
| 181 | t.is(after.deactivated, true, 'user flagged deactivated') | |
| 182 | t.is(after.deactivatedBy, 'admin-1', 'records who deactivated it') | |
| 183 | ||
| 184 | // Identity is revoked → providers will reject auth; identity row preserved | |
| 185 | t.ok(await reg.isRevoked('pk_deact'), 'linked identity is revoked (auth blocked)') | |
| 186 | t.ok(await reg.getIdentity('pk_deact'), 'identity record still exists') | |
| 187 | }) | |
| 188 | ||
| 189 | test('IdentityRegistry - reactivateUser reverses a soft-delete', async (t) => { | |
| 190 | const db = new MockRegistry() | |
| 191 | const reg = new IdentityRegistry(db) | |
| 192 | ||
| 193 | const { user } = await reg.autoRegister({ | |
| 194 | identityId: 'pk_react', | |
| 195 | method: 'web-identity', | |
| 196 | credentials: { publicKey: 'pk_react' } | |
| 197 | }) | |
| 198 | ||
| 199 | await reg.deactivateUser(user.userId, { by: 'admin-1' }) | |
| 200 | await reg.reactivateUser(user.userId) | |
| 201 | ||
| 202 | const after = await reg.getUser(user.userId) | |
| 203 | t.absent(after.deactivated, 'deactivated flag cleared') | |
| 204 | t.absent(await reg.isRevoked('pk_react'), 'identity restored (no longer revoked)') | |
| 205 | }) |