29 · Contractors / Vendors
The platform supports external contractors and vendors
(subcontractors, freelancers, suppliers) who get their own dedicated
portal at /vendors. They sign in via a separate /vendor-login
URL, see only what's been explicitly assigned to them, and update
their own status, time, and ticket replies.
The internal role code is VENDOR for both — the label
"Contractor / Vendor" is purely the user-facing display string.
Two distinct surfaces
| Audience | URL | Chrome |
|---|---|---|
| Staff | /login → / |
Full PortalShell, sidebar, all modules |
| Vendor | /vendor-login → /vendors |
Stripped-down VendorShell (5 tabs) |
The main staff login screen has a Vendor sign in link in its
footer (alongside Status and Knowledge base) that goes to
/vendor-login. Vendors arriving via either login flow are
auto-redirected to /vendors after authenticating; staff arriving via
/vendor-login are refused with the error "This is the contractor /
vendor portal login. Use the staff sign-in instead."
Creating a vendor account
/admin/users/invite — pick VENDOR from the role dropdown.
You can use either tab:
- ✉️ Send invite — vendor sets their own password.
- ⚡ Create now — admin sets the password directly. Useful when onboarding a sub-contractor on a kickoff call.
⚠️ Caution. Vendor accounts are scoped to your tenant. If you operate multiple tenants, create the vendor in the tenant whose projects they'll work on.
Linking a vendor to a project
Once the account exists, open the project (/projects/<id>) — the
Vendors card on the detail page lets admins:
- Pick a vendor from the dropdown (only VENDOR-role users in the current tenant appear)
- Add an optional role label ("Cabling", "Racking", …)
- Click Link vendor to grant access
- Click Remove to revoke
The vendor sees the project under /vendors/projects immediately on
their next refresh.
📷 Screenshot placeholder: screenshots/vendor-link-project.png
What a vendor sees
Five tabs on the vendor portal:
| Tab | Scope |
|---|---|
| 🏠 Dashboard | Counts of their projects + open tickets, recent rows |
| 📋 Projects | Projects where they have a ProjectVendor link |
| 🎫 Tickets | Tickets where assigneeId is their user ID |
| ⏱️ Timesheets | Their own time entries (Project + Manual kinds) |
| 👤 Profile | Read-only profile view |
What a vendor does not see:
- Other vendors' rows (even on the same project)
- Staff-assigned tickets, even if linked to a project they're on
- Any module not in the five tabs (no CRM, no RMM, no admin, no customers)
- The tenant switcher (it's hidden for non-admin-tenancy users)
What a vendor can update
| Surface | What they can change |
|---|---|
| Project detail | Status notes (free text) — visible to staff. Nothing else (status, billable, invoice fields are locked) |
| Project detail | Log time against the project (PROJECT kind), against projects they're linked to |
| Ticket detail | Status — set the ticket's typed status (statusId) to any status |
| Ticket detail | Add a comment — any comment a vendor posts is created as public (isInternal: false). Note that the vendor ticket view does still show existing internal notes — internal notes are not hidden from vendors. |
| Timesheets | Log PROJECT or GENERAL entries, and delete their own (no TICKET kind). The vendor time API also supports editing an entry, but the vendor portal UI exposes only logging and delete. |
| Profile | Read-only for now |
Access boundary
Every /api/vendor/* route runs requireVendor() first:
- Verify a valid JWT
- Verify
role === 'VENDOR' - Filter every query by
userId === jwt.sub
A staff token hitting any vendor API gets 403 "Contractor / Vendor
account required". A vendor token hitting any staff API hits the
existing auth-service guards (which check role for admin-only
endpoints).
The (vendor) route group's client-side layout also redirects:
- No JWT →
/vendor-login - JWT with
role !== 'VENDOR'→/
This is defence in depth — the API checks are the real boundary. The client checks are UX so non-vendors don't get a confusing empty shell.
End-to-end test (verified)
1. Admin creates vendor (e2e-vendor@example.test, role=VENDOR, password set)
2. Vendor logs in via POST /api/auth/login → 200 + JWT(role=VENDOR)
3. GET /api/vendor/me → 200 with vendor's record, role=VENDOR
4. GET /api/vendor/projects → 200, [] (no project links)
5. Admin token GET /api/vendor/me → 403 "Contractor / Vendor account required" ✓
Common workflows
Onboard a subcontractor for a SNAG project
- Admin → Users → Invite → role=VENDOR. Either send invite or set password directly.
- Open the SNAG project → Vendors card → pick the new vendor → role "Cabling" → Link.
- Tell the subbie to go to
portal.kwgroup.org.uk/vendor-login. - They log in, see only the SNAG project on
/vendors, log time against it as they work.
Assign a ticket to a vendor for triage
- On the ticket: Assignee dropdown → pick the vendor user.
- Ticket immediately appears at
/vendors/ticketsfor them. - They reply with a public comment + change status to "In progress".
Offboard a vendor
- Project → Vendors → Remove for each project they're on.
- Admin → Users → suspend the account.
- Their existing time entries stay (audit trail).
See also
- Timesheets — same model, different scope
- Projects — staff-side project management
- Users & access — creating accounts