Skip to content

Tenant Onboarding Walkthrough

This guide walks a Platform Admin through provisioning a new construction company (tenant), then walks the Owner through logging in, inviting their team, and running the first bid through to a completed job.

Related: docs/SPEC.md §"No self-serve tenant signup", §"Auth0 Integration", §"Roles & Permissions". See also Permissions Matrix and Tenant Enforcement.


Overview

Hardhat Flow is a multi-tenant SaaS. Each company gets its own isolated MySQL database. Provisioning is admin-only — there is no public sign-up. The flow is:

Platform Admin approves AccessRequest
  → per-tenant DB created + Owner row pre-seeded
    → Owner logs in via Auth0 (auth0_sub bound on first JWT)
      → Owner invites team from /settings/team/
        → Invited users log in via Auth0 (auto-linked by email)
          → PM creates bid → submits → Owner approves → Job created
            → PM starts job → completes job → Accounting invoices

Part 1 — Local Walkthrough

Prerequisites

  • Docker Desktop running
  • Repo cloned; working directory is the repo root
  • Node ≥ 20, Python ≥ 3.12, uv installed
  • Auth0 dev application configured (see Auth0 Setup Appendix)
  • .env (or env/) contains:
Variable Description
TENANT_ENCRYPTION_KEY Fernet key for per-tenant DB credential encryption
AUTH0_DOMAIN e.g. dev-xyz.us.auth0.com
AUTH0_AUDIENCE e.g. https://api.hardhatflow.local
AUTH0_CLIENT_ID SPA client ID
MYSQL_ROOT_PASSWORD Local MySQL root password

Step 1 — Start the stack

docker compose up -d
uv pip install -e .
python manage.py migrate --no-input
npm --prefix frontend ci
npm --prefix frontend run dev &

Verify: curl -s http://localhost:8000/health/ returns {"status":"ok"}.

Step 2 — Submit an access request (public endpoint)

The prospect's company submits a request — no auth required.

curl -s -X POST http://localhost:8000/platform/access-requests/ \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "Acme Construction",
    "email": "[email protected]",
    "phone": "+15555550100"
  }' | jq .

Note the returned id — you need it in Step 3.

Alternatively, use the helper script which handles all steps:

scripts/onboard_demo_tenant.sh \
  --env=local \
  --slug=acme \
  --owner-email=[email protected] \
  --owner-name="Alice Owner" \
  --platform-token=<your-platform-admin-jwt>

Step 3 — Platform Admin approves the request

Getting a local platform-admin JWT

# python manage.py shell
from apps.accounts.tests.helpers import make_platform_admin_jwt
print(make_platform_admin_jwt())
Or log in as a platform admin in the frontend and copy the Bearer from browser DevTools → Network.

AR_ID="<access-request-uuid>"
PLATFORM_TOKEN="<platform-admin-bearer>"

curl -s -X POST \
  "http://localhost:8000/platform/access-requests/$AR_ID/approve/" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -d '{"slug": "acme"}' | jq .

What happens internally:

  1. provision_tenant() runs CREATE DATABASE tenant_acme on the shared MySQL cluster
  2. A MySQL user is created and creds are Fernet-encrypted into Company.db_credentials
  3. manage.py migrate --database=tenant_acme initialises the schema
  4. An Owner User row is inserted into tenant_acme with role=owner, auth0_sub=NULL

Verify:

# tenant DB exists
docker compose exec db mysql -uroot -p"$MYSQL_ROOT_PASSWORD" \
  -e "SHOW DATABASES LIKE 'tenant_acme';"

# Owner row pre-seeded (auth0_sub is NULL)
curl -s http://localhost:8000/platform/tenants/ \
  -H "Authorization: Bearer $PLATFORM_TOKEN" | jq '.[] | select(.slug=="acme")'

Step 4 — Owner logs in via Auth0

Send the owner this URL: http://localhost:3000/login

