Adds a portfolio-privilege concept (creator > admin > domna employee >
write > read > none) and gates all user-access mutations + the pending-
invitations view behind it. Plus opens the role dropdown to include
"admin" so creators/admins/Domna can promote and demote.
Privilege model:
- Portfolio creator: full admin powers (cannot be removed/demoted)
- Portfolio admin: full admin powers via explicit membership role
- Domna employee (email ends @domna.homes, case-insensitive):
implicit admin across all portfolios, even if not a member —
intended for customer-support / internal-tooling needs
- Anyone else (read/write/none): no admin powers
Backend:
- New pure-function helpers in src/app/lib/portfolioAdmin.ts —
isDomnaEmail() and canAdminister(privilege), with 6 tests covering
case-insensitivity and look-alike domain rejection
- New server helper resolvePortfolioPrivilege() that reads
portfolioUsers + checks the email, returning the highest privilege
- New denyIfNotAdmin(portfolioId, session) one-liner that returns a
401/403 NextResponse or null; used at the top of every mutating
route handler to keep the guard out of the way
- POST/PUT/DELETE on /colloborators and GET/DELETE on /invitations
are now all gated. Non-admin callers get 403.
- GET /colloborators now requires auth and returns
`{ users, currentUser: { privilege } }` so the UI knows which
actions to expose without an extra round-trip
Frontend:
- ROLE_OPTIONS extended to ["read", "write", "admin"]. RoleDropdown
takes allowAdminPromotion?: boolean to keep the basic dropdown
unchanged where promotion isn't allowed.
- UsersPermissionsCard derives isAdmin = canAdminister(privilege)
from the API response. Invite section, role-change dropdown,
Remove button, and the entire Pending Invitations section are now
rendered only when isAdmin. Non-admins see a read-only members
table.
- The invitations useQuery is disabled when !isAdmin, avoiding
guaranteed-403 network calls.
Defensive note: the UI gating is for UX; the backend guard is the
security boundary. A non-admin who hand-crafts a POST still gets 403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same email now contains a 6-digit code and a magic link, both backed by a
single verificationToken row. After submitting their email, the user
lands on /auth/verify-code with a single-input form (inputmode=numeric,
autocomplete=one-time-code, auto-submit on 6 digits or paste) and can
either type the code or use the link from the email. Either path
consumes the same row — single-use, replace-on-resend.
This is the structural fix for the silent-quarantine pattern observed
with Atkins and Sustainable Building UK: corporate gateways are happier
with short transactional content than long opaque token URLs, and a
code can't be broken by SafeLinks-style URL rewriting. The link path is
preserved so users whose email gets through unmangled keep one-click UX.
Security:
- Codes are 6-digit, crypto.randomInt-generated, stored as sha256
hashed against NEXTAUTH_SECRET on the same row as the link token
- 5-attempt lockout per code (attempts column); 6th attempt with the
correct code still fails
- Per-email send rate limit: 5/hour fixed window (authRateLimits
table); 6th send returns an error
- Code + link share a 10-minute window (maxAge dropped from 1h)
- Resending replaces any prior token rows for the identifier so only
the latest send is ever live
Implementation:
- verificationCode.ts holds generateCode + hashCode + the pure
evaluateCodeAttempt decision tree; 9 unit tests cover every branch
of the verification outcome (no-such-row, expired, locked-out, ok,
wrong-with-newAttempts, locked-out-still-rejects-correct-code)
- sendVerificationRequest now hashes the URL token the same way
/verify/[token]/page.tsx does, applies the rate limit + records the
code + replaces older rows in two transactions
- CredentialsProvider (id: "email-code") calls evaluateCodeAttempt
inside a transaction, handles all 5 outcomes, creates the user on
first successful code (parity with the magic-link callback path)
- oauthId backfill in the signIn callback is now guarded on
account.type === "oauth" so the credentials flow doesn't pollute
oauthProvider with "email-code"
- Migration is additive: code_hash nullable, attempts default 0; new
authRateLimits table is independent. In-flight tokens at deploy time
keep working via the link path.
Vercel preview deployment is the test surface; a Mailpit + Cypress E2E
loop is intentionally deferred per the lean-setup plan in docs/wip/
auth-email-code-fallback-plan.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements selectPibiMeasures (issue #254): replaces all pibi_ordered
rows for a deal, then pushes the new list to HubSpot under
measures_for_pibi_ordered via syncMeasuresFieldToHubSpot. Mirrors the
instruct-measure service; no approval rows touched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Approver-only flow that records an instructed measure in
user_defined_deal_measures, auto-creates a deal_measure_approvals row +
event in a single tx, then pushes the instructed list to HubSpot under
instructed_measures. When the deal has no proposed measures and no prior
approvals, also pushes the new measure as proposed_measures so the deal
has a coherent starting point. Soft-warning helper surfaces the
out-of-order case for the drawer (and later slices).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Generic push for multi-value HubSpot deal properties (semicolon-separated)
with the same ECONNRESET retry pattern as pushDealPropertiesToHubSpot.
Returns a discriminated ok result so callers can stamp pushed_at only on
success. Slice 4 (PIBI selections) will reuse the same helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Plug `domna_survey_type` and `domna_survey_date` into
DEAL_PROPERTY_FIELDS, gated on the approver capability and reusing the
shared string-or-null + ISO date schemas. Drops the now-unused boolean
column types from the field registry. Vitest cases cover validation,
capability gating, HubSpot mapping, independent setting and clear-to-null.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>