Skip to content

Commit ff2cdb0

Browse files
authored
Proposal Invite Accept Flow (#590)
Adds a landing screen for invite links to accept an invitation. The link autoaccepts if the invite is valid and redirects to the proposal page. We use a common /invite link so that users can communicate a single invite link (a la Google) and we lookup a user's invite in the DB.
1 parent a1eed10 commit ff2cdb0

15 files changed

Lines changed: 201 additions & 169 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createClient } from '@op/api/serverClient';
2+
import { OPURLConfig } from '@op/core';
3+
import { NextResponse } from 'next/server';
4+
5+
export const GET = async (
6+
_request: Request,
7+
{
8+
params,
9+
}: {
10+
params: Promise<{ locale: string; slug: string; profileId: string }>;
11+
},
12+
) => {
13+
const { slug, profileId } = await params;
14+
const proposalUrl = `${OPURLConfig('APP').ENV_URL}/decisions/${slug}/proposal/${profileId}/edit`;
15+
16+
try {
17+
const client = await createClient();
18+
await client.decision.acceptProposalInvite({ profileId });
19+
} catch {
20+
// Redirect to the proposal page and let the natural access error occur
21+
}
22+
23+
return NextResponse.redirect(proposalUrl);
24+
};

apps/app/src/components/decisions/ShareProposalModal.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,18 @@ function ShareProposalModalContent({
267267

268268
const handleCopyLink = async () => {
269269
try {
270-
await navigator.clipboard.writeText(window.location.href);
270+
// Build the invite link from the current URL up to /proposal/{profileId}
271+
const path = window.location.pathname;
272+
const proposalIndex = path.indexOf(`/proposal/${proposalProfileId}`);
273+
const basePath =
274+
proposalIndex !== -1
275+
? path.slice(
276+
0,
277+
proposalIndex + `/proposal/${proposalProfileId}`.length,
278+
)
279+
: path;
280+
const inviteUrl = `${window.location.origin}${basePath}/invite`;
281+
await navigator.clipboard.writeText(inviteUrl);
271282
toast.success({ message: t('Link copied to clipboard') });
272283
} catch {
273284
toast.error({ message: t('Failed to copy link') });

apps/app/src/lib/i18n/dictionaries/bn.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,5 +660,6 @@
660660
"Options cannot be empty": "বিকল্পগুলি খালি হতে পারে না",
661661
"Minimum must be less than or equal to maximum": "সর্বনিম্ন অবশ্যই সর্বোচ্চের সমান বা কম হতে হবে",
662662
"These are the categories you defined in": "এগুলি আপনার সংজ্ঞায়িত বিভাগগুলি",
663-
"Set maximum budget": "সর্বোচ্চ বাজেট নির্ধারণ করুন"
663+
"Set maximum budget": "সর্বোচ্চ বাজেট নির্ধারণ করুন",
664+
"This invite is no longer valid": "এই আমন্ত্রণটি আর বৈধ নয়"
664665
}

apps/app/src/lib/i18n/dictionaries/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,5 +653,6 @@
653653
"Options cannot be empty": "Options cannot be empty",
654654
"Minimum must be less than or equal to maximum": "Minimum must be less than or equal to maximum",
655655
"These are the categories you defined in": "These are the categories you defined in",
656-
"Set maximum budget": "Set maximum budget"
656+
"Set maximum budget": "Set maximum budget",
657+
"This invite is no longer valid": "This invite is no longer valid"
657658
}

apps/app/src/lib/i18n/dictionaries/es.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,5 +652,6 @@
652652
"Options cannot be empty": "Las opciones no pueden estar vacías",
653653
"Minimum must be less than or equal to maximum": "El mínimo debe ser menor o igual al máximo",
654654
"These are the categories you defined in": "Estas son las categorías que definiste en",
655-
"Set maximum budget": "Establecer presupuesto máximo"
655+
"Set maximum budget": "Establecer presupuesto máximo",
656+
"This invite is no longer valid": "Esta invitación ya no es válida"
656657
}

apps/app/src/lib/i18n/dictionaries/fr.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,5 +652,6 @@
652652
"Options cannot be empty": "Les options ne peuvent pas être vides",
653653
"Minimum must be less than or equal to maximum": "Le minimum doit être inférieur ou égal au maximum",
654654
"These are the categories you defined in": "Ce sont les catégories que vous avez définies dans",
655-
"Set maximum budget": "Définir le budget maximum"
655+
"Set maximum budget": "Définir le budget maximum",
656+
"This invite is no longer valid": "Cette invitation n'est plus valide"
656657
}

apps/app/src/lib/i18n/dictionaries/pt.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,5 +648,6 @@
648648
"Options cannot be empty": "As opções não podem estar vazias",
649649
"Minimum must be less than or equal to maximum": "O mínimo deve ser menor ou igual ao máximo",
650650
"These are the categories you defined in": "Estas são as categorias que você definiu em",
651-
"Set maximum budget": "Definir orçamento máximo"
651+
"Set maximum budget": "Definir orçamento máximo",
652+
"This invite is no longer valid": "Este convite não é mais válido"
652653
}

