Add paginated users/roles, user status, frontend permissions, profile pictures, identifier file storage
- Users page: paginated, searchable, sortable with inline roles (no N+1) - Roles page: paginated, searchable, sortable + /roles/all for dropdowns - User is_active field with migration, PATCH toggle, auth check (disabled=401) - Frontend permission checks: auth store loads permissions, sidebar/buttons conditional - Profile pictures via file storage for users and members, avatar component - Identifier images use file storage API instead of base64 - Fix TypeScript errors across admin UI - 64 API tests passing (10 new)
This commit is contained in:
@@ -139,6 +139,35 @@ suite('Files', { tags: ['files', 'storage'] }, (t) => {
|
||||
t.assert.equal(res.status, 400)
|
||||
})
|
||||
|
||||
t.test('uploads profile picture for user entity type', { tags: ['upload', 'profile'] }, async () => {
|
||||
// Get the current test user ID from the users list
|
||||
const usersRes = await t.api.get('/v1/users')
|
||||
const testUser = usersRes.data.data[0]
|
||||
t.assert.ok(testUser)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'avatar.jpg')
|
||||
formData.append('entityType', 'user')
|
||||
formData.append('entityId', testUser.id)
|
||||
formData.append('category', 'profile')
|
||||
|
||||
const res = await fetch(`${t.baseUrl}/v1/files`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${t.token}` },
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
t.assert.equal(res.status, 201)
|
||||
t.assert.equal(data.entityType, 'user')
|
||||
t.assert.equal(data.category, 'profile')
|
||||
|
||||
// Verify it shows up in files list
|
||||
const listRes = await t.api.get('/v1/files', { entityType: 'user', entityId: testUser.id })
|
||||
t.assert.status(listRes, 200)
|
||||
t.assert.greaterThan(listRes.data.data.length, 0)
|
||||
})
|
||||
|
||||
t.test('returns 404 for missing file', { tags: ['read'] }, async () => {
|
||||
const res = await t.api.get('/v1/files/a0000000-0000-0000-0000-999999999999')
|
||||
t.assert.status(res, 404)
|
||||
|
||||
@@ -172,6 +172,7 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
|
||||
t.test('roles list returns system roles', { tags: ['roles'] }, async () => {
|
||||
const res = await t.api.get('/v1/roles')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.pagination)
|
||||
const slugs = res.data.data.map((r: { slug: string }) => r.slug)
|
||||
t.assert.includes(slugs, 'admin')
|
||||
t.assert.includes(slugs, 'manager')
|
||||
@@ -181,6 +182,13 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
|
||||
t.assert.includes(slugs, 'viewer')
|
||||
})
|
||||
|
||||
t.test('roles/all returns unpaginated list', { tags: ['roles'] }, async () => {
|
||||
const res = await t.api.get('/v1/roles/all')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.greaterThan(res.data.data.length, 5)
|
||||
t.assert.equal(res.data.pagination, undefined)
|
||||
})
|
||||
|
||||
t.test('permissions list returns all system permissions', { tags: ['permissions'] }, async () => {
|
||||
const res = await t.api.get('/v1/permissions')
|
||||
t.assert.status(res, 200)
|
||||
@@ -196,6 +204,120 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
|
||||
t.assert.equal(deleteRes.status, 403)
|
||||
})
|
||||
|
||||
t.test('roles search by name', { tags: ['roles', 'search'] }, async () => {
|
||||
const res = await t.api.get('/v1/roles', { q: 'admin' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.greaterThan(res.data.data.length, 0)
|
||||
t.assert.ok(res.data.data.every((r: { name: string }) => r.name.toLowerCase().includes('admin')))
|
||||
})
|
||||
|
||||
t.test('roles sort by name descending', { tags: ['roles', 'sort'] }, async () => {
|
||||
const res = await t.api.get('/v1/roles', { sort: 'name', order: 'desc' })
|
||||
t.assert.status(res, 200)
|
||||
const names = res.data.data.map((r: { name: string }) => r.name)
|
||||
const sorted = [...names].sort().reverse()
|
||||
t.assert.equal(JSON.stringify(names), JSON.stringify(sorted))
|
||||
})
|
||||
|
||||
t.test('users list is paginated with roles', { tags: ['users', 'pagination'] }, async () => {
|
||||
const res = await t.api.get('/v1/users')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.pagination)
|
||||
t.assert.greaterThan(res.data.data.length, 0)
|
||||
// Each user should have a roles array
|
||||
const first = res.data.data[0]
|
||||
t.assert.ok(Array.isArray(first.roles))
|
||||
})
|
||||
|
||||
t.test('users search by name', { tags: ['users', 'search'] }, async () => {
|
||||
// Create a user with a distinctive name
|
||||
await fetch(`${t.baseUrl}/v1/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' },
|
||||
body: JSON.stringify({ email: `searchme-${Date.now()}@test.com`, password: 'testpassword1234', firstName: 'Searchable', lastName: 'Pessoa', role: 'staff' }),
|
||||
})
|
||||
|
||||
const res = await t.api.get('/v1/users', { q: 'Searchable' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.data.length, 1)
|
||||
t.assert.equal(res.data.data[0].firstName, 'Searchable')
|
||||
})
|
||||
|
||||
t.test('users sort by email ascending', { tags: ['users', 'sort'] }, async () => {
|
||||
const res = await t.api.get('/v1/users', { sort: 'email', order: 'asc' })
|
||||
t.assert.status(res, 200)
|
||||
const emails = res.data.data.map((u: { email: string }) => u.email)
|
||||
const sorted = [...emails].sort()
|
||||
t.assert.equal(JSON.stringify(emails), JSON.stringify(sorted))
|
||||
})
|
||||
|
||||
t.test('can disable and re-enable a user', { tags: ['users', 'status'] }, async () => {
|
||||
// Create a user
|
||||
const email = `disable-${Date.now()}@test.com`
|
||||
const password = 'testpassword1234'
|
||||
const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' },
|
||||
body: JSON.stringify({ email, password, firstName: 'Disable', lastName: 'Me', role: 'staff' }),
|
||||
})
|
||||
const regData = await regRes.json() as { user: { id: string } }
|
||||
const userId = regData.user.id
|
||||
|
||||
// Disable the user
|
||||
const disableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: false })
|
||||
t.assert.status(disableRes, 200)
|
||||
t.assert.equal(disableRes.data.isActive, false)
|
||||
|
||||
// Disabled user cannot authenticate
|
||||
const loginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const loginData = await loginRes.json() as { token: string }
|
||||
|
||||
// Try to use the token — should get 401
|
||||
const authRes = await fetch(`${t.baseUrl}/v1/accounts`, {
|
||||
headers: { Authorization: `Bearer ${loginData.token}` },
|
||||
})
|
||||
t.assert.equal(authRes.status, 401)
|
||||
|
||||
// Re-enable the user
|
||||
const enableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: true })
|
||||
t.assert.status(enableRes, 200)
|
||||
t.assert.equal(enableRes.data.isActive, true)
|
||||
|
||||
// Now they can authenticate again
|
||||
const reLoginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const reLoginData = await reLoginRes.json() as { token: string }
|
||||
const reAuthRes = await fetch(`${t.baseUrl}/v1/accounts`, {
|
||||
headers: { Authorization: `Bearer ${reLoginData.token}` },
|
||||
})
|
||||
// Will be 403 (no permissions) but NOT 401 (not disabled)
|
||||
t.assert.notEqual(reAuthRes.status, 401)
|
||||
})
|
||||
|
||||
t.test('cannot disable yourself', { tags: ['users', 'status'] }, async () => {
|
||||
// Get current user ID from the users list
|
||||
const usersRes = await t.api.get('/v1/users')
|
||||
const currentUser = usersRes.data.data.find((u: { email: string }) => u.email === 'test@forte.dev')
|
||||
t.assert.ok(currentUser)
|
||||
|
||||
const res = await t.api.patch(`/v1/users/${currentUser.id}/status`, { isActive: false })
|
||||
t.assert.equal(res.status, 400)
|
||||
})
|
||||
|
||||
t.test('users list includes isActive field', { tags: ['users'] }, async () => {
|
||||
const res = await t.api.get('/v1/users')
|
||||
t.assert.status(res, 200)
|
||||
const first = res.data.data[0]
|
||||
t.assert.equal(typeof first.isActive, 'boolean')
|
||||
})
|
||||
|
||||
t.test('can create and delete custom role', { tags: ['roles'] }, async () => {
|
||||
const createRes = await t.api.post('/v1/roles', {
|
||||
name: 'Temp Role',
|
||||
|
||||
Reference in New Issue
Block a user