diff --git a/CHANGELOG.json b/CHANGELOG.json index 123113f..2471975 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -483,5 +483,28 @@ } ] } - ] + ], + "unreleased": { + "highlights": [ + { + "description": "`@plexusone/invites`: pages for SystemForge invite-gated sign-up, an operator page for invite codes, email invites, the waitlist and member allowances, and a member page to invite people" + } + ], + "added": [ + { + "description": "`createInvitesApi`: a typed client for SystemForge's invite API, with snake_case wire types and `ApiError`" + }, + { + "description": "`InviteAdminPage`: codes, email invites, waitlist, allowances and joined accounts, with new codes and links shown once and inline revoke" + }, + { + "description": "`InvitePeoplePage`: a member's allowance, create and copy an invite link, and revoke unused ones" + } + ], + "documentation": [ + { + "description": "`@plexusone/invites` package page" + } + ] + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 780e501..4c439d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +### Highlights + +- `@plexusone/invites`: pages for SystemForge invite-gated sign-up, an operator page for invite codes, email invites, the waitlist and member allowances, and a member page to invite people + +### Added + +- `createInvitesApi`: a typed client for SystemForge's invite API, with snake_case wire types and `ApiError` +- `InviteAdminPage`: codes, email invites, waitlist, allowances and joined accounts, with new codes and links shown once and inline revoke +- `InvitePeoplePage`: a member's allowance, create and copy an invite link, and revoke unused ones + +### Documentation + +- `@plexusone/invites` package page + ## [v0.4.0] - 2026-10-20 ### Highlights diff --git a/README.md b/README.md index 056f394..f79bdbb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Alphabetical; see [Packages](docs/packages/index.md) for how they depend on each | `@plexusone/console` | Organization console pages | [![npm](https://img.shields.io/npm/v/@plexusone/console)](https://www.npmjs.com/package/@plexusone/console) | | `@plexusone/design-tokens` | Design system tokens | [![npm](https://img.shields.io/npm/v/@plexusone/design-tokens)](https://www.npmjs.com/package/@plexusone/design-tokens) | | `@plexusone/host` | App and shell contracts, createHost | [![npm](https://img.shields.io/npm/v/@plexusone/host)](https://www.npmjs.com/package/@plexusone/host) | +| `@plexusone/invites` | Invite-gated sign-up pages | [![npm](https://img.shields.io/npm/v/@plexusone/invites)](https://www.npmjs.com/package/@plexusone/invites) | | `@plexusone/pages` | Pre-built pages | [![npm](https://img.shields.io/npm/v/@plexusone/pages)](https://www.npmjs.com/package/@plexusone/pages) | | `@plexusone/shell` | Application shell | [![npm](https://img.shields.io/npm/v/@plexusone/shell)](https://www.npmjs.com/package/@plexusone/shell) | | `@plexusone/telemetry` | Event instrumentation | [![npm](https://img.shields.io/npm/v/@plexusone/telemetry)](https://www.npmjs.com/package/@plexusone/telemetry) | diff --git a/docs/changelog.md b/docs/changelog.md index 780e501..4c439d5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,20 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +### Highlights + +- `@plexusone/invites`: pages for SystemForge invite-gated sign-up, an operator page for invite codes, email invites, the waitlist and member allowances, and a member page to invite people + +### Added + +- `createInvitesApi`: a typed client for SystemForge's invite API, with snake_case wire types and `ApiError` +- `InviteAdminPage`: codes, email invites, waitlist, allowances and joined accounts, with new codes and links shown once and inline revoke +- `InvitePeoplePage`: a member's allowance, create and copy an invite link, and revoke unused ones + +### Documentation + +- `@plexusone/invites` package page + ## [v0.4.0] - 2026-10-20 ### Highlights diff --git a/docs/packages/index.md b/docs/packages/index.md index 6017056..e201bc9 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -32,6 +32,7 @@ share one copy. Every package also needs React 19. | [@plexusone/console](console.md) | The organization console | `consoleApp`, `consoleSessionClient`, `Connections`, `Connect` | | [@plexusone/design-tokens](design-tokens.md) | Design system | Colors, typography, spacing | | [@plexusone/host](host.md) | Hosting applications | `AppModule`, `Shell`, `createHost`, `DevShell` | +| [@plexusone/invites](invites.md) | Invite-gated sign-up pages | `InviteAdminPage`, `InvitePeoplePage`, `createInvitesApi` | | [@plexusone/pages](pages.md) | Pre-built pages | `LoginPage`, `ErrorPage`, `OrganizationSettingsPage` | | [@plexusone/shell](shell.md) | Application chrome | `AppShell`, `ProductSwitcher`, `OrganizationSwitcher`, `ProfileMenu` | | [@plexusone/telemetry](telemetry.md) | Instrumentation | `TelemetryProvider`, `ErrorBoundary` | diff --git a/docs/packages/invites.md b/docs/packages/invites.md new file mode 100644 index 0000000..184edd6 --- /dev/null +++ b/docs/packages/invites.md @@ -0,0 +1,77 @@ +# @plexusone/invites + +Pages for SystemForge's invite-gated sign-up: an operator page to manage +invite codes, email invites, the waitlist and member allowances, and a member +page to invite people. Both call SystemForge's invite API (`signupapi`); see +SystemForge's [invite-gated sign-up guide](https://plexusone.github.io/systemforge/guides/invite-gated-signup/). + +```bash +pnpm add @plexusone/invites @plexusone/ui +``` + +## Use the pages + +The pages are plain components: route them in your application wherever they +belong. They are not an `AppModule`, because invites are platform-wide, not +organization-scoped. + +```tsx +import { createInvitesApi, InviteAdminPage, InvitePeoplePage } from '@plexusone/invites'; + +// Where the host serves the invite API: behind its own session... +const invites = createInvitesApi('/invites/api'); +// ...or on SystemAuth with Options.SignupAPI: +// createInvitesApi('https://auth.example.com/signup-invites/api'); + +} /> +} /> +``` + +Requests carry the browser's cookies, so the API must be same-origin with the +page or allow credentialed requests from it. The pages use Tailwind classes +from `@plexusone/ui`'s preset; add the package's `dist` to your Tailwind +sources. + +## InvitePeoplePage + +For any signed-in person: + +- how many people they can still invite, from their allowance; +- **Create invite link**, disabled when none are left, which shows the new + link once with a copy button; +- their links with status and expiry, and **Revoke** (asked inline) for a link + nobody has used, which gives the slot back. + +| Prop | Type | Description | +| ------- | ------------ | ---------------------------------- | +| `api` | `InvitesApi` | The client from `createInvitesApi` | +| `title` | `string` | Heading; default "Invite people" | + +## InviteAdminPage + +For platform administrators; anyone else sees the API's refusal. Sections: + +| Section | Does | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Invite codes** | Create a code with a label, how many people it admits, expiry and note; the code and link are shown once. List codes with uses and status; revoke | +| **Email invites** | Invite pasted addresses, with a note and expiry; list with status; revoke | +| **Waitlist** | Select entries (or everyone not yet invited) and invite them; remove an entry | +| **Member allowances** | Set how many people a member may invite, by email, or return them to the default | +| **Joined** | Accounts admitted by codes and email invites, with the code or member that brought them | + +| Prop | Type | Description | +| ------- | ------------ | ---------------------------------- | +| `api` | `InvitesApi` | The client from `createInvitesApi` | +| `title` | `string` | Heading; default "Invites" | + +## API client + +`createInvitesApi(baseUrl)` returns an `InvitesApi` with one method per +route: `settings`, `myInvites`, `createMyCode`, `revokeMyCode`, and for +administrators `invites`, `addInvites`, `revokeInvite`, `codes`, +`createCode`, `revokeCode`, `waitlist`, `inviteFromWaitlist`, +`removeFromWaitlist`, `allowances`, `setAllowance` and `joined`. A failed +call throws `ApiError` with the HTTP status and the API's message. Wire types +(`InviteCode`, `CreatedCode`, `EmailInvite`, `WaitlistEntry`, `Allowance`, +`JoinedAccount`, `MyInvites`, `InviteSettings`) are snake_case, as the API +sends them. diff --git a/mkdocs.yml b/mkdocs.yml index 77d7943..61d0082 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - '@plexusone/console': packages/console.md - '@plexusone/design-tokens': packages/design-tokens.md - '@plexusone/host': packages/host.md + - '@plexusone/invites': packages/invites.md - '@plexusone/pages': packages/pages.md - '@plexusone/shell': packages/shell.md - '@plexusone/telemetry': packages/telemetry.md diff --git a/packages/invites/package.json b/packages/invites/package.json new file mode 100644 index 0000000..421939b --- /dev/null +++ b/packages/invites/package.json @@ -0,0 +1,61 @@ +{ + "name": "@plexusone/invites", + "version": "0.1.0", + "description": "Pages for SystemForge invite-gated sign-up: an operator page for invite codes, email invites, the waitlist and member allowances, and a member page to invite people", + "homepage": "https://plexusone.github.io/systemforge-web/packages/invites/", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@plexusone/ui": "workspace:^" + }, + "devDependencies": { + "@plexusone/ui": "workspace:*", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.0.1", + "typescript": "^5.3.3" + }, + "keywords": [ + "systemforge", + "invites", + "signup", + "waitlist", + "react" + ], + "author": "SystemForge Team", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/plexusone/systemforge-web.git", + "directory": "packages/invites" + }, + "bugs": { + "url": "https://github.com/plexusone/systemforge-web/issues" + } +} diff --git a/packages/invites/src/__tests__/invites.test.tsx b/packages/invites/src/__tests__/invites.test.tsx new file mode 100644 index 0000000..789d8c5 --- /dev/null +++ b/packages/invites/src/__tests__/invites.test.tsx @@ -0,0 +1,364 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createInvitesApi, InviteAdminPage, InvitePeoplePage, splitEmails } from '../index'; +import type { InviteCode, MyInvites } from '../types'; + +interface Call { + method: string; + url: string; + body?: unknown; + credentials?: RequestCredentials; +} + +let calls: Call[]; +let respond: (c: Call) => Response | undefined; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +function code(over: Partial = {}): InviteCode { + return { + id: 'code-1', + kind: 'member', + hint: 'WXYZ', + max_uses: 1, + use_count: 0, + status: 'active', + created_at: '2026-10-01T00:00:00Z', + expires_at: '2026-10-15T00:00:00Z', + ...over, + }; +} + +function mine(over: Partial = {}): MyInvites { + return { + allowance: 3, + held: 1, + remaining: 2, + member_code_ttl_seconds: 14 * 86400, + codes: [code()], + ...over, + }; +} + +beforeEach(() => { + calls = []; + respond = () => undefined; + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const c: Call = { + method: init?.method ?? 'GET', + url: String(input), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + credentials: init?.credentials, + }; + calls.push(c); + const custom = respond(c); + if (custom) return custom; + if (c.url.endsWith('/me/invites')) return json(200, mine()); + if (c.url.endsWith('/settings')) + return json(200, { + default_allowance: 0, + member_code_ttl_seconds: 14 * 86400, + signup_url: '', + is_admin: true, + }); + return json(404, { error: 'not found' }); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const api = createInvitesApi('/invites/api/'); + +describe('createInvitesApi', () => { + it('sends cookies, encodes paths and reports API errors', async () => { + respond = (c) => + c.url.includes('/admin/codes') + ? json(403, { error: 'platform administrators only' }) + : undefined; + await expect(api.codes({ kind: 'operator', activeOnly: true })).rejects.toMatchObject({ + status: 403, + message: 'platform administrators only', + }); + expect(calls[0].url).toBe('/invites/api/admin/codes?kind=operator&status=active'); + expect(calls[0].credentials).toBe('include'); + + respond = (c) => (c.method === 'DELETE' ? new Response(null, { status: 204 }) : undefined); + await api.revokeInvite('a+b@example.com'); + expect(calls[1]).toMatchObject({ + method: 'DELETE', + url: '/invites/api/admin/invites/a%2Bb%40example.com', + }); + + respond = (c) => (c.method === 'PUT' ? json(200, {}) : undefined); + await api.setAllowance('ada@example.com', null); + expect(calls[2].body).toEqual({ email: 'ada@example.com', granted: null, note: '' }); + }); + + it('splits pasted addresses', () => { + expect(splitEmails(' ada@example.com,grace@example.com\n\nlin@example.com; ')).toEqual([ + 'ada@example.com', + 'grace@example.com', + 'lin@example.com', + ]); + }); +}); + +describe('InvitePeoplePage', () => { + it('shows the allowance and creates a link shown once', async () => { + respond = (c) => + c.method === 'POST' && c.url.endsWith('/me/codes') + ? json(201, { + code: code({ id: 'code-2', hint: 'ABCD' }), + invite_code: 'AAAA-BBBB-CCCC-ABCD', + link: 'https://auth.example.com/signup?code=AAAA-BBBB-CCCC-ABCD', + }) + : undefined; + render(); + expect(await screen.findByText(/You can invite 2 more people/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Create invite link' })); + const shown = await screen.findByRole('region', { name: 'New invite' }); + expect(within(shown).getByRole('textbox', { name: 'Invite' })).toHaveValue( + 'https://auth.example.com/signup?code=AAAA-BBBB-CCCC-ABCD' + ); + expect(within(shown).getByText(/will not be shown again/)).toBeInTheDocument(); + }); + + it('keeps a new link on screen when reloading the list fails', async () => { + let loads = 0; + respond = (c) => { + if (c.method === 'GET' && c.url.endsWith('/me/invites')) { + loads++; + return loads > 1 ? json(500, { error: 'database unavailable' }) : undefined; + } + if (c.method === 'POST' && c.url.endsWith('/me/codes')) + return json(201, { + code: code({ id: 'code-2', hint: 'ABCD' }), + invite_code: 'AAAA-BBBB-CCCC-ABCD', + link: 'https://auth.example.com/signup?code=AAAA-BBBB-CCCC-ABCD', + }); + return undefined; + }; + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Create invite link' })); + expect(await screen.findByText(/Could not load your invites/)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Invite' })).toHaveValue( + 'https://auth.example.com/signup?code=AAAA-BBBB-CCCC-ABCD' + ); + }); + + it('cannot create a link with none left, and says so', async () => { + respond = (c) => + c.url.endsWith('/me/invites') + ? json(200, mine({ allowance: 0, held: 0, remaining: 0, codes: [] })) + : undefined; + render(); + expect(await screen.findByText("You don't have invites to give yet.")).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create invite link' })).toBeDisabled(); + }); + + it('asks before revoking an unused link', async () => { + respond = (c) => + c.url.endsWith('/revoke') ? json(200, code({ status: 'revoked' })) : undefined; + render(); + const row = await screen.findByRole('listitem', { name: 'Invite …WXYZ' }); + fireEvent.click(within(row).getByRole('button', { name: 'Revoke' })); + expect(calls.some((c) => c.url.endsWith('/revoke'))).toBe(false); + fireEvent.click(within(row).getByRole('button', { name: 'Revoke …WXYZ' })); + await waitFor(() => + expect( + calls.some((c) => c.method === 'POST' && c.url.endsWith('/me/codes/code-1/revoke')) + ).toBe(true) + ); + }); +}); + +describe('InviteAdminPage', () => { + it('creates an operator code and shows it once', async () => { + respond = (c) => { + if (c.url.includes('/admin/codes') && c.method === 'GET') return json(200, { codes: [] }); + if (c.url.endsWith('/admin/codes') && c.method === 'POST') + return json(201, { + code: code({ kind: 'operator', max_uses: 25, label: 'launch' }), + invite_code: 'AAAA-BBBB-CCCC-DDDD', + }); + return undefined; + }; + render(); + expect(await screen.findByText(/No invite codes yet/)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'launch' } }); + fireEvent.change(screen.getByLabelText('People it admits'), { target: { value: '25' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create invite code' })); + await screen.findByRole('region', { name: 'New invite' }); + const post = calls.find((c) => c.method === 'POST'); + expect(post?.body).toEqual({ label: 'launch', note: '', max_uses: 25, expires_in_days: 14 }); + expect(screen.getByRole('textbox', { name: 'Invite' })).toHaveValue('AAAA-BBBB-CCCC-DDDD'); + }); + + it('does not show a new code as copied', async () => { + const writeText = vi.fn(async () => undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + let made = 0; + respond = (c) => { + if (c.url.includes('/admin/codes') && c.method === 'GET') return json(200, { codes: [] }); + if (c.url.endsWith('/admin/codes') && c.method === 'POST') { + made++; + return json(201, { + code: code({ kind: 'operator' }), + invite_code: `AAAA-BBBB-CCCC-000${made}`, + }); + } + return undefined; + }; + try { + render(); + await screen.findByText(/No invite codes yet/); + const create = screen.getByRole('button', { name: 'Create invite code' }); + fireEvent.click(create); + await screen.findByDisplayValue('AAAA-BBBB-CCCC-0001'); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(await screen.findByText('Copied.')).toBeInTheDocument(); + expect(writeText).toHaveBeenCalledWith('AAAA-BBBB-CCCC-0001'); + + fireEvent.click(create); + await screen.findByDisplayValue('AAAA-BBBB-CCCC-0002'); + expect(screen.queryByText('Copied.')).not.toBeInTheDocument(); + } finally { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('tells a non-administrator they cannot manage invites', async () => { + respond = (c) => + c.url.includes('/admin/') ? json(403, { error: 'platform administrators only' }) : undefined; + render(); + expect( + await screen.findByText('Only platform administrators can manage invites.') + ).toBeInTheDocument(); + }); + + it('invites selected people from the waitlist', async () => { + respond = (c) => { + if (c.url.endsWith('/admin/codes') || c.url.includes('/admin/codes?')) + return json(200, { codes: [] }); + if (c.url.endsWith('/admin/waitlist')) + return json(200, { + waitlist: [ + { + email: 'ada@example.com', + consent_version: '1', + joined_at: '2026-10-01T00:00:00Z', + last_submitted_at: '2026-10-01T00:00:00Z', + }, + { + email: 'grace@example.com', + consent_version: '1', + joined_at: '2026-10-02T00:00:00Z', + last_submitted_at: '2026-10-02T00:00:00Z', + invited_at: '2026-10-03T00:00:00Z', + }, + ], + }); + if (c.url.endsWith('/admin/waitlist/invite')) + return json(200, { + invites: [ + { email: 'ada@example.com', status: 'pending', created_at: '2026-10-04T00:00:00Z' }, + ], + }); + return undefined; + }; + render(); + fireEvent.click(screen.getByRole('tab', { name: 'Waitlist' })); + await screen.findByRole('listitem', { name: 'Waiting ada@example.com' }); + fireEvent.click(screen.getByRole('button', { name: 'Select everyone not yet invited' })); + fireEvent.click(screen.getByRole('button', { name: 'Invite selected (1)' })); + expect(await screen.findByText(/Invited 1\./)).toBeInTheDocument(); + expect(calls.find((c) => c.url.endsWith('/admin/waitlist/invite'))?.body).toEqual({ + emails: ['ada@example.com'], + }); + }); + + it('does not invite an address removed after it was selected', async () => { + const entry = (email: string) => ({ + email, + consent_version: '1', + joined_at: '2026-10-01T00:00:00Z', + last_submitted_at: '2026-10-01T00:00:00Z', + }); + let removed = false; + respond = (c) => { + if (c.url.includes('/admin/codes')) return json(200, { codes: [] }); + if (c.method === 'DELETE') { + removed = true; + return new Response(null, { status: 204 }); + } + if (c.url.endsWith('/admin/waitlist')) + return json(200, { + waitlist: removed + ? [entry('lin@example.com')] + : [entry('ada@example.com'), entry('lin@example.com')], + }); + if (c.url.endsWith('/admin/waitlist/invite')) + return json(200, { + invites: [ + { email: 'lin@example.com', status: 'pending', created_at: '2026-10-04T00:00:00Z' }, + ], + }); + return undefined; + }; + render(); + fireEvent.click(screen.getByRole('tab', { name: 'Waitlist' })); + const ada = await screen.findByRole('listitem', { name: 'Waiting ada@example.com' }); + fireEvent.click(screen.getByRole('button', { name: 'Select everyone not yet invited' })); + expect(screen.getByRole('button', { name: 'Invite selected (2)' })).toBeEnabled(); + fireEvent.click(within(ada).getByRole('button', { name: 'Remove' })); + fireEvent.click(within(ada).getByRole('button', { name: 'Remove ada@example.com' })); + await waitFor(() => + expect(screen.queryByRole('listitem', { name: 'Waiting ada@example.com' })).toBeNull() + ); + fireEvent.click(screen.getByRole('button', { name: 'Invite selected (1)' })); + await screen.findByText(/Invited 1\./); + expect(calls.find((c) => c.url.endsWith('/admin/waitlist/invite'))?.body).toEqual({ + emails: ['lin@example.com'], + }); + }); + + it('sets a member allowance by email', async () => { + respond = (c) => { + if (c.url.includes('/admin/codes')) return json(200, { codes: [] }); + if (c.url.endsWith('/admin/allowances') && c.method === 'GET') + return json(200, { allowances: [] }); + if (c.method === 'PUT') + return json(200, { + principal_id: 'p1', + email: 'ada@example.com', + granted: 5, + effective: 5, + held: 0, + updated_at: '2026-10-04T00:00:00Z', + }); + return undefined; + }; + render(); + fireEvent.click(screen.getByRole('tab', { name: 'Member allowances' })); + expect( + await screen.findByText(/Members without an allowance of their own may invite 0 people/) + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Member's email"), { + target: { value: 'ada@example.com' }, + }); + fireEvent.change(screen.getByLabelText('People they may invite (empty: default)'), { + target: { value: '5' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Set allowance' })); + await waitFor(() => + expect(calls.find((c) => c.method === 'PUT')?.body).toEqual({ + email: 'ada@example.com', + granted: 5, + note: '', + }) + ); + }); +}); diff --git a/packages/invites/src/api.ts b/packages/invites/src/api.ts new file mode 100644 index 0000000..b772922 --- /dev/null +++ b/packages/invites/src/api.ts @@ -0,0 +1,139 @@ +import type { + Allowance, + CodeKind, + CodeOptions, + CreatedCode, + EmailInvite, + InviteCode, + InviteOptions, + InviteSettings, + JoinedAccount, + MyInvites, + WaitlistEntry, +} from './types'; + +/** An error from the invite API, with its HTTP status. */ +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +/** The invite API. Every call runs as the signed-in person. */ +export interface InvitesApi { + settings(): Promise; + + /** The caller's allowance and codes. */ + myInvites(): Promise; + /** Creates a single-use code for the caller; fails with 409 when none are left. */ + createMyCode(): Promise; + revokeMyCode(id: string): Promise; + + // Platform administrators only. + invites(all?: boolean): Promise; + addInvites( + emails: string[], + options?: InviteOptions + ): Promise<{ invites: EmailInvite[]; failed: { email: string; error: string }[] }>; + revokeInvite(email: string): Promise; + codes(filter?: { kind?: CodeKind; label?: string; activeOnly?: boolean }): Promise; + createCode(options: CodeOptions): Promise; + revokeCode(id: string): Promise; + waitlist(): Promise; + inviteFromWaitlist( + emails: string[], + options?: InviteOptions + ): Promise<{ invites: EmailInvite[]; error?: string }>; + removeFromWaitlist(email: string): Promise; + allowances(): Promise; + /** Sets a member's allowance by email; null returns them to the default. */ + setAllowance(email: string, granted: number | null, note?: string): Promise; + joined(filter?: { codeId?: string; label?: string; inviter?: string }): Promise; +} + +/** query encodes the set parameters as a query string, with its leading "?". */ +function query(params: Record): string { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) if (v) q.set(k, v); + const s = q.toString(); + return s ? `?${s}` : ''; +} + +/** + * createInvitesApi returns a client for the invite API served at baseUrl, + * such as '/invites/api' behind the host's own session or + * 'https://auth.example.com/signup-invites/api' on SystemAuth. Requests + * carry the browser's cookies. + */ +export function createInvitesApi(baseUrl: string): InvitesApi { + const base = baseUrl.replace(/\/+$/, ''); + + async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, { + ...init, + credentials: 'include', + headers: { + Accept: 'application/json', + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + }, + }); + if (!res.ok) { + let message = res.statusText || `request failed with status ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + // Not JSON; the status line is the message. + } + throw new ApiError(res.status, message); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + const post = (path: string, body?: unknown) => + request(path, { + method: 'POST', + body: body === undefined ? undefined : JSON.stringify(body), + }); + const enc = encodeURIComponent; + + return { + settings: () => request('/settings'), + myInvites: () => request('/me/invites'), + createMyCode: () => post('/me/codes'), + revokeMyCode: (id) => post(`/me/codes/${enc(id)}/revoke`), + + invites: (all) => + request<{ invites: EmailInvite[] }>(`/admin/invites${all ? '?status=all' : ''}`).then( + (r) => r.invites + ), + addInvites: (emails, options) => post('/admin/invites', { emails, ...options }), + revokeInvite: (email) => request(`/admin/invites/${enc(email)}`, { method: 'DELETE' }), + codes: (f) => + request<{ codes: InviteCode[] }>( + `/admin/codes${query({ kind: f?.kind, label: f?.label, status: f?.activeOnly ? 'active' : undefined })}` + ).then((r) => r.codes), + createCode: (options) => post('/admin/codes', options), + revokeCode: (id) => post(`/admin/codes/${enc(id)}/revoke`), + waitlist: () => + request<{ waitlist: WaitlistEntry[] }>('/admin/waitlist').then((r) => r.waitlist), + inviteFromWaitlist: (emails, options) => post('/admin/waitlist/invite', { emails, ...options }), + removeFromWaitlist: (email) => + request(`/admin/waitlist/${enc(email)}`, { method: 'DELETE' }), + allowances: () => + request<{ allowances: Allowance[] }>('/admin/allowances').then((r) => r.allowances), + setAllowance: (email, granted, note) => + request('/admin/allowances', { + method: 'PUT', + body: JSON.stringify({ email, granted, note: note ?? '' }), + }), + joined: (f) => + request<{ joined: JoinedAccount[] }>( + `/admin/joined${query({ code_id: f?.codeId, label: f?.label, inviter: f?.inviter })}` + ).then((r) => r.joined), + }; +} diff --git a/packages/invites/src/components/ui.tsx b/packages/invites/src/components/ui.tsx new file mode 100644 index 0000000..b0b02bb --- /dev/null +++ b/packages/invites/src/components/ui.tsx @@ -0,0 +1,187 @@ +import { useState, type ReactNode } from 'react'; +import { Button, Input } from '@plexusone/ui'; +import type { CodeStatus, CreatedCode, InviteStatus } from '../types'; + +/** PageHeader shows a page's title, an optional subtitle, and its actions. */ +export function PageHeader({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: ReactNode; + children?: ReactNode; +}): ReactNode { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children &&
{children}
} +
+ ); +} + +/** ErrorNote shows an error as an alert. */ +export function ErrorNote({ children }: { children: ReactNode }): ReactNode { + return ( +

+ {children} +

+ ); +} + +/** Note shows the result of an action. */ +export function Note({ children }: { children: ReactNode }): ReactNode { + return ( +

+ {children} +

+ ); +} + +/** Loading says what is being loaded. */ +export function Loading({ what }: { what: string }): ReactNode { + return

Loading {what}…

; +} + +/** Empty shows a message in place of a list that has no entries. */ +export function Empty({ children }: { children: ReactNode }): ReactNode { + return ( +

+ {children} +

+ ); +} + +const LABEL: Record = { + active: 'Active', + used_up: 'Used', + expired: 'Expired', + revoked: 'Revoked', + pending: 'Pending', + redeemed: 'Joined', +}; + +const TONE: Record = { + active: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400', + pending: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400', + used_up: 'bg-muted text-muted-foreground', + redeemed: 'bg-muted text-muted-foreground', + expired: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + revoked: 'bg-destructive/15 text-destructive', +}; + +/** StatusBadge shows a code's or an invite's status. */ +export function StatusBadge({ status }: { status: CodeStatus | InviteStatus }): ReactNode { + return ( + + {LABEL[status]} + + ); +} + +/** A date as people read it, or a dash. */ +export function formatDate(iso?: string): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +/** A code's short name: its last four characters. */ +export function codeName(hint: string): string { + return `…${hint}`; +} + +/** + * ShowOnce shows a new code and its link with a copy button. The API returns + * them only once, so the person is told to copy them now. + */ +export function ShowOnce({ + created, + onDone, +}: { + created: CreatedCode; + onDone: () => void; +}): ReactNode { + const value = created.link ?? created.invite_code; + // The copy result belongs to the value copied, so a new code that replaces + // this one does not show "Copied." before it is copied. + const [copiedResult, setCopiedResult] = useState<{ value: string; ok: boolean }>(); + const copied = copiedResult?.value === value ? copiedResult.ok : undefined; + async function copy() { + const copying = value; + try { + await navigator.clipboard.writeText(copying); + setCopiedResult({ value: copying, ok: true }); + } catch { + setCopiedResult({ value: copying, ok: false }); + } + } + return ( +
+

+ Copy this {created.link ? 'link' : 'code'} now. It will not be shown again. +

+
+ e.currentTarget.select()} + /> + +
+ {created.link &&

Code: {created.invite_code}

} + {copied === true &&

Copied.

} + {copied === false && ( +

+ Copying is not available here; select it and copy. +

+ )} + +
+ ); +} + +/** + * useRun runs an action, tracking busy and error state, and calls after on + * success. + */ +export function useRun(): { + busy: boolean; + error?: string; + run: (what: string, fn: () => Promise) => Promise; + clear: () => void; +} { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + async function run(what: string, fn: () => Promise) { + setBusy(true); + setError(undefined); + try { + await fn(); + } catch (err) { + setError(`${what}: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + return { busy, error, run, clear: () => setError(undefined) }; +} diff --git a/packages/invites/src/index.ts b/packages/invites/src/index.ts new file mode 100644 index 0000000..52a30b0 --- /dev/null +++ b/packages/invites/src/index.ts @@ -0,0 +1,23 @@ +// @plexusone/invites — pages for SystemForge invite-gated sign-up. + +export { createInvitesApi, ApiError } from './api'; +export type { InvitesApi } from './api'; +export { InviteAdminPage, splitEmails } from './pages/InviteAdminPage'; +export type { InviteAdminPageProps } from './pages/InviteAdminPage'; +export { InvitePeoplePage } from './pages/InvitePeoplePage'; +export type { InvitePeoplePageProps } from './pages/InvitePeoplePage'; +export type { + Allowance, + CodeKind, + CodeOptions, + CodeStatus, + CreatedCode, + EmailInvite, + InviteCode, + InviteOptions, + InviteSettings, + InviteStatus, + JoinedAccount, + MyInvites, + WaitlistEntry, +} from './types'; diff --git a/packages/invites/src/pages/InviteAdminPage.tsx b/packages/invites/src/pages/InviteAdminPage.tsx new file mode 100644 index 0000000..00270ee --- /dev/null +++ b/packages/invites/src/pages/InviteAdminPage.tsx @@ -0,0 +1,709 @@ +import { useState, type FormEvent, type ReactNode } from 'react'; +import { Button, Input, Label } from '@plexusone/ui'; +import { ApiError, type InvitesApi } from '../api'; +import { + Empty, + ErrorNote, + Loading, + Note, + PageHeader, + ShowOnce, + StatusBadge, + codeName, + formatDate, + useRun, +} from '../components/ui'; +import type { CreatedCode, EmailInvite, InviteCode, WaitlistEntry } from '../types'; +import { useLoad, type Load } from '../useLoad'; + +export interface InviteAdminPageProps { + /** The invite API, from createInvitesApi. */ + api: InvitesApi; + /** The page heading. Default: "Invites". */ + title?: string; +} + +const SECTIONS = [ + { id: 'codes', label: 'Invite codes' }, + { id: 'invites', label: 'Email invites' }, + { id: 'waitlist', label: 'Waitlist' }, + { id: 'allowances', label: 'Member allowances' }, + { id: 'joined', label: 'Joined' }, +] as const; + +type Section = (typeof SECTIONS)[number]['id']; + +/** + * InviteAdminPage is the operator page for invite-gated sign-up: invite + * codes, email invites, the waitlist, member allowances, and the accounts + * invites admitted. It is for platform administrators; others get the + * API's refusal. + */ +export function InviteAdminPage({ api, title = 'Invites' }: InviteAdminPageProps): ReactNode { + const [section, setSection] = useState
('codes'); + return ( + <> + +
+ {SECTIONS.map((s) => ( + + ))} +
+
+ {section === 'codes' && } + {section === 'invites' && } + {section === 'waitlist' && } + {section === 'allowances' && } + {section === 'joined' && } +
+ + ); +} + +/** loadState renders a load's error or loading state, or nothing once loaded. */ +function loadState(load: Load, what: string): ReactNode { + if (load.error) { + if (load.error instanceof ApiError && load.error.status === 403) { + return Only platform administrators can manage invites.; + } + return ( + + Could not load {what}: {load.error.message} + + ); + } + if (load.data === undefined) return ; + return null; +} + +/** splitEmails reads addresses separated by commas, spaces or new lines. */ +export function splitEmails(text: string): string[] { + return text + .split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** days parses a number of days from a form field, or returns 0. */ +function days(value: string): number { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** Field labels a form control. */ +function Field({ + id, + label, + children, +}: { + id: string; + label: string; + children: ReactNode; +}): ReactNode { + return ( +
+ + {children} +
+ ); +} + +/** ConfirmRow asks before a destructive action, with confirm and cancel buttons. */ +function ConfirmRow({ + prompt, + action, + busy, + onConfirm, + onCancel, +}: { + prompt: string; + action: string; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}): ReactNode { + return ( +
+ {prompt} + + +
+ ); +} + +// --- Invite codes --- + +/** CodesSection creates operator invite codes and lists every code. */ +function CodesSection({ api }: { api: InvitesApi }): ReactNode { + const codes = useLoad(() => api.codes({ kind: 'operator' }), [api]); + const [created, setCreated] = useState(); + const [label, setLabel] = useState(''); + const [maxUses, setMaxUses] = useState('10'); + const [expires, setExpires] = useState('14'); + const [note, setNote] = useState(''); + const action = useRun(); + + function submit(e: FormEvent) { + e.preventDefault(); + void action.run('Create code', async () => { + setCreated( + await api.createCode({ + label: label.trim(), + note: note.trim(), + max_uses: Math.max(1, Number.parseInt(maxUses, 10) || 1), + expires_in_days: days(expires), + }) + ); + setLabel(''); + setNote(''); + codes.reload(); + }); + } + + return ( + <> +
+ + setLabel(e.target.value)} + /> + + + setMaxUses(e.target.value)} + /> + + + setExpires(e.target.value)} + /> + + + setNote(e.target.value)} + /> + +
+ +
+
+ {action.error && {action.error}} + {created && setCreated(undefined)} />} + {loadState(codes, 'invite codes') ?? + (codes.data!.length === 0 ? ( + No invite codes yet. Create one above and share its link. + ) : ( +
    + {codes.data!.map((c) => ( + + ))} +
+ ))} + + ); +} + +/** CodeRow shows one invite code's use and status, with revoke. */ +function CodeRow({ + code: c, + api, + onChange, +}: { + code: InviteCode; + api: InvitesApi; + onChange: () => void; +}): ReactNode { + const [confirm, setConfirm] = useState(false); + const action = useRun(); + const name = c.label ? `${c.label} ${codeName(c.hint)}` : codeName(c.hint); + return ( +
  • +
    +
    + {c.label && {c.label}} + {codeName(c.hint)} + +
    +
    + {c.use_count} of {c.max_uses} used · created {formatDate(c.created_at)} + {c.expires_at && ` · expires ${formatDate(c.expires_at)}`} +
    +
    + {c.note &&

    {c.note}

    } + {c.status === 'active' && !confirm && ( + + )} + {confirm && ( + setConfirm(false)} + onConfirm={() => + void action.run('Revoke', async () => { + await api.revokeCode(c.id); + setConfirm(false); + onChange(); + }) + } + /> + )} + {action.error && {action.error}} +
  • + ); +} + +// --- Email invites --- + +/** InvitesSection invites people by email address and lists the invites. */ +function InvitesSection({ api }: { api: InvitesApi }): ReactNode { + const invites = useLoad(() => api.invites(true), [api]); + const [emails, setEmails] = useState(''); + const [note, setNote] = useState(''); + const [expires, setExpires] = useState('0'); + const [result, setResult] = useState(); + const action = useRun(); + + function submit(e: FormEvent) { + e.preventDefault(); + const list = splitEmails(emails); + if (list.length === 0) return; + void action.run('Invite', async () => { + const r = await api.addInvites(list, { note: note.trim(), expires_in_days: days(expires) }); + const failed = r.failed.map((f) => `${f.email} (${f.error})`).join(', '); + setResult( + `Invited ${r.invites.length} ${r.invites.length === 1 ? 'address' : 'addresses'}.` + + (failed ? ` Not invited: ${failed}.` : '') + ); + setEmails(''); + invites.reload(); + }); + } + + return ( + <> +
    + +