Skip to content

Commit 81ba53a

Browse files
fix(dashboard): wire updateTeam/inviteMember/listInvitations + email-verify gate + PAT copy fallback
BUGBASH 2026-05-20 — B6/B8 P0/P1 dashboard funnel fixes. B6-P0 003 (Resource detail nav): VERIFIED already fixed in main at 8f6a3f9. ResourcesPage.tsx:188 uses r.token. No-op in this PR. B6-P0 008 (email-verified UX on upgrade): added src/components/VerifyEmailBanner.tsx with isEmailVerifiedError() detector. Wired into CheckoutPage and BillingPage catch blocks so a 403 email_not_verified renders an actionable "Resend magic link" banner instead of a generic "checkout failed" toast. The server fix (auto-verify on claim) is the durable solution; this banner is the UI escape hatch for the window where api hasn't shipped that yet, and a permanent fallback for any other path that lands an unverified user on Upgrade. B8-P1 F1 (updateTeam): wired the no-op stub at src/api/index.ts:445 to PATCH /api/v1/team. Body shape matches openapi.json (required: {name}, 1-200 chars). Response merges TeamSelf into the cached DashboardTeam so consumers reading slug/owner_id/member_count keep working. B8-P1 F2 (listInvitations): wired the empty-stub at src/api/index.ts:489 to GET /api/v1/team/invitations (owner-only). 401/403 fail open to [] so non-owners see the empty pending-invites section instead of a broken page. B8-P1 F3 (inviteMember): wired the no-op stub at src/api/index.ts:496 to POST /api/v1/team/members/invite. Returns the created invitation row. Side change: src/api/types.ts Role gains 'member' so the listInvitations adapter cast is honest — the server's invite enum is {admin, developer, viewer, member}. B8-P1 F5 (DeployTtlPolicyCard admin gate): added a listMembers() probe in src/pages/SettingsPage.tsx that derives the caller's role and renders null for non-owner/admin. Server still enforces 403 on PATCH /api/v1/team/settings — this is UX cleanup so devs/viewers don't see a Save button they can't use. Fails closed: hides while role is unknown. B8-P1 F21 (PAT copy fallback): replaced the inline PAT-mint-result block in SettingsPage with PatCreatedBanner. The token now lives in a textarea (selectable + select() on copy failure) and a loud red toast fires when navigator.clipboard.writeText() refuses (insecure context, blocked permission). The user can keyboard-copy from the textarea instead of losing the one-time-shown token to a silent console.warn. Gates: - tsc --noEmit: green - vitest run: 662 passed, 3 skipped (38 test files) - npm run build: green (vite + prerender both pass) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 229e230 commit 81ba53a

6 files changed

Lines changed: 474 additions & 31 deletions

File tree

src/api/index.ts

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -448,11 +448,38 @@ export async function fetchTeam(): Promise<{ ok: true; team: DashboardTeam }> {
448448
return { ok: true, team: me.team }
449449
}
450450

