feat(auth): add /accept-invite page + lookup endpoint
Adds the invitee-side flow for self-serve signup Phase 2 (Task 36):
Backend
- Public GET /accounts/invites/{code}/lookup returns
{account_name, inviter_name, invited_email, role} for a valid invite,
404 invite_invalid_or_expired_or_revoked otherwise (collapses unknown /
expired / revoked / used into one anti-enumeration response). Mounted
in a new account_invite_lookup endpoints module on the public route
list, uses get_admin_db (BYPASSRLS) since the caller has no tenant.
- OAuthCallbackPayload gains optional account_invite_code + invited_email.
_sign_in_or_register honors them: a new OAuth user with a valid invite
joins the invited account (no personal account, no Pro trial), the
invite is marked used, and OAuth-profile-email vs invite-email mismatch
raises invite_email_mismatch (matching the email+password register
contract).
Frontend
- New public route /accept-invite -> AcceptInvitePage. Reads ?code=,
calls inviteApi.lookupAccountInvite, renders "Join {account} on
ResolutionFlow" with the invited email locked (rendered as a div, not
an input), three sign-in options (set password, Google, Microsoft),
and a clear "ask {inviter} to resend" + mailto: fallback for invalid
codes.
- OAuth state for invitees is base64url(JSON({csrf, accountInviteCode,
invitedEmail})). OAuthCallbackPage decodes both shapes, forwards the
invite fields to the backend, and surfaces invite_email_mismatch /
invite_invalid_or_expired_or_revoked errors with friendly text.
Successful invite-OAuth lands on /?welcome=teammate (suppresses the
welcome wizard for invitees per spec).
- UserCreate type + invite/auth API clients extended for the new fields.
Tests
- Backend: invite lookup happy path + four invalid-state collapse, OAuth
callback links invite when supplied + rejects on email mismatch.
- Frontend Vitest: AcceptInvitePage renders account name + locked email
+ accept buttons; resend message + mailto on invalid code.
All 43 backend auth/account/invite/email-verification tests green;
frontend Vitest 120/120 green; tsc -b clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
frontend/src/pages/__tests__/AcceptInvitePage.test.tsx
Normal file
123
frontend/src/pages/__tests__/AcceptInvitePage.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { AcceptInvitePage } from '../AcceptInvitePage'
|
||||
import { inviteApi } from '@/api/invite'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
vi.mock('@/api/invite', () => ({
|
||||
inviteApi: {
|
||||
lookupAccountInvite: vi.fn(),
|
||||
validateCode: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
register: vi.fn().mockResolvedValue(undefined),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
clearError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function renderPage(initialPath: string) {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<AcceptInvitePage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AcceptInvitePage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: ['google', 'microsoft'],
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows account name + locked email + accept buttons for a valid code', async () => {
|
||||
vi.mocked(inviteApi.lookupAccountInvite).mockResolvedValue({
|
||||
account_name: 'Acme MSP',
|
||||
inviter_name: 'Alice Owner',
|
||||
invited_email: 'bob@acme.example',
|
||||
role: 'engineer',
|
||||
})
|
||||
|
||||
renderPage('/accept-invite?code=VALIDINVITECODE0011223344556677')
|
||||
|
||||
// Inviter context (also confirms the lookup completed and rendered)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Alice Owner invited you as engineer/),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
// Account name surfaces in the heading line.
|
||||
expect(
|
||||
screen.getByText((_content, node) => {
|
||||
return (
|
||||
node?.tagName.toLowerCase() === 'span' &&
|
||||
/Acme MSP/.test(node.textContent || '')
|
||||
)
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
|
||||
// Locked email — not an editable input
|
||||
const emailDisplay = screen.getByTestId('invited-email')
|
||||
expect(emailDisplay.tagName.toLowerCase()).not.toBe('input')
|
||||
expect(emailDisplay).toHaveTextContent('bob@acme.example')
|
||||
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
|
||||
|
||||
// OAuth buttons + password submit all rendered
|
||||
expect(screen.getByTestId('oauth-google')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('oauth-microsoft')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('accept-submit')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('accept-submit')).toHaveTextContent(/Join Acme MSP/)
|
||||
|
||||
expect(inviteApi.lookupAccountInvite).toHaveBeenCalledWith(
|
||||
'VALIDINVITECODE0011223344556677',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows resend message + mailto link for an invalid invite code', async () => {
|
||||
vi.mocked(inviteApi.lookupAccountInvite).mockRejectedValue(
|
||||
Object.assign(new Error('not found'), {
|
||||
response: {
|
||||
status: 404,
|
||||
data: { detail: { error: 'invite_invalid_or_expired_or_revoked' } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage('/accept-invite?code=BADCODE')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/This invite is no longer valid/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(
|
||||
screen.getByText(/Ask the person who invited you to resend it/i),
|
||||
).toBeInTheDocument()
|
||||
|
||||
const resendLink = screen.getByRole('link', { name: /Email your inviter/i })
|
||||
expect(resendLink).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringMatching(/^mailto:/),
|
||||
)
|
||||
|
||||
// No accept form rendered when invite is invalid.
|
||||
expect(screen.queryByTestId('accept-submit')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('oauth-google')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user