Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ import {
} from './auth';
import {
acceptInvitation,
acceptInvitationForUser,
inviteMember,
isInvitableRole,
listInvitations,
listInvitationsForUser,
listMembers,
previewInvitation,
removeMember,
Expand Down Expand Up @@ -1604,6 +1606,7 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
invitationEmail(minted.email, link, {
organization: organization?.name ?? 'their team',
invitedBy: inviter?.name?.trim() || inviter?.email || 'A teammate',
...(inviter?.email ? { replyTo: inviter.email } : {}),
}),
);
} catch (error) {
Expand Down Expand Up @@ -1734,6 +1737,53 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
});
});

/**
* 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
Expand Down
75 changes: 75 additions & 0 deletions apps/api/src/team.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
112 changes: 104 additions & 8 deletions apps/api/src/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,23 +297,119 @@ export async function acceptInvitation(
token: string,
userId: string,
): Promise<AcceptedInvitation> {
const hash = await hashToken(token);
const row = await queryOne<PendingRow>(
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<InvitationForMe[]> {
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<AcceptedInvitation> {
const row = await queryOne<PendingRow>(
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<AcceptedInvitation> {
if (row.revoked_at) throw new TeamError('that invitation was withdrawn');
if (row.expires_at <= now()) throw new TeamError('that invitation has expired');

Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -24,7 +25,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
</div>

{/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */}
<main className="mx-auto w-full max-w-2xl px-4 pb-28">{children}</main>
<main className="mx-auto w-full max-w-2xl px-4 pb-28">
<PendingInvitations />
{children}
</main>
<BottomNav />
</>
);
Expand Down
Loading
Loading