The owner signs up at Auth0 using [email protected]. On first login:

  • Auth0 issues a JWT with sub, email, email_verified: true
  • Django's Auth0JWTAuthentication._get_or_create_user finds the pre-seeded row by email, writes auth0_sub, and binds the session to tenant_acme
  • The frontend routes the owner to /dashboard/

Auth0 first-login race condition

If the owner attempts to log in before Step 3 completes, the email-match lookup will find no tenant row and return a no_tenant error. Ensure Step 3 is fully complete before sharing the login URL.

Step 5 — Owner invites team members

The owner opens Settings → Team (/settings/team/).

For each team member, click + Invite and fill in:

Field Example
First name Pat
Last name Manager
Email [email protected]
Phone +15555550101
Role Project Manager

Available roles: project_manager, supervisor, accounting, badge_worker. The owner cannot assign owner or platform_admin.

Or via API:

OWNER_TOKEN="<owner-bearer>"

curl -s -X POST http://localhost:8000/api/v1/accounts/users/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OWNER_TOKEN" \
  -d '{
    "email": "[email protected]",
    "first_name": "Pat",
    "last_name": "Manager",
    "phone": "+15555550101",
    "role": "project_manager"
  }' | jq .

Invited users appear in the team list as Awaiting first login (auth0_sub is NULL). Share http://localhost:3000/login with each invitee — they sign up at Auth0 with the same email and are auto-linked.

Step 6 — PM creates and submits a bid

The PM logs in and navigates to Bids → New Bid (/bids/new/).

Fill in:

  • Client — select from dropdown (or create inline)
  • Location — job site address
  • Scope — work description
  • Measurements / Equipment — line items

Submit the form. The bid is created with status New Bid.

Or via API:

PM_TOKEN="<pm-bearer>"
CLIENT_ID="<uuid>"

curl -s -X POST http://localhost:8000/api/v1/bids/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $PM_TOKEN" \
  -d "{
    \"client_id\": \"$CLIENT_ID\",
    \"location\": \"123 Main St\",
    \"scope\": \"Concrete pour — north parking lot\"
  }" | jq .

Then submit:

BID_ID="<bid-uuid>"

curl -s -X POST "http://localhost:8000/api/v1/bids/$BID_ID/submit/" \
  -H "Authorization: Bearer $PM_TOKEN" | jq .
# status is now: awaiting_po

curl -s -X PATCH "http://localhost:8000/api/v1/bids/$BID_ID/" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $PM_TOKEN" \
  -d '{"po_number": "PO-2026-001"}' | jq .

Step 7 — Owner approves the bid (auto-creates Job)

The owner opens the bid detail page at /bids/<bid-id>/ and clicks Approve.

This calls POST /api/v1/bids/<id>/approve/ which:

  1. Transitions bid status to Bid Approved (terminal)
  2. Auto-creates a Job with status Ready to Start — copying project_manager and supervisor from the bid
  3. Bid becomes read-only (Owner-only edits thereafter, logged to ActivityLog)

Verify:

curl -s "http://localhost:8000/api/v1/bids/$BID_ID/" \
  -H "Authorization: Bearer $PM_TOKEN" \
  | jq '{status: .status, has_job: .has_job}'

Step 8 — PM starts and completes the job

The PM opens Jobs → [job] and clicks Start Job (visible when status is ready).

Or via API:

JOB_ID="<job-uuid>"

curl -s -X POST "http://localhost:8000/api/v1/jobs/$JOB_ID/start/" \
  -H "Authorization: Bearer $PM_TOKEN" | jq .
# status: in_progress

curl -s -X POST "http://localhost:8000/api/v1/jobs/$JOB_ID/complete/" \
  -H "Authorization: Bearer $PM_TOKEN" | jq .
# status: needs_invoice

The job is now in Completed / Needs Invoice — ready for the Accounting team to invoice.

Rollback (local)

# Soft-delete the company (platform DB)
python manage.py shell -c "
from apps.companies.models import Company
Company.objects.filter(slug='acme').update(is_active=False)
"

# Drop the tenant DB
docker compose exec db mysql -uroot -p"$MYSQL_ROOT_PASSWORD" \
  -e "DROP DATABASE IF EXISTS tenant_acme;"

