From 92371ff228e9016bebcac5a6aa005b01d102f09c Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sat, 28 Mar 2026 18:11:32 -0500 Subject: [PATCH] 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. --- packages/admin/src/wiki/index.ts | 81 +++++++++ packages/backend/api-tests/suites/rbac.ts | 210 ++++++++++++++++++++++ packages/backend/src/routes/v1/auth.ts | 6 +- 3 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 packages/backend/api-tests/suites/rbac.ts diff --git a/packages/admin/src/wiki/index.ts b/packages/admin/src/wiki/index.ts index 386a367..4d106f8 100644 --- a/packages/admin/src/wiki/index.ts +++ b/packages/admin/src/wiki/index.ts @@ -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[] { diff --git a/packages/backend/api-tests/suites/rbac.ts b/packages/backend/api-tests/suites/rbac.ts new file mode 100644 index 0000000..9db3b6e --- /dev/null +++ b/packages/backend/api-tests/suites/rbac.ts @@ -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 = { 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) + }) +}) diff --git a/packages/backend/src/routes/v1/auth.ts b/packages/backend/src/routes/v1/auth.ts index 6224aef..a97f8f1 100644 --- a/packages/backend/src/routes/v1/auth.ts +++ b/packages/backend/src/routes/v1/auth.ts @@ -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