mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-30 14:57:59 +00:00
A person is admitted when IAM says they administer the app's org, or when an ADMIN vouches for them: through a membership that came from that ADMIN's invitation, or through such an invitation pending for their verified address. The rule (isAdmitted, with vouches: an ADMIN who is not disabled) decides every IAM sign-in, linked or new. A linked account that fails it is refused and its sessions and API tokens are deleted. Sessions and API tokens work only while their holder is linked to IAM, not disabled and admitted (credentialIsCurrent over readStanding); a team token's holder is the organisation's owner. Accepting an invitation needs its sender to vouch for it now, and the membership records that sender as the member's inviter. The cutoff is the migration that ships with this: 1_iam_only_sign_in deletes every session and API token and adds OrganisationMember.invitedById beside OrganisationMemberInvite.inviterId, NULL for every row already standing, so nothing from before it vouches for anyone. IAM_ONLY_SINCE goes. IAM_PROVIDER names the one provider in lib. The e2e fixture admits its users through one ADMIN voucher. Tests cover the decision for returning and first sign-ins, vouching, credentials, and the migration against real SQLite. Co-authored-by: Hanzo Dev <[email protected]>
182 lines
4.8 KiB
TypeScript
182 lines
4.8 KiB
TypeScript
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
|
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
|
|
|
import { prisma } from '@hanzo/esign-prisma';
|
|
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { jobs } from '../../jobs/client';
|
|
import { generateDatabaseId } from '../../universal/id';
|
|
import { vouches } from '../../utils/iam-sign-in';
|
|
import { isInvitedPerson } from './is-invited-person';
|
|
|
|
export type AcceptOrganisationInvitationOptions = {
|
|
token: string;
|
|
/** The signed-in person accepting. The invitation must name them. */
|
|
userId: number;
|
|
};
|
|
|
|
/**
|
|
* Accept an organisation invitation as the signed-in person it was sent to.
|
|
*
|
|
* The token proves the invitation was received; it does not say who received
|
|
* it. So the invitation joins the person holding the session, and only when that
|
|
* person's verified address is the address it was sent to. Holding the link is
|
|
* never enough to put somebody else into the organisation. And it is accepted
|
|
* only while its sender vouches for it (`vouches`: an ADMIN, not disabled) — the
|
|
* same rule that lets it admit its invitee — and the membership records that
|
|
* sender as the member's inviter.
|
|
*/
|
|
export const acceptOrganisationInvitation = async ({
|
|
token,
|
|
userId,
|
|
}: AcceptOrganisationInvitationOptions) => {
|
|
const organisationMemberInvite = await prisma.organisationMemberInvite.findFirst({
|
|
where: {
|
|
token,
|
|
status: {
|
|
not: OrganisationMemberInviteStatus.DECLINED,
|
|
},
|
|
},
|
|
include: {
|
|
organisation: {
|
|
include: {
|
|
groups: true,
|
|
},
|
|
},
|
|
inviter: {
|
|
select: {
|
|
roles: true,
|
|
disabled: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!organisationMemberInvite) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
|
}
|
|
|
|
if (!vouches(organisationMemberInvite.inviter)) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'This invitation is no longer valid; ask an admin to invite you again',
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
|
return;
|
|
}
|
|
|
|
const user = await prisma.user.findFirst({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
emailVerified: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Sign in to accept an organisation invitation',
|
|
});
|
|
}
|
|
|
|
if (!isInvitedPerson(user, organisationMemberInvite.email)) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'This invitation was sent to a different address',
|
|
});
|
|
}
|
|
|
|
const { organisation } = organisationMemberInvite;
|
|
|
|
const isUserPartOfOrganisation = await prisma.organisationMember.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
organisationId: organisation.id,
|
|
},
|
|
});
|
|
|
|
if (isUserPartOfOrganisation) {
|
|
return;
|
|
}
|
|
|
|
await addUserToOrganisation({
|
|
userId: user.id,
|
|
organisationId: organisation.id,
|
|
organisationGroups: organisation.groups,
|
|
organisationMemberRole: organisationMemberInvite.organisationRole,
|
|
invitedById: organisationMemberInvite.inviterId,
|
|
});
|
|
|
|
await prisma.organisationMemberInvite.update({
|
|
where: {
|
|
id: organisationMemberInvite.id,
|
|
},
|
|
data: {
|
|
status: OrganisationMemberInviteStatus.ACCEPTED,
|
|
},
|
|
});
|
|
};
|
|
|
|
export const addUserToOrganisation = async ({
|
|
userId,
|
|
organisationId,
|
|
organisationGroups,
|
|
organisationMemberRole,
|
|
invitedById = null,
|
|
bypassEmail = false,
|
|
}: {
|
|
userId: number;
|
|
organisationId: string;
|
|
organisationGroups: OrganisationGroup[];
|
|
organisationMemberRole: OrganisationMemberRole;
|
|
/** The sender of the invitation this membership comes from. */
|
|
invitedById?: number | null;
|
|
bypassEmail?: boolean;
|
|
}) => {
|
|
const organisationGroupToUse = organisationGroups.find(
|
|
(group) =>
|
|
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
|
|
group.organisationRole === organisationMemberRole,
|
|
);
|
|
|
|
if (!organisationGroupToUse) {
|
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
message: 'Organisation group not found',
|
|
});
|
|
}
|
|
|
|
await prisma.$transaction(
|
|
async (tx) => {
|
|
await tx.organisationMember.create({
|
|
data: {
|
|
id: generateDatabaseId('member'),
|
|
userId,
|
|
organisationId,
|
|
invitedById,
|
|
organisationGroupMembers: {
|
|
create: {
|
|
id: generateDatabaseId('group_member'),
|
|
groupId: organisationGroupToUse.id,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
},
|
|
{ timeout: 30_000 },
|
|
);
|
|
|
|
if (!bypassEmail) {
|
|
await jobs.triggerJob({
|
|
name: 'send.organisation-member-joined.email',
|
|
payload: {
|
|
organisationId,
|
|
memberUserId: userId,
|
|
},
|
|
});
|
|
}
|
|
};
|