Part 2 — Staging Walkthrough

Prerequisites

  • doctl installed and authenticated (doctl auth init)
  • Access to the DO App Platform job-runner component
  • Auth0 staging tenant configured with the same SPA app settings
  • Platform admin account in Auth0 staging tenant

Step 1 — Access the job-runner shell

# List app components
doctl apps list

APP_ID="<your-app-id>"
doctl apps logs "$APP_ID" job-runner --type run --follow

To run management commands against staging:

doctl apps exec "$APP_ID" --component=job-runner -- python manage.py shell

Step 2 — Submit access request

Same curl as local but against the staging URL:

curl -s -X POST https://hardhatflow.com/platform/access-requests/ \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "Acme Construction",
    "email": "[email protected]",
    "phone": "+15555550100"
  }' | jq .

Or run the helper script:

scripts/onboard_demo_tenant.sh \
  --env=staging \
  --slug=acme-staging \
  --owner-email=[email protected] \
  --owner-name="Alice Owner" \
  --platform-token=<staging-platform-admin-jwt> \
  --i-understand-staging

Staging blast radius

--i-understand-staging is required. The script also rejects slugs on the deny-list (prod, production, hardhat, hardhatflow, admin). Never provision against staging with a slug that matches a production tenant.

Step 3 — Platform Admin approves

Log in as a Platform Admin at https://hardhatflow.com/login. Open DevTools → Network, copy the Authorization: Bearer header from any /api/ request.

PLATFORM_TOKEN="<staging-platform-admin-jwt>"
AR_ID="<access-request-uuid>"

curl -s -X POST \
  "https://hardhatflow.com/platform/access-requests/$AR_ID/approve/" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -d '{"slug": "acme-staging"}' | jq .

Steps 4–8

Same as local, substituting http://localhost:3000https://hardhatflow.com and http://localhost:8000https://hardhatflow.com.

Rollback (staging)

# Via DO job-runner shell
doctl apps exec "$APP_ID" --component=job-runner -- python manage.py shell -c "
from apps.companies.models import Company
Company.objects.filter(slug='acme-staging').update(is_active=False)
"

Contact the DO Managed Database admin to drop the tenant_acme_staging schema if full cleanup is needed.


Auth0 Setup Appendix

SPA Application settings

Setting Value
Application Type Single Page Application
Allowed Callback URLs http://localhost:3000/post-login (local) / https://hardhatflow.com/post-login (prod)
Allowed Logout URLs http://localhost:3000 / https://hardhatflow.com
Allowed Web Origins http://localhost:3000 / https://hardhatflow.com

Custom roles claim

Add an Auth0 Action (Login / Post Login) that writes the user's app_metadata role into the JWT:

exports.onExecutePostLogin = async (event, api) => {
  const namespace = 'https://hardhatpro.com/roles';
  const roles = event.authorization?.roles ?? [];
  api.idToken.setCustomClaim(namespace, roles);
  api.accessToken.setCustomClaim(namespace, roles);
};

Platform Admin role is read from this claim; all other roles are DB-authoritative and set by the Owner during team invite.


Troubleshooting

auth0_sub not bound after first login : Ensure email_verified: true in the Auth0 JWT. Auth0 may require email verification before marking it verified. Check _get_or_create_user at apps/accounts/authentication.py:281.

403 on team invite : Caller must be role=owner. Platform admins calling tenant endpoints must pass X-Tenant-Slug: <slug> header or ?acting_tenant=<slug> query param.

400 on bid approval — "PO number is required" : The bid state machine requires po_number to be set before awaiting_po → approved. Patch po_number first (Step 6 above).

Per-tenant DB KeyError on cold start : The tenant DB alias is registered lazily in apps/companies/middleware.py:19. A request to a tenant endpoint before the alias is configured triggers this. Retry the request — the middleware registers the alias on the next call.

Invited user sees "account error" on login : The user logged in with a different email than the one the owner entered. Auth0 sub → email match requires exact (case-insensitive) match and email_verified=true.