451-
export async function updateTeam(_patch: { name?: string; display_name?: string }): Promise<{ ok: true; team: DashboardTeam }> {
452-
// PATCH /api/v1/team isn't implemented. Return current team unchanged
453-
// and let the caller surface "this isn't editable yet" to the user.
451+
export async function updateTeam(patch: { name?: string; display_name?: string }): Promise<{ ok: true; team: DashboardTeam }> {
452+
// B8-P1 F1 (BUGBASH 2026-05-20): PATCH /api/v1/team is live (api/openapi.json
453+
// confirms: required `name`, 1-200 chars, whitespace trimmed). The previous
454+
// implementation was a no-op stub that silently returned the cached team
455+
// unchanged — every rename "succeeded" from the UI's POV but never reached
456+
// the server. We now PATCH with the new name (prefer `name`, fall back to
457+
// `display_name` for callers that still pass that key) and rebuild the
458+
// DashboardTeam from the server response so the UI reflects the persisted
459+
// value, not the optimistic input. On error we surface to the caller so the
460+
// form can show a real banner.
461+
const name = (patch.name ?? patch.display_name ?? '').trim()
462+
if (!name) {
463+
// Don't waste a round-trip on an empty patch — the api would 400.
464+
const me = await fetchMe()
465+
return { ok: true, team: me.team }
466+
}
467+
type PatchResp = { ok: boolean; team: { id: string; name: string; plan_tier: string; has_active_subscription: boolean; created_at: string } }
468+
const r = await call<PatchResp>('/api/v1/team', {
469+
method: 'PATCH',
470+
body: JSON.stringify({ name }),
471+
})
472+
// The PATCH response is the slim TeamSelf shape (no slug / owner_id /
473+
// member_count). Merge with /auth/me-derived team so consumers that read
474+
// those fields keep working.
454475
const me = await fetchMe()
455-
return { ok: true, team: me.team }
476+
const team: DashboardTeam = {
477+
...me.team,
478+
name: r.team?.name ?? name,
479+
tier: (r.team?.plan_tier as any) ?? me.team.tier,
480+
created_at: r.team?.created_at ?? me.team.created_at,
481+
}
482+
return { ok: true, team }
456483
}
457484

458485
export async function listMembers(): Promise<{ ok: true; members: TeamMember[]; member_limit: number }> {
@@ -493,18 +520,93 @@ export async function listMembers(): Promise<{ ok: true; members: TeamMember[];
493520
}
494521

495522
export async function listInvitations(): Promise<{ ok: true; invitations: TeamInvitation[] }> {
496-
// GET /api/v1/teams/:id/invitations exists on the agent API but the
497-
// dashboard adapter isn't wired yet. Return empty until then — better
498-
// than fabricating pending invites that don't exist.
499-
return { ok: true, invitations: [] }
523+
// B8-P1 F2 (BUGBASH 2026-05-20): GET /api/v1/team/invitations is live
524+
// (owner-only, see openapi.json — 200/401/403). The previous stub returned
525+
// [] so TeamPage's "Pending · 0" was a lie when the team had real invites.
526+
// We now call the live endpoint and adapt. On 401 (not logged in) and 403
527+
// (caller isn't owner) we fail open to []: the team page renders the
528+
// empty-invite section either way, and a banner would be noise for the
529+
// common case of non-owners viewing the page.
530+
type InviteRow = {
531+
id: string
532+
email: string
533+
role: string
534+
status?: string
535+
invited_by_user_id?: string
536+
invited_by?: string
537+
invited_by_name?: string
538+
created_at: string
539+
expires_at: string
540+
}
541+
type Resp = { ok: boolean; invitations: InviteRow[] }
542+
try {
543+
const r = await call<Resp>('/api/v1/team/invitations')
544+
const invitations: TeamInvitation[] = (r.invitations ?? []).map((i) => ({
545+
id: i.id,
546+
email: i.email,
547+
role: i.role as TeamInvitation['role'],
548+
status: (i.status as TeamInvitation['status']) ?? 'pending',
549+
invited_by: i.invited_by ?? i.invited_by_user_id ?? '',
550+
invited_by_name: i.invited_by_name,
551+
created_at: i.created_at,
552+
expires_at: i.expires_at,
553+
}))
554+
return { ok: true, invitations }
555+
} catch (e: any) {
556+
if (e?.status === 401 || e?.status === 403) {
557+
// Not owner / not logged in — render zero pending invites rather
558+
// than blowing up the team page.
559+
return { ok: true, invitations: [] }
560+
}
561+
throw e
562+
}
500563
}
501564

502-
export async function inviteMember(_body: { email: string; role: string }): Promise<{ ok: true }> {
503-
// The team-invite flow is agent-driven in this product (see TeamPage
504-
// PromptCard). The dashboard never POSTs invitations directly. Return
505-
// ok so any legacy callers don't break; the actual invite is sent by
506-
// the agent running the user's prompt.
507-
return { ok: true }
565+
export async function inviteMember(body: { email: string; role: string }): Promise<{ ok: true; invitation?: TeamInvitation }> {
566+
// B8-P1 F3 (BUGBASH 2026-05-20): POST /api/v1/team/members/invite is live
567+
// (owner/admin only, see openapi.json — 201/400/401/403/409/429). The
568+
// previous stub returned `{ ok: true }` without contacting the server, so
569+
// the agent-driven flow that the prompt-card describes ran on top of a
570+
// broken direct-call path: any code calling inviteMember thought the
571+
// invite was sent when nothing happened.
572+
//
573+
// We now POST and surface the created invitation (or a structured error)
574+
// to the caller. The role enum on the server is {admin, developer, viewer,
575+
// member}; we pass through whatever the caller sent and let the api do the
576+
// validation (returns 400 for an unknown value).
577+
type CreateResp = {
578+
ok: boolean
579+
invitation?: {
580+
id: string
581+
email: string
582+
role: string
583+
status?: string
584+
invited_by_user_id?: string
585+
invited_by?: string
586+
invited_by_name?: string
587+
created_at: string
588+
expires_at: string
589+
}
590+
}
591+
const r = await call<CreateResp>('/api/v1/team/members/invite', {
592+
method: 'POST',
593+
body: JSON.stringify({ email: body.email, role: body.role }),
594+
})
595+
if (!r.invitation) {
596+
return { ok: true }
597+
}
598+
const i = r.invitation
599+
const invitation: TeamInvitation = {
600+
id: i.id,
601+
email: i.email,
602+
role: i.role as TeamInvitation['role'],
603+
status: (i.status as TeamInvitation['status']) ?? 'pending',
604+
invited_by: i.invited_by ?? i.invited_by_user_id ?? '',
605+
invited_by_name: i.invited_by_name,
606+
created_at: i.created_at,
607+
expires_at: i.expires_at,
608+
}
609+
return { ok: true, invitation }
508610
}
509611

510612
// ─── Resources (LIVE) ───────────────────────────────────────────────────

src/api/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
// ------------------------------------------------------------------
55

66
export type Tier = 'anonymous' | 'free' | 'hobby' | 'hobby_plus' | 'pro' | 'team' | 'growth'
7-
export type Role = 'owner' | 'admin' | 'developer' | 'viewer'
7+
// Role: the server-side enum is {owner, admin, developer, viewer, member}.
8+
// 'member' is the default role on POST /api/v1/team/members/invite (see
9+
// api/openapi.json) and was missing from the dashboard type, which made
10+
// `listInvitations()` lose row-level type safety after wiring B8-P1 F2.
11+
export type Role = 'owner' | 'admin' | 'developer' | 'viewer' | 'member'
812
export type Env = 'production' | 'staging' | 'development' | string
913

1014
export type ResourceType =
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// VerifyEmailBanner — B6-P0 008 (BUGBASH 2026-05-20).
2+
//
3+
// The agent-driven funnel was: claim → land on dashboard → click Upgrade →
4+
// POST /api/v1/billing/checkout → Razorpay redirect. The api gate-checks
5+
// `email_verified` on the team's primary user before minting a Razorpay
6+
// subscription, so any team that landed via /claim (which creates the user
7+
// but doesn't auto-verify the email) hits a 403 at checkout. The dashboard
8+
// previously rendered that 403 as a generic "checkout failed" toast, which
9+
// gave the user no recovery path — they couldn't tell *why* and had no
10+
// "resend magic link" action to take.
11+
//
12+
// This component is the recovery path. Callers detect the api's "email
13+
// not verified" envelope (status 403 + a code/message hinting at email
14+
// verification) and render the banner inline next to the failed CTA. The
15+
// banner offers a single click to resend the magic-link email via POST
16+
// /auth/email/start (the same endpoint LoginPage uses), then surfaces the
17+
// "check your inbox" confirmation in place. Once the user clicks the link
18+
// and re-loads the page their session carries `email_verified=true` and
19+
// the original Upgrade click works.
20+
//
21+
// The server fix (auto-verify on claim) is the right durable fix; this
22+
// banner exists for the window where that hasn't shipped, and as a
23+
// permanent fallback for any other path that lands an unverified user on
24+
// the upgrade button (e.g. an admin manually creating a user).
25+
26+
import { useState } from 'react'
27+
28+
const SUFFIX = '/login/callback'
29+
30+
/** Heuristic: was the caught error the api's email-not-verified gate?
31+
*
32+
* The api returns 403 with a code like `email_not_verified` and/or a
33+
* message containing "verify". We match on either. Status 403 alone is
34+
* too broad (RBAC failures, admin gates, etc.), so we require a hint.
35+
*/
36+
export function isEmailVerifiedError(err: unknown): boolean {
37+
if (!err || typeof err !== 'object') return false
38+
const e = err as { status?: number; code?: string; message?: string }
39+
if (e.status !== 403) return false
40+
const code = (e.code ?? '').toLowerCase()
41+
const msg = (e.message ?? '').toLowerCase()
42+
return (
43+
code.includes('email') && (code.includes('verif') || code.includes('not_verified'))
44+
) || (msg.includes('email') && msg.includes('verif'))
45+
}
46+
47+
/** Pulled out so tests + the LoginPage can share the same probe. */
48+
function resolveApiBase(): string {
49+
// Mirror LoginPage.resolveApiBase: prefer the build-time API base if set,
50+
// fall back to current origin. The Vite proxy handles dev.
51+
const fromEnv = (import.meta as any).env?.VITE_API_BASE
52+
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv
53+
return ''
54+
}
55+
56+
export function VerifyEmailBanner({ email }: { email?: string }) {
57+
const [busy, setBusy] = useState(false)
58+
const [sent, setSent] = useState(false)
59+
const [err, setErr] = useState<string | null>(null)
60+
61+
const targetEmail = (email ?? '').trim()
62+
63+
async function resend() {
64+
if (!targetEmail) {
65+
setErr('No email on this session — please log in again.')
66+
return
67+
}
68+
setBusy(true)
69+
setErr(null)
70+
try {
71+
const apiBase = resolveApiBase()
72+
const url = apiBase + '/auth/email/start'
73+
const returnTo = window.location.origin + SUFFIX
74+
const resp = await fetch(url, {
75+
method: 'POST',
76+
headers: { 'Content-Type': 'application/json' },
77+
body: JSON.stringify({ email: targetEmail, return_to: returnTo }),
78+
})
79+
// /auth/email/start deliberately returns 202 even for unknown emails
80+
// to avoid email-enumeration. Treat any 2xx as success.
81+
if (resp.status >= 400) {
82+
const body = await resp.json().catch(() => null)
83+
throw new Error((body && body.message) || resp.statusText)
84+
}
85+
setSent(true)
86+
} catch (e: any) {
87+
setErr(e?.message ?? 'Could not send the magic link.')
88+
} finally {
89+
setBusy(false)
90+
}
91+
}
92+
93+
return (
94+
<div
95+
role="alert"
96+
data-testid="verify-email-banner"
97+
style={{
98+
marginTop: 12,
99+
padding: '12px 14px',
100+
border: '1px solid var(--accent, #00e48e)',
101+
background: 'var(--accent-soft, rgba(0, 228, 142, 0.08))',
102+
borderRadius: 6,
103+
fontSize: 13,
104+
lineHeight: 1.55,
105+
}}
106+
>
107+
<div style={{ marginBottom: 8 }}>
108+
<strong>Verify your email to upgrade.</strong> Checkout is gated on a
109+
verified email — the claim flow creates your account but doesn't auto-verify it.
110+
</div>
111+
{targetEmail && (
112+
<div style={{ marginBottom: 10, color: 'var(--text-dim)', fontSize: 12 }}>
113+
We'll send the magic link to <code>{targetEmail}</code>.
114+
</div>
115+
)}
116+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
117+
<button
118+
className="btn btn-sm btn-primary"
119+
data-testid="verify-email-resend"
120+
onClick={resend}
121+
disabled={busy || sent || !targetEmail}
122+
>
123+
{sent ? 'Sent — check your inbox' : busy ? 'Sending…' : 'Resend magic link'}
124+
</button>
125+
{sent && (
126+
<span data-testid="verify-email-sent-hint" style={{ fontSize: 11.5, color: 'var(--text-dim)' }}>
127+
Click the link, then retry Upgrade.
128+
</span>
129+
)}
130+
</div>
131+
{err && (
132+
<div
133+
role="alert"
134+
data-testid="verify-email-resend-error"
135+
style={{ marginTop: 10, color: 'var(--rose, #ff5a6e)', fontSize: 12 }}
136+
>
137+
{err}
138+
</div>
139+
)}
140+
</div>
141+
)
142+
}

src/pages/BillingPage.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as api from '../api'
88
import type { BillingDetails, BillingUsage, ChangePlanTier, Invoice, PlanFrequency, Tier } from '../api'
99
import { useDashboardCtx } from '../hooks/useDashboardCtx'
1010
import { formatInvoiceAmount, formatInvoiceDate } from '../lib/currency'
11+
import { isEmailVerifiedError, VerifyEmailBanner } from '../components/VerifyEmailBanner'
1112

1213
// Tiers eligible for the in-dashboard Change-plan modal. Anonymous + free
1314
// users have no active subscription to swap, so the /api/v1/billing/change-
@@ -169,6 +170,10 @@ export function BillingPage() {
169170
const [billingLoading, setBillingLoading] = useState(true)
170171
const [checkoutErr, setCheckoutErr] = useState<string | null>(null)
171172
const [checkoutLoading, setCheckoutLoading] = useState(false)
173+
// B6-P0 008 (BUGBASH 2026-05-20): when the api 403s for email_not_verified,
174+
// we surface the VerifyEmailBanner inline next to the upgrade grid instead
175+
// of just dropping the failure into the generic checkout error toast.
176+
const [verifyEmailGate, setVerifyEmailGate] = useState(false)
172177

173178
// Frequency state — default Annual. Read once on mount.
174179
const [frequency, setFrequencyState] = useState<PlanFrequency>(() => readStoredFrequency())
@@ -294,6 +299,7 @@ export function BillingPage() {
294299
return
295300
}
296301
setCheckoutErr(null)
302+
setVerifyEmailGate(false)
297303
setCheckoutLoading(true)
298304
try {
299305
// Promo codes are intentionally NOT plumbed through the dashboard
@@ -309,7 +315,13 @@ export function BillingPage() {
309315
}
310316
setCheckoutErr('checkout returned no url')
311317
} catch (e: any) {
312-
setCheckoutErr(e?.message ?? 'checkout failed')
318+
if (isEmailVerifiedError(e)) {
319+
// B6-P0 008: don't render the generic toast — show the recoverable
320+
// "Verify your email" banner with a resend-magic-link button.
321+
setVerifyEmailGate(true)
322+
} else {
323+
setCheckoutErr(e?.message ?? 'checkout failed')
324+
}
313325
} finally {
314326
setCheckoutLoading(false)
315327
}
@@ -416,6 +428,9 @@ export function BillingPage() {
416428
{checkoutErr}
417429
</div>
418430
)}
431+
{verifyEmailGate && (
432+
<VerifyEmailBanner email={me?.user?.email} />
433+
)}
419434
<div style={{ marginTop: 10, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
420435
<a
421436
className="btn btn-ghost btn-sm"

0 commit comments

Comments
 (0)