diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 124700f..d42533f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -61,9 +61,11 @@ import { } from './auth'; import { acceptInvitation, + acceptInvitationForUser, inviteMember, isInvitableRole, listInvitations, + listInvitationsForUser, listMembers, previewInvitation, removeMember, @@ -1604,6 +1606,7 @@ export function createApp(options: AppOptions): Hono { invitationEmail(minted.email, link, { organization: organization?.name ?? 'their team', invitedBy: inviter?.name?.trim() || inviter?.email || 'A teammate', + ...(inviter?.email ? { replyTo: inviter.email } : {}), }), ); } catch (error) { @@ -1734,6 +1737,53 @@ export function createApp(options: AppOptions): Hono { }); }); + /** + * Invitations addressed to the signed-in user, so joining does not depend on + * the email arriving. Matched on the account's verified address. + */ + api.get('/me/invitations', async (c) => { + const actor = c.get('actor'); + return c.json({ invitations: await listInvitationsForUser(c.get('db'), actor.userId) }); + }); + + api.post('/me/invitations/:id/accept', async (c) => { + const actor = c.get('actor'); + const db = c.get('db'); + + let accepted; + try { + accepted = await acceptInvitationForUser(db, c.req.param('id'), actor.userId); + } catch (error) { + if (error instanceof TeamError) throw ApiError.badRequest(error.message); + throw error; + } + + const cookie = readCookie(c.req.header('cookie') ?? null, SESSION_COOKIE); + if (cookie && accepted.workspaceId) { + await switchSessionWorkspace(db, cookie, actor.userId, accepted.workspaceId); + } + + await repo.audit(db, { + actorKind: 'user', + actorId: actor.userId, + eventType: 'team.invitation_accepted', + entityKind: 'user', + entityId: actor.userId, + detail: { + organizationId: accepted.organizationId, + role: accepted.role, + via: 'in_app', + }, + }); + + return c.json({ + joined: true, + organizationId: accepted.organizationId, + workspaceId: accepted.workspaceId, + role: accepted.role, + }); + }); + // ----------------------------------------------------------- workspaces // // `organizations → workspaces` has been in the schema since 0000, described diff --git a/apps/api/src/team.test.ts b/apps/api/src/team.test.ts index 9ebab34..6568b4d 100644 --- a/apps/api/src/team.test.ts +++ b/apps/api/src/team.test.ts @@ -3,8 +3,10 @@ import { now, queryAll, queryOne } from '@outreachgraph/db'; import { seedDatabase, SEED, type SeededDatabase } from './test-seed'; import { acceptInvitation, + acceptInvitationForUser, inviteMember, listInvitations, + listInvitationsForUser, listMembers, previewInvitation, removeMember, @@ -169,6 +171,79 @@ describe('acceptInvitation', () => { }); }); +describe('invitations for the signed-in user', () => { + test('an existing account sees and accepts its invitation without the link', async () => { + seeded = await seedDatabase('team-mine'); + const { db } = seeded; + await addUser(db, 'usr_sam', 'sam@example.com'); + + const minted = await inviteMember(db, { ...INVITER, email: 'Sam@Example.com', role: 'admin' }); + + const mine = await listInvitationsForUser(db, 'usr_sam'); + expect(mine.map((i) => i.id)).toEqual([minted.id]); + expect(mine[0]?.role).toBe('admin'); + + const accepted = await acceptInvitationForUser(db, minted.id, 'usr_sam'); + expect(accepted.workspaceId).toBe(SEED.workspaceId); + expect(await listMembers(db, SEED.organizationId)).toHaveLength(2); + + // Answered, and already a member: nothing left to offer. + expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0); + }); + + test('an unverified address neither sees nor accepts it', async () => { + seeded = await seedDatabase('team-mine-unverified'); + const { db } = seeded; + await addUser(db, 'usr_sam', 'sam@example.com'); + await db.execute({ + sql: 'UPDATE users SET email_verified_at = NULL WHERE id = ?', + args: ['usr_sam'], + }); + + const minted = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' }); + + expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0); + await expect(acceptInvitationForUser(db, minted.id, 'usr_sam')).rejects.toBeInstanceOf( + TeamError, + ); + expect(await listMembers(db, SEED.organizationId)).toHaveLength(1); + }); + + test("someone else's invitation is refused", async () => { + seeded = await seedDatabase('team-mine-other'); + const { db } = seeded; + await addUser(db, 'usr_sam', 'sam@example.com'); + await addUser(db, 'usr_eve', 'eve@example.com'); + + const minted = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' }); + + expect(await listInvitationsForUser(db, 'usr_eve')).toHaveLength(0); + await expect(acceptInvitationForUser(db, minted.id, 'usr_eve')).rejects.toBeInstanceOf( + TeamError, + ); + }); + + test('withdrawn and expired invitations are not offered', async () => { + seeded = await seedDatabase('team-mine-stale'); + const { db } = seeded; + await addUser(db, 'usr_sam', 'sam@example.com'); + + const minted = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' }); + await db.execute({ + sql: 'UPDATE invitations SET expires_at = ? WHERE id = ?', + args: ['2020-01-01T00:00:00.000Z', minted.id], + }); + expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0); + await expect(acceptInvitationForUser(db, minted.id, 'usr_sam')).rejects.toBeInstanceOf( + TeamError, + ); + + const again = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' }); + await revokeInvitation(db, SEED.organizationId, again.id); + expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0); + }); +}); + describe('removeMember', () => { test('removes a teammate', async () => { seeded = await seedDatabase('team-remove'); diff --git a/apps/api/src/team.ts b/apps/api/src/team.ts index 9e402fb..7ee627a 100644 --- a/apps/api/src/team.ts +++ b/apps/api/src/team.ts @@ -297,23 +297,119 @@ export async function acceptInvitation( token: string, userId: string, ): Promise { - const hash = await hashToken(token); + const row = await queryOne( + db, + `SELECT id, organization_id, role, expires_at, accepted_at, revoked_at + FROM invitations WHERE token_hash = ?`, + [await hashToken(token)], + ); - const row = await queryOne<{ + if (!row) throw new TeamError('that invitation link is not valid'); + return consumeInvitation(db, row, userId); +} + +export interface InvitationForMe { + readonly id: string; + readonly organizationId: string; + readonly organizationName: string; + readonly role: string; + readonly invitedBy: string | null; + readonly expiresAt: string; +} + +/** + * Invitations waiting for the signed-in user, matched on their address. + * + * The email was the only way an invitation reached anybody, and one that lands + * in spam — or in a forwarding alias nobody reads — left an invitee who already + * has an account with no way to know they were asked. Showing it in the app + * closes that gap without involving the link at all. + * + * Only a **verified** address counts. Without that, registering someone else's + * address would be enough to walk into the team they were invited to. + * Organizations the user already belongs to are left out: accepting would be a + * no-op that looks like a bug. + */ +export async function listInvitationsForUser( + db: Client, + userId: string, +): Promise { + const rows = await queryAll<{ id: string; organization_id: string; + organization_name: string; role: string; + invited_by_name: string | null; + invited_by_email: string | null; expires_at: string; - accepted_at: string | null; - revoked_at: string | null; }>( db, - `SELECT id, organization_id, role, expires_at, accepted_at, revoked_at - FROM invitations WHERE token_hash = ?`, - [hash], + `SELECT i.id, i.organization_id, o.name AS organization_name, i.role, + inviter.name AS invited_by_name, inviter.email AS invited_by_email, i.expires_at + FROM users me + JOIN invitations i ON lower(i.email) = lower(me.email) + JOIN organizations o ON o.id = i.organization_id + LEFT JOIN users inviter ON inviter.id = i.invited_by + WHERE me.id = ? AND me.email_verified_at IS NOT NULL + AND i.accepted_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > ? + AND NOT EXISTS ( + SELECT 1 FROM organization_members m + WHERE m.organization_id = i.organization_id AND m.user_id = me.id + ) + ORDER BY i.created_at DESC`, + [userId, now()], ); - if (!row) throw new TeamError('that invitation link is not valid'); + return rows.map((row) => ({ + id: row.id, + organizationId: row.organization_id, + organizationName: row.organization_name, + role: row.role, + invitedBy: row.invited_by_name?.trim() || row.invited_by_email, + expiresAt: row.expires_at, + })); +} + +/** + * Accepts an invitation by id, for the user it was addressed to. + * + * The in-app counterpart of {@link acceptInvitation}. There is no token, so the + * verified address is what authorizes it — the opposite trade from the link, + * which deliberately does not check it. An invitation that is not yours reads + * as not valid rather than forbidden, so ids cannot be probed. + */ +export async function acceptInvitationForUser( + db: Client, + invitationId: string, + userId: string, +): Promise { + const row = await queryOne( + db, + `SELECT i.id, i.organization_id, i.role, i.expires_at, i.accepted_at, i.revoked_at + FROM invitations i + JOIN users me ON lower(me.email) = lower(i.email) + WHERE i.id = ? AND me.id = ? AND me.email_verified_at IS NOT NULL`, + [invitationId, userId], + ); + + if (!row) throw new TeamError('that invitation is not valid'); + return consumeInvitation(db, row, userId); +} + +interface PendingRow { + id: string; + organization_id: string; + role: string; + expires_at: string; + accepted_at: string | null; + revoked_at: string | null; +} + +async function consumeInvitation( + db: Client, + row: PendingRow, + userId: string, +): Promise { if (row.revoked_at) throw new TeamError('that invitation was withdrawn'); if (row.expires_at <= now()) throw new TeamError('that invitation has expired'); diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index ab6e523..ef05458 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { BottomNav } from '../../components/bottom-nav'; import { BrandLockup } from '../../components/brand'; +import { PendingInvitations } from '../../components/pending-invitations'; /** * The signed-in app shell: a narrow reading column and the bottom nav. @@ -24,7 +25,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { {/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */} -
{children}
+
+ + {children} +
); diff --git a/apps/web/components/pending-invitations.tsx b/apps/web/components/pending-invitations.tsx new file mode 100644 index 0000000..18e2456 --- /dev/null +++ b/apps/web/components/pending-invitations.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +interface InvitationForMe { + id: string; + organizationName: string; + role: string; + invitedBy: string | null; +} + +/** + * Team invitations addressed to the signed-in user. + * + * The emailed link used to be the only way in, so an invitation that landed in + * spam — or in an alias nobody reads — left someone who already had an account + * with no way to know they had been asked. The API matches these on the + * account's verified address, which is why accepting here needs no token. + * + * Renders nothing until there is something to show: a failed fetch is not + * worth a banner of its own on every page. + */ +export function PendingInvitations() { + const router = useRouter(); + const [invitations, setInvitations] = useState([]); + const [busy, setBusy] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + let live = true; + + fetch('/api/v1/me/invitations', { credentials: 'same-origin' }) + .then((response) => (response.ok ? response.json() : { invitations: [] })) + .then((payload: { invitations?: InvitationForMe[] }) => { + if (live) setInvitations(payload.invitations ?? []); + }) + .catch(() => undefined); + + return () => { + live = false; + }; + }, []); + + async function join(id: string): Promise { + setBusy(id); + setError(undefined); + + try { + const response = await fetch(`/api/v1/me/invitations/${encodeURIComponent(id)}/accept`, { + method: 'POST', + credentials: 'same-origin', + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + setError(payload?.error?.message ?? `that failed (${response.status})`); + setBusy(undefined); + return; + } + + // Same as the emailed link: the API re-pins the session onto the + // workspace just joined, and a refresh shows it. + setInvitations((current) => current.filter((invitation) => invitation.id !== id)); + setBusy(undefined); + router.replace('/inbox'); + router.refresh(); + } catch { + setError('could not reach the server'); + setBusy(undefined); + } + } + + if (invitations.length === 0) return null; + + return ( +
+ {invitations.map((invitation) => ( +
+
+
+ {invitation.invitedBy ?? 'A teammate'} invited you to their team +
+
+ {invitation.organizationName} · {invitation.role} +
+
+ + +
+ ))} + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/packages/email/src/templates.ts b/packages/email/src/templates.ts index f33fe5d..140d3bc 100644 --- a/packages/email/src/templates.ts +++ b/packages/email/src/templates.ts @@ -118,15 +118,25 @@ export function passwordResetEmail(to: string, link: string): Message { export function invitationEmail( to: string, link: string, - context: { organization: string; invitedBy: string }, + context: { organization: string; invitedBy: string; replyTo?: string }, ): Message { - const from = `${context.invitedBy} invited you to join ${context.organization} on OutreachGraph.`; + // Registration names an organization " workspace", and a sentence + // that repeats an address twice — "a@x.com invited you to join a@x.com + // workspace" — reads like phishing to people and filters alike. An + // organization nobody has named yet is just "their team". + const organization = context.organization.includes('@') ? 'their team' : context.organization; + const from = `${context.invitedBy} invited you to join ${organization} on OutreachGraph.`; + const existing = + 'Already have an OutreachGraph account with this address? Sign in and the ' + + 'invitation is waiting at the top of your inbox.'; const text = [ from, '', link, '', + existing, + '', 'The link expires in 14 days. If you were not expecting this you can ignore', 'the message — nothing is shared with you until you open it, and nothing is', 'sent on your behalf.', @@ -141,7 +151,9 @@ export function invitationEmail( 'style="width:48px;height:48px;border:0" />

' : '', `

${escapeHtml(from)}

`, - `

${safe}

`, + `

Accept the invitation

`, + `

Or paste this link into your browser:
${safe}

`, + `

${escapeHtml(existing)}

`, '

The link expires in 14 days. If you were not expecting this you can ignore ', 'the message — nothing is shared with you until you open it, and nothing is ', 'sent on your behalf.

', @@ -149,8 +161,11 @@ export function invitationEmail( return { to, - subject: `Join ${context.organization} on OutreachGraph`, + subject: `${context.invitedBy} invited you to OutreachGraph`, text, html, + // A reply goes to the person who asked, not to noreply@ — the first thing + // an unsure recipient does is ask "is this really you?". + ...(context.replyTo ? { replyTo: context.replyTo } : {}), }; }