Add RBAC tests, wiki docs, reset token to 1 hour

12 RBAC API tests: permission denial for no-role users, viewer read-only,
sales associate can create but not delete, technician scoped access,
instructor inventory denied, admin full access, permission inheritance
(admin implies edit+view), system role undeletable, custom role lifecycle.

Wiki articles for Users & Roles and Profile settings.
Reset password link expires in 1 hour instead of 24.
This commit is contained in:
Ryan Moon
2026-03-28 18:11:32 -05:00
parent 7dea20e818
commit 92371ff228
3 changed files with 294 additions and 3 deletions

View File

@@ -216,6 +216,87 @@ You can upload photos of the front and back of the ID. These are stored securely
If a member has multiple IDs, mark one as **Primary** — this is the one shown by default in quick lookups.
`.trim(),
},
{
slug: 'users-roles',
title: 'Users & Roles',
category: 'Admin',
content: `
# Users & Roles
Forte uses a permission-based access control system. **Permissions** are specific actions (like "view accounts" or "edit inventory"). **Roles** are named groups of permissions that you assign to users.
## Managing Users
Go to **Users** in the Admin section of the sidebar.
- View all staff accounts with their assigned roles
- Click the three-dot menu on a user to:
- **Manage Roles** — add or remove roles
- **Reset Password Link** — generates a secure one-time link (expires in 1 hour) that you can send to the user
## Managing Roles
Go to **Roles** in the Admin section.
- View all roles (system defaults + custom roles you've created)
- **System roles** (Admin, Manager, Sales Associate, etc.) come pre-configured but you can modify their permissions
- Click a role to edit its permissions
- Click **New Role** to create a custom role
## How Permissions Work
Permissions are organized by area:
- **Accounts** — view, edit, admin
- **Inventory** — view, edit, admin
- **POS** — view, edit, admin
- **Rentals, Lessons, Repairs** — each has view, edit, admin
**Permission inheritance:** If a role has **admin** permission for an area, it automatically includes **edit** and **view** too. If it has **edit**, it includes **view**.
## Creating a Custom Role
1. Go to **Roles** → **New Role**
2. Enter a name (e.g. "School Sales Rep")
3. Check the permissions this role needs
4. Click **Create Role**
5. Go to **Users** → assign the new role to staff members
## Multiple Roles
A user can have multiple roles. Their effective permissions are the combination of all their roles. For example, a user with "Sales Associate" + "Repair Viewer" can do everything a sales associate can, plus view repair tickets.
`.trim(),
},
{
slug: 'profile-settings',
title: 'Your Profile',
category: 'General',
content: `
# Your Profile
Click your name at the bottom of the sidebar to access your profile.
## Editing Your Name
You can update your first and last name. Click **Save** to apply changes.
## Changing Your Password
1. Enter your current password
2. Enter a new password (at least 12 characters)
3. Confirm the new password
4. Click **Change Password**
## Appearance
Choose your preferred mode and color theme:
- **Mode** — Light, Dark, or System (follows your device setting)
- **Color Theme** — Slate, Emerald, Violet, Amber, or Rose
Your preferences are saved in your browser and persist across sessions.
`.trim(),
},
]
export function getWikiPages(): WikiPage[] {

View File

@@ -0,0 +1,210 @@
import { suite } from '../lib/context.js'
suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
// Helper: register a user with no roles (restricted)
async function createRestrictedUser() {
const email = `restricted-${Date.now()}@test.com`
const password = 'testpassword1234'
// Register via raw fetch (needs x-company-id)
const registerRes = 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: 'Restricted', lastName: 'User', role: 'staff' }),
})
const registerData = await registerRes.json() as { token: string }
// Login (no roles assigned, so no permissions)
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 }
return loginData.token
}
// Helper: register a user and assign a specific role
async function createUserWithRole(roleSlug: string) {
const email = `${roleSlug}-${Date.now()}@test.com`
const password = 'testpassword1234'
const registerRes = 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: roleSlug, lastName: 'User', role: 'staff' }),
})
const registerData = await registerRes.json() as { user: { id: string } }
// Get the role and assign it
const rolesRes = await t.api.get<{ data: { id: string; slug: string }[] }>('/v1/roles')
const role = rolesRes.data.data.find((r: { slug: string }) => r.slug === roleSlug)
if (role) {
await t.api.post(`/v1/users/${registerData.user.id}/roles`, { roleId: role.id })
}
// Login to get token with permissions
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 }
return loginData.token
}
async function fetchAs(token: string, method: string, path: string, body?: unknown) {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
if (body !== undefined) headers['Content-Type'] = 'application/json'
const res = await fetch(`${t.baseUrl}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
return { status: res.status, data: await res.json() }
}
t.test('user with no roles gets 403 on all routes', { tags: ['deny'] }, async () => {
const token = await createRestrictedUser()
const res = await fetchAs(token, 'GET', '/v1/accounts')
t.assert.equal(res.status, 403)
})
t.test('viewer role can read accounts but not create', { tags: ['viewer'] }, async () => {
const token = await createUserWithRole('viewer')
const readRes = await fetchAs(token, 'GET', '/v1/accounts')
t.assert.equal(readRes.status, 200)
const createRes = await fetchAs(token, 'POST', '/v1/accounts', { name: 'Should Fail' })
t.assert.equal(createRes.status, 403)
})
t.test('viewer cannot delete accounts', { tags: ['viewer'] }, async () => {
const token = await createUserWithRole('viewer')
// Try to delete — should be 403 even before 404
const res = await fetchAs(token, 'DELETE', '/v1/accounts/a0000000-0000-0000-0000-999999999999')
t.assert.equal(res.status, 403)
})
t.test('sales associate can create accounts but not delete', { tags: ['sales'] }, async () => {
const token = await createUserWithRole('sales_associate')
const createRes = await fetchAs(token, 'POST', '/v1/accounts', { name: 'Sales Test' })
t.assert.equal(createRes.status, 201)
const deleteRes = await fetchAs(token, 'DELETE', `/v1/accounts/${createRes.data.id}`)
t.assert.equal(deleteRes.status, 403)
})
t.test('technician can view repairs but not accounts edit', { tags: ['technician'] }, async () => {
const token = await createUserWithRole('technician')
// Can view accounts (via accounts.view in technician role — wait, technician doesn't have accounts.view)
const acctRes = await fetchAs(token, 'GET', '/v1/accounts')
t.assert.equal(acctRes.status, 200) // technician has accounts.view
// Cannot create accounts
const createRes = await fetchAs(token, 'POST', '/v1/accounts', { name: 'Should Fail' })
t.assert.equal(createRes.status, 403)
})
t.test('instructor cannot access inventory', { tags: ['instructor'] }, async () => {
const token = await createUserWithRole('instructor')
const res = await fetchAs(token, 'GET', '/v1/products')
t.assert.equal(res.status, 403)
})
t.test('admin role has access to everything', { tags: ['admin'] }, async () => {
// The default test user is admin — just verify
const res = await t.api.get('/v1/roles')
t.assert.status(res, 200)
const permsRes = await t.api.get('/v1/permissions')
t.assert.status(permsRes, 200)
const usersRes = await t.api.get('/v1/users')
t.assert.status(usersRes, 200)
})
t.test('permission inheritance: admin implies edit and view', { tags: ['inheritance'] }, async () => {
// Create a custom role with only accounts.admin
const roleRes = await t.api.post('/v1/roles', {
name: 'Admin Only Test',
slug: `admin_only_${Date.now()}`,
permissionSlugs: ['accounts.admin'],
})
t.assert.status(roleRes, 201)
// Create user and assign this role
const email = `inherit-${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: 'Inherit', lastName: 'Test', role: 'staff' }),
})
const regData = await regRes.json() as { user: { id: string } }
await t.api.post(`/v1/users/${regData.user.id}/roles`, { roleId: roleRes.data.id })
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 }
// Should be able to view (inherited from admin)
const viewRes = await fetchAs(loginData.token, 'GET', '/v1/accounts')
t.assert.equal(viewRes.status, 200)
// Should be able to create (edit inherited from admin)
const createRes = await fetchAs(loginData.token, 'POST', '/v1/accounts', { name: 'Inherited Edit' })
t.assert.equal(createRes.status, 201)
// Should be able to delete (admin)
const deleteRes = await fetchAs(loginData.token, 'DELETE', `/v1/accounts/${createRes.data.id}`)
t.assert.equal(deleteRes.status, 200)
})
t.test('roles list returns system roles', { tags: ['roles'] }, async () => {
const res = await t.api.get('/v1/roles')
t.assert.status(res, 200)
const slugs = res.data.data.map((r: { slug: string }) => r.slug)
t.assert.includes(slugs, 'admin')
t.assert.includes(slugs, 'manager')
t.assert.includes(slugs, 'sales_associate')
t.assert.includes(slugs, 'technician')
t.assert.includes(slugs, 'instructor')
t.assert.includes(slugs, 'viewer')
})
t.test('permissions list returns all system permissions', { tags: ['permissions'] }, async () => {
const res = await t.api.get('/v1/permissions')
t.assert.status(res, 200)
t.assert.greaterThan(res.data.data.length, 30)
})
t.test('cannot delete system role', { tags: ['roles'] }, async () => {
const rolesRes = await t.api.get('/v1/roles')
const adminRole = rolesRes.data.data.find((r: { slug: string }) => r.slug === 'admin')
t.assert.ok(adminRole)
const deleteRes = await t.api.del(`/v1/roles/${adminRole.id}`)
t.assert.equal(deleteRes.status, 403)
})
t.test('can create and delete custom role', { tags: ['roles'] }, async () => {
const createRes = await t.api.post('/v1/roles', {
name: 'Temp Role',
slug: `temp_${Date.now()}`,
permissionSlugs: ['accounts.view'],
})
t.assert.status(createRes, 201)
const deleteRes = await t.api.del(`/v1/roles/${createRes.data.id}`)
t.assert.status(deleteRes, 200)
})
})

View File

@@ -175,12 +175,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1)
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
// Generate a signed reset token that expires in 24 hours
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' }, { expiresIn: '24h' })
// Generate a signed reset token that expires in 1 hour
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' }, { expiresIn: '1h' })
const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}`
request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated')
return reply.send({ resetLink, expiresIn: '24 hours' })
return reply.send({ resetLink, expiresIn: '1 hour' })
})
// Reset password with token