packages/common/src/services/access/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Profile, ProfileUser } from '@op/db/schema';
44
import { organizations, users } from '@op/db/schema';
55
import type { User } from '@op/supabase/lib';
66
import type { AccessZonePermission, NormalizedRole } from 'access-zones';
7-
import { assertAccess, checkPermission } from 'access-zones';
7+
import { checkPermission } from 'access-zones';
88
import { z } from 'zod';
99

1010
import { UnauthorizedError } from '../../utils/error';
@@ -198,7 +198,9 @@ export const assertInstanceProfileAccess = async ({
198198
organizationId: org[0].id,
199199
});
200200

201-
assertAccess(orgFallbackPermissions, orgUser?.roles ?? []);
201+
if (!checkPermission(orgFallbackPermissions, orgUser?.roles ?? [])) {
202+
throw new UnauthorizedError("You don't have access to do this");
203+
}
202204
}
203205
};
204206

packages/common/src/services/decision/acceptProposalInvite.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ import {
1818
* of the parent decision process
1919
*/
2020
export const acceptProposalInvite = async ({
21-
inviteId,
21+
profileId: proposalProfileId,
2222
user,
2323
}: {
24-
inviteId: string;
24+
profileId: string;
2525
user: User;
2626
}) => {
2727
const email = user.email;
@@ -31,21 +31,21 @@ export const acceptProposalInvite = async ({
3131
}
3232

3333
const invite = await db.query.profileInvites.findFirst({
34-
where: { id: inviteId },
34+
where: {
35+
profileId: proposalProfileId,
36+
email: email.toLowerCase(),
37+
acceptedOn: { isNull: true },
38+
},
3539
});
3640

3741
if (!invite) {
38-
throw new NotFoundError('Invite', inviteId);
42+
throw new NotFoundError('No pending invite found for this proposal');
3943
}
4044

4145
if (invite.acceptedOn) {
4246
throw new ConflictError('This invite has already been accepted');
4347
}
4448

45-
if (invite.email.toLowerCase() !== email.toLowerCase()) {
46-
throw new UnauthorizedError('This invite is for a different email address');
47-
}
48-
4949
// Check user isn't already a member of the proposal profile
5050
const existingProposalMembership = await db.query.profileUsers.findFirst({
5151
where: { profileId: invite.profileId, authUserId: user.id },
@@ -140,7 +140,7 @@ export const acceptProposalInvite = async ({
140140
tx
141141
.update(profileInvites)
142142
.set({ acceptedOn: now })
143-
.where(eq(profileInvites.id, inviteId)),
143+
.where(eq(profileInvites.id, invite.id)),
144144
];
145145

146146
if (decisionProfileUser) {

packages/common/src/services/decision/getProposal.ts

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,16 @@ import type {
77
} from '@op/db/schema';
88
import {
99
ProfileRelationshipType,
10-
organizations,
1110
posts,
1211
postsToProfiles,
13-
processInstances,
1412
profileRelationships,
1513
} from '@op/db/schema';
1614
import type { User } from '@op/supabase/lib';
1715
import { createSBServiceClient } from '@op/supabase/server';
1816
import { checkPermission, permission } from 'access-zones';
1917

2018
import { NotFoundError, UnauthorizedError } from '../../utils';
21-
import { getOrgAccessUser } from '../access';
19+
import { getProfileAccessUser } from '../access';
2220
import { assertUserByAuthId } from '../assert';
2321
import { generateProposalHtml } from './generateProposalHtml';
2422
import {
@@ -242,36 +240,17 @@ export const getPermissionsOnProposal = async ({
242240
}
243241
}
244242

245-
// If it's not already editable, then check admin permissions to see if we can still make it editable
246-
if (
247-
!isEditable &&
248-
proposal.processInstance &&
249-
'id' in proposal.processInstance
250-
) {
251-
// Get the organization for the process instance
252-
const instanceOrg = await db
253-
.select({
254-
id: organizations.id,
255-
})
256-
.from(organizations)
257-
.leftJoin(
258-
processInstances,
259-
eq(organizations.profileId, processInstances.ownerProfileId),
260-
)
261-
.where(eq(processInstances.id, proposal.processInstance.id))
262-
.limit(1);
243+
// If it's not already editable, check profile-level edit permission
244+
if (!isEditable && proposal.processInstance?.profileId) {
245+
const profileUser = await getProfileAccessUser({
246+
user,
247+
profileId: proposal.processInstance.profileId,
248+
});
263249

264-
if (instanceOrg[0]) {
265-
const orgUser = await getOrgAccessUser({
266-
user,
267-
organizationId: instanceOrg[0].id,
268-
});
269-
270-
isEditable = checkPermission(
271-
{ decisions: permission.ADMIN },
272-
orgUser?.roles ?? [],
273-
);
274-
}
250+
isEditable = checkPermission(
251+
{ profile: permission.UPDATE },
252+
profileUser?.roles ?? [],
253+
);
275254
}
276255

277256
return isEditable;

0 commit comments

Comments
 (0)