Skip to content

Commit 5c9f0da

Browse files
committed
feat: use soft delete by default
1 parent ca9ee73 commit 5c9f0da

File tree

9 files changed

+204
-17
lines changed

9 files changed

+204
-17
lines changed

docs/user-guide/configuration.md

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export default defineNuxtConfig({
7777
requireNumbers: true,
7878
requireSpecialChars: true,
7979
preventCommonPasswords: true
80-
}
80+
},
81+
82+
// User deletion behavior
83+
hardDelete: false // true = permanent deletion, false = soft delete (default)
8184
}
8285
})
8386
```
@@ -341,6 +344,88 @@ nuxtUsers: {
341344
| `requireSpecialChars` | Require special characters (!@#$%^&*) | `true` |
342345
| `preventCommonPasswords` | Block common weak passwords | `true` |
343346

347+
## User Deletion Behavior
348+
349+
The module supports two types of user deletion: **soft delete** (default) and **hard delete**.
350+
351+
### Soft Delete (Default - Recommended)
352+
353+
By default, when a user is "deleted", they are actually **soft deleted**:
354+
355+
```ts
356+
nuxtUsers: {
357+
hardDelete: false // Default behavior
358+
}
359+
```
360+
361+
**What happens during soft delete:**
362+
- User's `active` field is set to `false`
363+
- User record remains in the database
364+
- All user tokens are revoked (user is logged out)
365+
- User cannot log in anymore
366+
- User data is preserved for compliance/audit purposes
367+
368+
**Benefits of soft delete:**
369+
- **Data preservation** - Important for compliance and audit trails
370+
- **Reversible** - Can reactivate users by setting `active: true`
371+
- **Reference integrity** - Foreign keys to user records remain valid
372+
- **Analytics** - Historical data remains available
373+
374+
### Hard Delete (Permanent)
375+
376+
For applications that require permanent user removal:
377+
378+
```ts
379+
nuxtUsers: {
380+
hardDelete: true // Enable permanent deletion
381+
}
382+
```
383+
384+
**What happens during hard delete:**
385+
- All user tokens are revoked first
386+
- User record is permanently removed from database
387+
- Action cannot be undone
388+
- Any foreign key references will break
389+
390+
**Use cases for hard delete:**
391+
- **GDPR "right to be forgotten"** compliance
392+
- **Data minimization** requirements
393+
- **Storage optimization** for high-volume applications
394+
- **Security-sensitive** applications
395+
396+
### Important Considerations
397+
398+
⚠️ **Before enabling hard delete:**
399+
400+
1. **Check foreign keys** - Ensure your app handles missing user references
401+
2. **Backup strategy** - Have proper backups before permanent deletion
402+
3. **Compliance** - Verify hard delete meets your legal requirements
403+
4. **Audit trail** - Consider logging deletion events separately
404+
405+
### Database Behavior Notes
406+
407+
**SQLite Boolean Handling:**
408+
When using soft delete with SQLite, the `active` field is stored as integers (1 for `true`, 0 for `false`). This is normal SQLite behavior and doesn't affect functionality.
409+
410+
**Cross-database compatibility:**
411+
The module handles boolean values consistently across SQLite, MySQL, and PostgreSQL, but the underlying storage may differ.
412+
413+
### Example Usage
414+
415+
```ts
416+
// Soft delete configuration (default)
417+
nuxtUsers: {
418+
hardDelete: false,
419+
// User deletion sets active: false, preserves data
420+
}
421+
422+
// Hard delete configuration
423+
nuxtUsers: {
424+
hardDelete: true,
425+
// User deletion permanently removes record
426+
}
427+
```
428+
344429
## Custom Table Names
345430

346431
If you need to customize database table names:

src/module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const defaultOptions: ModuleOptions = {
4242
requireSpecialChars: true,
4343
preventCommonPasswords: true,
4444
},
45+
hardDelete: false,
4546
}
4647

4748
export default defineNuxtModule<RuntimeModuleOptions>({
@@ -73,6 +74,7 @@ export default defineNuxtModule<RuntimeModuleOptions>({
7374
tokenExpiration: options.auth?.tokenExpiration || defaultOptions.auth.tokenExpiration,
7475
permissions: options.auth?.permissions || defaultOptions.auth.permissions
7576
},
77+
hardDelete: options.hardDelete ?? defaultOptions.hardDelete,
7678
}
7779

7880
// Add public runtime config for client-side access

src/runtime/server/api/nuxt-users/me.patch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export default defineEventHandler(async (event) => {
2424
delete body.role
2525
}
2626

27+
// Users should not be able to change their active status via this endpoint.
28+
if (body.active) {
29+
delete body.active
30+
}
31+
2732
try {
2833
const updatedUser = await updateUser(user.id, body, options)
2934
return { user: updatedUser }

src/runtime/server/utils/user.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,32 @@ export const updateUser = async (id: number, userData: Partial<User>, options: M
160160

161161
/**
162162
* Deletes a user by their ID.
163+
* By default performs soft delete (sets active to false).
164+
* If hardDelete option is true, performs hard delete (removes from database).
165+
* In both cases, user tokens are revoked.
163166
*/
164167
export const deleteUser = async (id: number, options: ModuleOptions): Promise<void> => {
165168
const db = await useDb(options)
166169
const usersTable = options.tables.users
167170

168-
const result = await db.sql`DELETE FROM {${usersTable}} WHERE id = ${id}`
171+
// Check if user exists first
172+
const userExists = await db.sql`SELECT id FROM {${usersTable}} WHERE id = ${id}` as { rows: Array<{ id: number }> }
169173

170-
if (result.rows?.length === 0) {
171-
throw new Error('User not found or could not be deleted.')
174+
if (userExists.rows.length === 0) {
175+
throw new Error('User not found.')
172176
}
177+
178+
// Always revoke user tokens first
179+
await revokeUserTokens(id, options)
180+
181+
if (options.hardDelete) {
182+
// Hard delete - permanently remove from database
183+
await db.sql`DELETE FROM {${usersTable}} WHERE id = ${id}`
184+
return
185+
}
186+
187+
// Soft delete - set active to false
188+
await db.sql`UPDATE {${usersTable}} SET active = false, updated_at = CURRENT_TIMESTAMP WHERE id = ${id}`
173189
}
174190

175191
/**

src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ export interface RuntimeModuleOptions {
104104
*/
105105
preventCommonPasswords?: boolean
106106
}
107+
/**
108+
* Enable hard delete for user deletion
109+
* When false (default), users are soft deleted (active set to false)
110+
* When true, users are permanently deleted from database
111+
* @default false
112+
*/
113+
hardDelete?: boolean
107114
}
108115

109116
// Runtime config type with all properties required (after merging with defaults)
@@ -134,6 +141,7 @@ export interface ModuleOptions {
134141
requireSpecialChars: boolean
135142
preventCommonPasswords: boolean
136143
}
144+
hardDelete: boolean
137145
}
138146

139147
export interface MailerOptions {

test/test-setup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export const getTestOptions = (dbType: DatabaseType, dbConfig: DatabaseConfig) =
7171
requireNumbers: false,
7272
requireSpecialChars: false,
7373
preventCommonPasswords: false,
74-
}
74+
},
75+
hardDelete: false
7576
})
7677

7778
export const cleanupTestSetup = async (dbType: DatabaseType, db: Database, cleanupFiles: string[], tableName: string) => {

test/unit/api.users-list.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ const testOptions: ModuleOptions = {
4444
connector: {
4545
name: 'sqlite',
4646
options: {
47-
path: './test.sqlite'
48-
}
47+
path: './_test-users-list',
48+
},
4949
},
5050
tables: {
5151
migrations: 'migrations',
@@ -56,17 +56,18 @@ const testOptions: ModuleOptions = {
5656
passwordResetUrl: '/reset-password',
5757
auth: {
5858
whitelist: [],
59-
permissions: {},
60-
tokenExpiration: 1440
59+
tokenExpiration: 1440,
60+
permissions: {}
6161
},
6262
passwordValidation: {
6363
minLength: 8,
64-
requireUppercase: true,
65-
requireLowercase: true,
66-
requireNumbers: true,
67-
requireSpecialChars: true,
68-
preventCommonPasswords: true
69-
}
64+
requireUppercase: false,
65+
requireLowercase: false,
66+
requireNumbers: false,
67+
requireSpecialChars: false,
68+
preventCommonPasswords: false,
69+
},
70+
hardDelete: false
7071
}
7172

7273
describe('Users List API Route', () => {

test/unit/usePublicPaths.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ describe('usePublicPaths', () => {
5454
requireNumbers: true,
5555
requireSpecialChars: true,
5656
preventCommonPasswords: true
57-
}
57+
},
58+
hardDelete: false
5859
}
5960

6061
mockUseRuntimeConfig.mockReturnValue({

test/utils.user.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { DatabaseType, DatabaseConfig, ModuleOptions } from '../src/types'
44
import { cleanupTestSetup, createTestSetup } from './test-setup'
55
import { createUsersTable } from '../src/runtime/server/utils/create-users-table'
66
import { createPersonalAccessTokensTable } from '../src/runtime/server/utils/create-personal-access-tokens-table'
7-
import { createUser, findUserByEmail, updateUser, updateUserPassword, getLastLoginTime, getCurrentUserFromToken } from '../src/runtime/server/utils/user'
7+
import { createUser, findUserByEmail, findUserById, deleteUser, updateUser, updateUserPassword, getLastLoginTime, getCurrentUserFromToken } from '../src/runtime/server/utils/user'
88
import { addActiveToUsers } from '../src/runtime/server/utils/add-active-to-users'
99

1010
describe('User Utilities (src/utils/user.ts)', () => {
@@ -181,6 +181,74 @@ describe('User Utilities (src/utils/user.ts)', () => {
181181
})
182182
})
183183

184+
describe('deleteUser', () => {
185+
it('should soft delete user by default (set active to false)', async () => {
186+
const userData = { email: 'softdelete@example.com', name: 'Soft Delete User', password: 'password123' }
187+
const user = await createUser(userData, testOptions)
188+
189+
// Verify user is active initially (SQLite may return 1 instead of true)
190+
expect(user.active).toBeTruthy()
191+
192+
// Delete user (should be soft delete by default)
193+
await deleteUser(user.id, testOptions)
194+
195+
// Verify user still exists but is inactive
196+
const deletedUser = await findUserById(user.id, testOptions)
197+
expect(deletedUser).not.toBeNull()
198+
expect(deletedUser?.active).toBeFalsy()
199+
200+
// Verify user still exists in database
201+
const result = await db.sql`SELECT * FROM {${testOptions.tables.users}} WHERE id = ${user.id}`
202+
expect(result.rows?.length).toBe(1)
203+
})
204+
205+
it('should hard delete user when hardDelete option is true', async () => {
206+
const userData = { email: 'harddelete@example.com', name: 'Hard Delete User', password: 'password123' }
207+
const user = await createUser(userData, testOptions)
208+
209+
// Set hardDelete option
210+
const hardDeleteOptions = { ...testOptions, hardDelete: true }
211+
212+
// Delete user with hard delete
213+
await deleteUser(user.id, hardDeleteOptions)
214+
215+
// Verify user no longer exists
216+
const deletedUser = await findUserById(user.id, testOptions)
217+
expect(deletedUser).toBeNull()
218+
219+
// Verify user is removed from database
220+
const result = await db.sql`SELECT * FROM {${testOptions.tables.users}} WHERE id = ${user.id}`
221+
expect(result.rows?.length).toBe(0)
222+
})
223+
224+
it('should throw error when trying to delete non-existent user', async () => {
225+
await expect(deleteUser(999, testOptions)).rejects.toThrow('User not found.')
226+
})
227+
228+
it('should revoke user tokens on both soft and hard delete', async () => {
229+
const userData = { email: 'tokenrevoke@example.com', name: 'Token Revoke User', password: 'password123' }
230+
const user = await createUser(userData, testOptions)
231+
232+
// Create a token for the user
233+
await db.sql`
234+
INSERT INTO {${testOptions.tables.personalAccessTokens}}
235+
(tokenable_type, tokenable_id, name, token, created_at, updated_at)
236+
VALUES ('user', ${user.id}, 'test-token', 'abc123', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
237+
`
238+
239+
// Verify token exists
240+
let tokenResult = await db.sql`SELECT * FROM {${testOptions.tables.personalAccessTokens}} WHERE tokenable_id = ${user.id}`
241+
expect(tokenResult.rows?.length).toBe(1)
242+
243+
// Soft delete user
244+
await deleteUser(user.id, testOptions)
245+
246+
// Verify tokens are revoked
247+
tokenResult = await db.sql`SELECT * FROM {${testOptions.tables.personalAccessTokens}} WHERE tokenable_id = ${user.id}`
248+
expect(tokenResult.rows?.length).toBe(0)
249+
})
250+
})
251+
184252
describe('Integration tests', () => {
185253
it('should work together: create user, find user, update password', async () => {
186254
const userData = { email: 'integration@webmania.cc', name: 'Integration User', password: 'initialpassword' }

0 commit comments

Comments
 (0)