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,
uvinstalled - Auth0 dev application configured (see Auth0 Setup Appendix)
.env(orenv/) 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())
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:
provision_tenant()runsCREATE DATABASE tenant_acmeon the shared MySQL cluster- A MySQL user is created and creds are Fernet-encrypted into
Company.db_credentials manage.py migrate --database=tenant_acmeinitialises the schema- An Owner
Userrow is inserted intotenant_acmewithrole=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_userfinds the pre-seeded row by email, writesauth0_sub, and binds the session totenant_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 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:
- Transitions bid status to
Bid Approved(terminal) - Auto-creates a
Jobwith statusReady to Start— copyingproject_managerandsupervisorfrom the bid - 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¶
doctlinstalled 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:3000 → https://hardhatflow.com and http://localhost:8000 → https://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.