Resend invite (#4274)

* Set up resending invite

* Resend invitation

* Set up resending invite in frontend

* Destroy invitation along with organization

* Fix lint issues

* Remove accepted flag

* Automatically add pending invitation in OrganizationStore
This commit is contained in:
Marek Fořt 2022-03-24 08:41:49 +01:00 committed by GitHub
parent 6aa50e0eb5
commit 0354e19556
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 674 additions and 108 deletions

View File

@ -8,6 +8,7 @@
"shopify.vscode-shadowenv",
"editorconfig.editorconfig",
"dracula-theme.theme-dracula",
"esbenp.prettier-vscode"
"esbenp.prettier-vscode",
"Orta.vscode-jest",
]
}

View File

@ -24,5 +24,6 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.exclude": {
"**/node_modules": true
}
},
"jest.jestCommandLine": "yarn test"
}

View File

@ -1,26 +1,28 @@
import OrganizationStore from '../stores/OrganizationStore';
import { Role } from '../graphql/types';
import { UserBasicInfo } from '@/models/UserBasicInfo';
import { OrganizationDetail } from '@/models';
jest.mock('@apollo/client');
describe('OrganizationStore', () => {
const userOne = {
const userOne: UserBasicInfo = {
id: 'user-1',
email: 'user1@email.com',
name: 'User One',
account: { name: 'user-one' },
accountName: 'user-one',
avatarUrl: '',
};
const userTwo = {
const userTwo: UserBasicInfo = {
id: 'user-2',
email: 'user2@email.com',
name: 'User Two',
account: { name: 'user-two' },
accountName: 'user-two',
avatarUrl: '',
};
const admin = {
const admin: UserBasicInfo = {
id: 'admin',
email: 'admin@email.com',
name: 'Admin',
account: { name: 'admin' },
accountName: 'admin',
avatarUrl: '',
};
const client = {
@ -35,32 +37,32 @@ describe('OrganizationStore', () => {
it('loads organization', async () => {
// Given
const organization = {
__typename: 'Organization' as 'Organization' | undefined,
id: 'organization-id',
users: [userOne, userTwo],
admins: [admin],
pendingInvitations: [],
};
const organizationStore = new OrganizationStore(client as any);
const expectedUserOne = {
role: Role.User,
id: userOne.id,
email: userOne.email,
name: userOne.account.name,
avatarUrl: undefined,
name: userOne.accountName,
avatarUrl: '',
};
const expectedUserTwo = {
role: Role.User,
id: userTwo.id,
email: userTwo.email,
name: userTwo.account.name,
avatarUrl: undefined,
name: userTwo.accountName,
avatarUrl: '',
};
const expectedAdmin = {
role: Role.Admin,
id: admin.id,
email: admin.email,
name: admin.account.name,
avatarUrl: undefined,
name: admin.accountName,
avatarUrl: '',
};
// When
@ -86,6 +88,7 @@ describe('OrganizationStore', () => {
id: 'organization-id',
users: [userOne, userTwo],
admins: [admin],
pendingInvitations: [],
};
const organizationStore = new OrganizationStore(client as any);
organizationStore.organization = organization;
@ -93,22 +96,22 @@ describe('OrganizationStore', () => {
role: Role.User,
id: userOne.id,
email: userOne.email,
name: userOne.account.name,
avatarUrl: undefined,
name: userOne.accountName,
avatarUrl: '',
};
const expectedUserTwo = {
role: Role.User,
id: userTwo.id,
email: userTwo.email,
name: userTwo.account.name,
avatarUrl: undefined,
name: userTwo.accountName,
avatarUrl: '',
};
const expectedUserThree = {
role: Role.User,
id: admin.id,
email: admin.email,
name: admin.account.name,
avatarUrl: undefined,
name: admin.accountName,
avatarUrl: '',
};
// When
@ -126,26 +129,26 @@ describe('OrganizationStore', () => {
it('removes a user from the organization', async () => {
// Given
const organization = {
__typename: 'Organization' as 'Organization' | undefined,
id: 'organization-id',
users: [userOne, userTwo],
admins: [admin],
};
pendingInvitations: [],
} as OrganizationDetail;
const organizationStore = new OrganizationStore(client as any);
organizationStore.organization = organization;
const expectedUserOne = {
role: Role.User,
id: userOne.id,
email: userOne.email,
name: userOne.account.name,
avatarUrl: undefined,
name: userOne.accountName,
avatarUrl: '',
};
const expectedAdmin = {
role: Role.Admin,
id: admin.id,
email: admin.email,
name: admin.account.name,
avatarUrl: undefined,
name: admin.accountName,
avatarUrl: '',
};
// When

View File

@ -31,10 +31,14 @@ const AcceptInvitationPage = observer(() => {
primaryAction={{
content: 'Accept the invitation',
onAction: async () => {
console.log('Accept me!');
// TODO: Handle error (e.g. when already signed in but with a different account than the invitation was meant for)
const slug =
await acceptInvitationPageStore.acceptInvitation(
token ?? '',
);
console.log('ola');
console.log(slug);
navigate(`/${slug}`);
},
}}

View File

@ -125,7 +125,7 @@ const OrganizationPage = observer(() => {
.includes(userStore.me.id)) ??
false;
const [organizationPageStore] = useState(
() => new OrganizationPageStore(),
() => new OrganizationPageStore(organizationStore),
);
return (
<Page
@ -180,6 +180,42 @@ const OrganizationPage = observer(() => {
}}
/>
</Card>
{organizationPageStore.isPendingInvitationsVisible && (
<Card title="Pending invitations">
<ResourceList
resourceName={{
singular: 'pending invitation',
plural: 'pending invitations',
}}
items={
organizationStore.organization?.pendingInvitations ?? []
}
renderItem={({ inviteeEmail, id }) => {
return (
<div style={{ padding: '10px 100px 10px 20px' }}>
<Stack alignment={'center'}>
<Avatar customer size="medium" />
<Stack.Item fill={true}>
<TextStyle variation="strong">
{inviteeEmail}
</TextStyle>
</Stack.Item>
{isAdmin && (
<Button
onClick={() => {
organizationStore.resendInvite(id);
}}
>
Resend invite
</Button>
)}
</Stack>
</div>
);
}}
/>
</Card>
)}
</Page>
);
});

View File

@ -1,13 +1,24 @@
import OrganizationStore from '@/stores/OrganizationStore';
import { makeAutoObservable } from 'mobx';
class OrganizationPageStore {
inviteeEmail = '';
isInvitePopoverActive = false;
constructor() {
private organizationStore: OrganizationStore;
constructor(organizationStore: OrganizationStore) {
this.organizationStore = organizationStore;
makeAutoObservable(this);
}
get isPendingInvitationsVisible() {
return (
(this.organizationStore.organization?.pendingInvitations
.length ?? 0) > 0
);
}
invitePopoverClosed() {
this.isInvitePopoverActive = false;
}

View File

@ -0,0 +1,54 @@
import { OrganizationDetail } from '@/models';
import OrganizationStore from '@/stores/OrganizationStore';
import OrganizationPageStore from '../OrganizationPageStore';
jest.mock('@/stores/OrganizationStore');
describe('OrganizationPageStore', () => {
const organizationStore = {} as OrganizationStore;
beforeEach(() => {
organizationStore.organization = {
id: 'organization',
pendingInvitations: [],
users: [],
admins: [],
} as OrganizationDetail;
});
afterEach(() => {
jest.clearAllMocks();
});
it('sets isPendingInvitationsVisible to false when there are pending invitations', () => {
// Given
organizationStore.organization!.pendingInvitations = [
{ id: 'one', inviteeEmail: 'test@mail.com' },
];
// When
const organizationPageStore = new OrganizationPageStore(
organizationStore,
);
// Then
expect(organizationPageStore.isPendingInvitationsVisible).toBe(
true,
);
});
it('sets isPendingInvitationsVisible to true when there are not pending invitations', () => {
// Given
organizationStore.organization!.pendingInvitations = [];
// When
const organizationPageStore = new OrganizationPageStore(
organizationStore,
);
// Then
expect(organizationPageStore.isPendingInvitationsVisible).toBe(
false,
);
});
});

View File

@ -1,6 +1,6 @@
import ProjectStore from '@/stores/ProjectStore';
import RemoteCachePageStore from '../RemoteCachePageStore';
import { Account, S3Bucket } from '@/models';
import { S3Bucket } from '@/models';
import { S3BucketInfoFragment } from '@/graphql/types';
jest.mock('@apollo/client');
@ -29,7 +29,7 @@ describe('RemoteCachePageStore', () => {
};
});
it('keeps apply changes button disabled when not all fields are filled', async () => {
it('keeps apply changes button disabled when not all fields are filled', () => {
// Given
const remoteCachePageStore = new RemoteCachePageStore(
client,
@ -46,7 +46,7 @@ describe('RemoteCachePageStore', () => {
).toBeTruthy();
});
it('marks apply changes button enabled when all fields are filled', async () => {
it('marks apply changes button enabled when all fields are filled', () => {
// Given
const remoteCachePageStore = new RemoteCachePageStore(
client,

View File

@ -1,11 +1,5 @@
query Invitation($token: String!) {
invitation(token: $token) {
organization {
name
}
inviteeEmail
inviter {
...UserBasicInfo
}
...PendingInvitation
}
}

View File

@ -1,13 +1,5 @@
mutation InviteUser($input: InviteUserInput!) {
inviteUser(input: $input) {
inviteeEmail
inviter {
...UserBasicInfo
}
organization {
account {
name
}
}
...PendingInvitation
}
}

View File

@ -7,5 +7,8 @@ query Organization($name: String!) {
admins {
...UserBasicInfo
}
invitations {
...PendingInvitation
}
}
}

View File

@ -0,0 +1,4 @@
fragment PendingInvitation on Invitation {
inviteeEmail
id
}

View File

@ -0,0 +1,5 @@
mutation ResendInvite($input: ResendInviteInput!) {
resendInvite(input: $input) {
inviteeEmail
}
}

View File

@ -69,6 +69,7 @@ export type CreateS3BucketInput = {
export type Invitation = {
__typename?: 'Invitation';
id: Scalars['ID'];
inviteeEmail: Scalars['ID'];
inviter: User;
organization: Organization;
@ -99,6 +100,8 @@ export type Mutation = {
inviteUser: Invitation;
/** Remove user from a given organization */
removeUser: User;
/** Resend invite for a user to a given organization */
resendInvite: Invitation;
/** Update S3 bucket */
updateS3Bucket: S3Bucket;
};
@ -139,6 +142,11 @@ export type MutationRemoveUserArgs = {
};
export type MutationResendInviteArgs = {
input: ResendInviteInput;
};
export type MutationUpdateS3BucketArgs = {
input: UpdateS3BucketInput;
};
@ -148,6 +156,7 @@ export type Organization = {
account: Account;
admins: Array<User>;
id: Scalars['ID'];
invitations: Array<Invitation>;
name: Scalars['String'];
users: Array<User>;
};
@ -214,6 +223,13 @@ export type RemoveUserInput = {
userId: Scalars['ID'];
};
/** Autogenerated input type of ResendInvite */
export type ResendInviteInput = {
/** A unique identifier for the client performing the mutation. */
clientMutationId?: InputMaybe<Scalars['String']>;
invitationId: Scalars['String'];
};
export enum Role {
Admin = 'admin',
User = 'user'
@ -291,14 +307,14 @@ export type InvitationQueryVariables = Exact<{
}>;
export type InvitationQuery = { __typename?: 'Query', invitation: { __typename?: 'Invitation', inviteeEmail: string, organization: { __typename?: 'Organization', name: string }, inviter: { __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } } } };
export type InvitationQuery = { __typename?: 'Query', invitation: { __typename?: 'Invitation', inviteeEmail: string, id: string } };
export type InviteUserMutationVariables = Exact<{
input: InviteUserInput;
}>;
export type InviteUserMutation = { __typename?: 'Mutation', inviteUser: { __typename?: 'Invitation', inviteeEmail: string, inviter: { __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } }, organization: { __typename?: 'Organization', account: { __typename?: 'Account', name: string } } } };
export type InviteUserMutation = { __typename?: 'Mutation', inviteUser: { __typename?: 'Invitation', inviteeEmail: string, id: string } };
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
@ -320,7 +336,9 @@ export type OrganizationQueryVariables = Exact<{
}>;
export type OrganizationQuery = { __typename?: 'Query', organization?: { __typename?: 'Organization', id: string, users: Array<{ __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } }>, admins: Array<{ __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } }> } | null };
export type OrganizationQuery = { __typename?: 'Query', organization?: { __typename?: 'Organization', id: string, users: Array<{ __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } }>, admins: Array<{ __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } }>, invitations: Array<{ __typename?: 'Invitation', inviteeEmail: string, id: string }> } | null };
export type PendingInvitationFragment = { __typename?: 'Invitation', inviteeEmail: string, id: string };
export type ProjectQueryVariables = Exact<{
name: Scalars['String'];
@ -337,6 +355,13 @@ export type RemoveUserMutationVariables = Exact<{
export type RemoveUserMutation = { __typename?: 'Mutation', removeUser: { __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } } };
export type ResendInviteMutationVariables = Exact<{
input: ResendInviteInput;
}>;
export type ResendInviteMutation = { __typename?: 'Mutation', resendInvite: { __typename?: 'Invitation', inviteeEmail: string } };
export type S3BucketInfoFragment = { __typename?: 'S3Bucket', id: string, name: string, accessKeyId: string, secretAccessKey?: string | null, accountId: string, region: string };
export type S3BucketsQueryVariables = Exact<{
@ -355,6 +380,12 @@ export type UpdateS3BucketMutation = { __typename?: 'Mutation', updateS3Bucket:
export type UserBasicInfoFragment = { __typename?: 'User', id: string, email: string, avatarUrl?: string | null, account: { __typename?: 'Account', name: string } };
export const PendingInvitationFragmentDoc = gql`
fragment PendingInvitation on Invitation {
inviteeEmail
id
}
`;
export const S3BucketInfoFragmentDoc = gql`
fragment S3BucketInfo on S3Bucket {
id
@ -549,16 +580,10 @@ export type CreateS3BucketMutationOptions = Apollo.BaseMutationOptions<CreateS3B
export const InvitationDocument = gql`
query Invitation($token: String!) {
invitation(token: $token) {
organization {
name
}
inviteeEmail
inviter {
...UserBasicInfo
}
...PendingInvitation
}
}
${UserBasicInfoFragmentDoc}`;
${PendingInvitationFragmentDoc}`;
/**
* __useInvitationQuery__
@ -590,18 +615,10 @@ export type InvitationQueryResult = Apollo.QueryResult<InvitationQuery, Invitati
export const InviteUserDocument = gql`
mutation InviteUser($input: InviteUserInput!) {
inviteUser(input: $input) {
inviteeEmail
inviter {
...UserBasicInfo
}
organization {
account {
name
}
}
...PendingInvitation
}
}
${UserBasicInfoFragmentDoc}`;
${PendingInvitationFragmentDoc}`;
export type InviteUserMutationFn = Apollo.MutationFunction<InviteUserMutation, InviteUserMutationVariables>;
/**
@ -750,9 +767,13 @@ export const OrganizationDocument = gql`
admins {
...UserBasicInfo
}
invitations {
...PendingInvitation
}
}
}
${UserBasicInfoFragmentDoc}`;
${UserBasicInfoFragmentDoc}
${PendingInvitationFragmentDoc}`;
/**
* __useOrganizationQuery__
@ -867,6 +888,39 @@ export function useRemoveUserMutation(baseOptions?: Apollo.MutationHookOptions<R
export type RemoveUserMutationHookResult = ReturnType<typeof useRemoveUserMutation>;
export type RemoveUserMutationResult = Apollo.MutationResult<RemoveUserMutation>;
export type RemoveUserMutationOptions = Apollo.BaseMutationOptions<RemoveUserMutation, RemoveUserMutationVariables>;
export const ResendInviteDocument = gql`
mutation ResendInvite($input: ResendInviteInput!) {
resendInvite(input: $input) {
inviteeEmail
}
}
`;
export type ResendInviteMutationFn = Apollo.MutationFunction<ResendInviteMutation, ResendInviteMutationVariables>;
/**
* __useResendInviteMutation__
*
* To run a mutation, you first call `useResendInviteMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useResendInviteMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [resendInviteMutation, { data, loading, error }] = useResendInviteMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useResendInviteMutation(baseOptions?: Apollo.MutationHookOptions<ResendInviteMutation, ResendInviteMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ResendInviteMutation, ResendInviteMutationVariables>(ResendInviteDocument, options);
}
export type ResendInviteMutationHookResult = ReturnType<typeof useResendInviteMutation>;
export type ResendInviteMutationResult = Apollo.MutationResult<ResendInviteMutation>;
export type ResendInviteMutationOptions = Apollo.BaseMutationOptions<ResendInviteMutation, ResendInviteMutationVariables>;
export const S3BucketsDocument = gql`
query S3Buckets($accountName: String!) {
s3Buckets(accountName: $accountName) {

View File

@ -0,0 +1,29 @@
import { Organization } from '@/graphql/types';
import {
mapPendingInvitation,
PendingInvitation,
} from './PendingInvitation';
import { mapUserBasicInfo, UserBasicInfo } from './UserBasicInfo';
export interface OrganizationDetail {
id: string;
pendingInvitations: PendingInvitation[];
admins: UserBasicInfo[];
users: UserBasicInfo[];
}
export const mapOrganizationDetail = ({
id,
invitations,
admins,
users,
}: Organization) => {
return {
id,
pendingInvitations: invitations.map((pendingInvitation) =>
mapPendingInvitation(pendingInvitation),
),
admins: admins.map((admin) => mapUserBasicInfo(admin)),
users: users.map((user) => mapUserBasicInfo(user)),
} as OrganizationDetail;
};

View File

@ -0,0 +1,16 @@
import { PendingInvitationFragment } from '@/graphql/types';
export interface PendingInvitation {
id: string;
inviteeEmail: string;
}
export const mapPendingInvitation = ({
id,
inviteeEmail,
}: PendingInvitationFragment) => {
return {
id,
inviteeEmail,
} as PendingInvitation;
};

View File

@ -0,0 +1,22 @@
import { UserBasicInfoFragment } from '@/graphql/types';
export interface UserBasicInfo {
id: string;
email: string;
avatarUrl: string;
accountName: string;
}
export const mapUserBasicInfo = ({
id,
email,
avatarUrl,
account: { name },
}: UserBasicInfoFragment) => {
return {
id,
email,
avatarUrl,
accountName: name,
} as UserBasicInfo;
};

View File

@ -3,3 +3,5 @@ export type { User } from './User';
export type { Account } from './Account';
export type { S3Bucket } from './S3Bucket';
export { mapS3Bucket } from './S3Bucket';
export type { OrganizationDetail } from './OrganizationDetail';
export { mapOrganizationDetail } from './OrganizationDetail';

View File

@ -2,15 +2,19 @@ import { ApolloClient } from '@apollo/client';
import { makeAutoObservable, runInAction } from 'mobx';
import {
ChangeUserRoleDocument,
OrganizationQuery,
OrganizationDocument,
Role,
RemoveUserDocument,
InviteUserDocument,
} from '../graphql/types';
ResendInviteMutation,
ResendInviteDocument,
InviteUserMutation,
} from '@/graphql/types';
import { OrganizationDetail, mapOrganizationDetail } from '@/models';
import { mapPendingInvitation } from '@/models/PendingInvitation';
class OrganizationStore {
organization: OrganizationQuery['organization'];
organization: OrganizationDetail | undefined;
client: ApolloClient<object>;
@ -36,7 +40,7 @@ class OrganizationStore {
return {
id: user.id,
email: user.email,
name: user.account.name,
name: user.accountName,
avatarUrl: user.avatarUrl ?? undefined,
role: Role.User,
};
@ -51,7 +55,7 @@ class OrganizationStore {
return {
id: user.id,
email: user.email,
name: user.account.name,
name: user.accountName,
avatarUrl: user.avatarUrl ?? undefined,
role: Role.Admin,
};
@ -131,7 +135,7 @@ class OrganizationStore {
variables: { name: organizationName },
});
runInAction(() => {
this.organization = data.organization;
this.organization = mapOrganizationDetail(data.organization);
});
}
@ -139,7 +143,7 @@ class OrganizationStore {
if (!this.organization) {
return;
}
await this.client.mutate({
const { data } = await this.client.mutate<InviteUserMutation>({
mutation: InviteUserDocument,
variables: {
input: {
@ -148,6 +152,24 @@ class OrganizationStore {
},
},
});
if (data === null || data === undefined) {
return;
}
this.organization.pendingInvitations = [
mapPendingInvitation(data.inviteUser),
...this.organization.pendingInvitations,
];
}
async resendInvite(invitationId: string) {
await this.client.mutate<ResendInviteMutation>({
mutation: ResendInviteDocument,
variables: {
input: {
invitationId,
},
},
});
}
}

View File

@ -6,7 +6,7 @@ module Mutations
argument :organization_id, String, required: true
def resolve(attributes)
OrganizationInviteService.call(**attributes, inviter: context[:current_user])
OrganizationInviteService.new.invite(**attributes, inviter: context[:current_user])
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Mutations
class ResendInvite < ::Mutations::BaseMutation
argument :invitation_id, String, required: true
def resolve(attributes)
OrganizationInviteService.new.resend_invite(**attributes)
end
end
end

View File

@ -6,5 +6,6 @@ module Types
field :inviter, UserType, null: false
field :organization, OrganizationType, null: false
field :token, String, null: false
field :id, ID, null: false
end
end

View File

@ -22,6 +22,11 @@ module Types
null: false,
description: "Invite a user to a given organization",
mutation: Mutations::InviteUser
field :resend_invite,
InvitationType,
null: false,
description: "Resend invite for a user to a given organization",
mutation: Mutations::ResendInvite
field :accept_invitation,
OrganizationType,
null: false,

View File

@ -7,5 +7,6 @@ module Types
field :users, [UserType], null: false
field :admins, [UserType], null: false
field :name, String, null: false
field :invitations, [InvitationType], null: false
end
end

View File

@ -5,7 +5,7 @@ class Organization < ApplicationRecord
# Associations
has_one :account, as: :owner, class_name: "Account", dependent: :destroy
has_many :invitations, as: :organization, dependent: :destroy
has_many :invitations, dependent: :destroy
# Inspired from: https://github.com/RolifyCommunity/rolify/wiki/Usage#finding-roles-through-associations
has_many :users, -> {
where(roles: { name: :user }).where.not(roles: { name: :admin }) }, through: :roles, class_name: "User", source: :users
@ -14,4 +14,8 @@ class Organization < ApplicationRecord
def name
account.name
end
def pending_invitations
invitations.where(accepted: false)
end
end

View File

@ -21,7 +21,10 @@ class InvitationAcceptService < ApplicationService
invitation = InvitationFetchService.call(token: token)
raise Error::Unauthorized.new unless invitation.invitee_email == user.email
user.add_role(:user, invitation.organization)
ActiveRecord::Base.transaction do
user.add_role(:user, invitation.organization)
invitation.delete
end
invitation.organization
end
end

View File

@ -21,29 +21,64 @@ class OrganizationInviteService < ApplicationService
"Organization with id #{organization_id} was not found"
end
end
end
def initialize(inviter:, invitee_email:, organization_id:)
super()
@inviter = inviter
@invitee_email = invitee_email
@organization_id = organization_id
end
class InvitationNotFound < CloudError
attr_reader :invitation_id
def call
begin
organization = Organization.find(organization_id)
rescue ActiveRecord::RecordNotFound
raise Error::OrganizationNotFound.new(organization_id)
def initialize(invitation_id)
@invitation_id = invitation_id
end
def message
"Invitation with id #{invitation_id} was not found"
end
end
raise Error::Unauthorized unless OrganizationPolicy.new(inviter, organization).update?
class DuplicateInvitation < CloudError
attr_reader :invitee_email, :organization_id
def initialize(invitee_email, organization_id)
@invitee_email = invitee_email
@organization_id = organization_id
end
def message
"User with email #{invitee_email} has already been to organization with id #{organization_id}. \
Consider resending the invite instead."
end
end
end
def resend_invite(invitation_id:)
begin
invitation = Invitation.find(invitation_id)
rescue ActiveRecord::RecordNotFound
raise Error::InvitationNotFound.new(invitation_id)
end
organization = find_organization(organization_id: invitation.organization.id, inviter: invitation.inviter)
InvitationMailer
.invitation_mail(
inviter: invitation.inviter,
invitee_email: invitation.invitee_email,
organization: organization,
token: invitation.token
)
.deliver_now
invitation
end
def invite(inviter:, invitee_email:, organization_id:)
organization = find_organization(organization_id: organization_id, inviter: inviter)
token = Devise.friendly_token.first(16)
invitation = inviter.invitations.create!(
invitee_email: invitee_email,
organization_id: organization.id,
token: token
)
begin
invitation = inviter.invitations.create!(
invitee_email: invitee_email,
organization_id: organization.id,
token: token
)
rescue ActiveRecord::RecordNotUnique
raise Error::DuplicateInvitation.new(invitee_email, organization.id)
end
InvitationMailer
.invitation_mail(
inviter: inviter,
@ -54,4 +89,15 @@ class OrganizationInviteService < ApplicationService
.deliver_now
invitation
end
private def find_organization(organization_id:, inviter:)
begin
organization = Organization.find(organization_id)
rescue ActiveRecord::RecordNotFound
raise Error::OrganizationNotFound.new(organization_id)
end
raise Error::Unauthorized unless OrganizationPolicy.new(inviter, organization).update?
organization
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddInvitationIndex < ActiveRecord::Migration[7.0]
def change
add_index(:invitations, [:invitee_email, :organization_id], unique: true)
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2022_03_11_074508) do
ActiveRecord::Schema[7.0].define(version: 2022_03_23_204451) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -29,10 +29,11 @@ ActiveRecord::Schema[7.0].define(version: 2022_03_11_074508) do
t.string "inviter_type", null: false
t.bigint "inviter_id", null: false
t.string "invitee_email", null: false
t.bigint "organization_id", null: false
t.string "token", limit: 100, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "organization_id"
t.index ["invitee_email", "organization_id"], name: "index_invitations_on_invitee_email_and_organization_id", unique: true
t.index ["inviter_type", "inviter_id"], name: "index_invitations_on_inviter"
t.index ["organization_id"], name: "index_invitations_on_organization_id"
t.index ["token"], name: "index_invitations_on_token", unique: true

View File

@ -72,6 +72,7 @@ input CreateS3BucketInput {
}
type Invitation {
id: ID!
inviteeEmail: ID!
inviter: User!
organization: Organization!
@ -161,6 +162,16 @@ type Mutation {
input: RemoveUserInput!
): User!
"""
Resend invite for a user to a given organization
"""
resendInvite(
"""
Parameters for ResendInvite
"""
input: ResendInviteInput!
): Invitation!
"""
Update S3 bucket
"""
@ -176,6 +187,7 @@ type Organization {
account: Account!
admins: [User!]!
id: ID!
invitations: [Invitation!]!
name: String!
users: [User!]!
}
@ -246,6 +258,17 @@ input RemoveUserInput {
userId: ID!
}
"""
Autogenerated input type of ResendInvite
"""
input ResendInviteInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
invitationId: String!
}
enum Role {
admin
user

View File

@ -458,6 +458,24 @@
"name": "Invitation",
"description": null,
"fields": [
{
"name": "id",
"description": null,
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "inviteeEmail",
"description": null,
@ -829,6 +847,39 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "resendInvite",
"description": "Resend invite for a user to a given organization",
"args": [
{
"name": "input",
"description": "Parameters for ResendInvite",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "ResendInviteInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Invitation",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateS3Bucket",
"description": "Update S3 bucket",
@ -937,6 +988,32 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "invitations",
"description": null,
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Invitation",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": null,
@ -1437,6 +1514,45 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "ResendInviteInput",
"description": "Autogenerated input type of ResendInvite",
"fields": null,
"inputFields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "invitationId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "Role",

View File

@ -10,7 +10,7 @@ class InvitationAcceptServiceTest < ActiveSupport::TestCase
token = Devise.friendly_token.first(8)
organization = Organization.create!
Account.create!(owner: organization, name: "tuist")
inviter.invitations.create!(invitee_email: user.email, token: token, organization: organization)
invitation = inviter.invitations.create!(invitee_email: user.email, token: token, organization: organization)
# When
got = InvitationAcceptService.call(token: token, user: user)
@ -18,6 +18,9 @@ class InvitationAcceptServiceTest < ActiveSupport::TestCase
# Then
assert user.has_role?(:user, organization)
assert organization, got
assert_raises(ActiveRecord::RecordNotFound) do
Invitation.find(invitation.id)
end
end
test "fails with not authorized error when invitation email and user email mismatch" do
@ -26,7 +29,8 @@ class InvitationAcceptServiceTest < ActiveSupport::TestCase
inviter = User.create!(email: "test1@cloud.tuist.io", password: Devise.friendly_token.first(16))
token = Devise.friendly_token.first(8)
organization = Organization.create!
inviter.invitations.create!(invitee_email: "test2@cloud.tuist.io", token: token, organization: organization)
inviter.invitations.create!(invitee_email: "test2@cloud.tuist.io", token: token,
organization: organization)
# When / Then
assert_raises(InvitationAcceptService::Error::Unauthorized) do

View File

@ -12,7 +12,7 @@ class OrganizationInviteServiceTest < ActiveSupport::TestCase
invitee_email = "test1@cloud.tuist.io"
# When
got = OrganizationInviteService.call(
got = OrganizationInviteService.new.invite(
inviter: inviter,
invitee_email: invitee_email,
organization_id: organization.id
@ -23,6 +23,34 @@ class OrganizationInviteServiceTest < ActiveSupport::TestCase
assert_equal got.organization, organization
end
test "invite a user to two organizations" do
# Given
inviter = User.create!(email: "test@cloud.tuist.io", password: Devise.friendly_token.first(16))
organization_one = Organization.create!
organization_two = Organization.create!
Account.create!(owner: organization_one, name: "tuist_one")
inviter.add_role(:admin, organization_one)
Account.create!(owner: organization_two, name: "tuist_two")
inviter.add_role(:admin, organization_two)
invitee_email = "test1@cloud.tuist.io"
# When
OrganizationInviteService.new.invite(
inviter: inviter,
invitee_email: invitee_email,
organization_id: organization_one.id
)
got = OrganizationInviteService.new.invite(
inviter: inviter,
invitee_email: invitee_email,
organization_id: organization_two.id
)
# Then
assert_equal got.inviter, inviter
assert_equal got.invitee_email, invitee_email
assert_equal got.organization, organization_two
end
test "inviting a user to the organization if user is not admin fails" do
# Given
inviter = User.create!(email: "test@cloud.tuist.io", password: Devise.friendly_token.first(16))
@ -31,7 +59,7 @@ class OrganizationInviteServiceTest < ActiveSupport::TestCase
# When / Then
assert_raises(OrganizationInviteService::Error::Unauthorized) do
OrganizationInviteService.call(
OrganizationInviteService.new.invite(
inviter: inviter,
invitee_email: "test1@cloud.tuist.io",
organization_id: organization.id
@ -45,11 +73,74 @@ class OrganizationInviteServiceTest < ActiveSupport::TestCase
# When / Then
assert_raises(OrganizationInviteService::Error::OrganizationNotFound) do
OrganizationInviteService.call(
OrganizationInviteService.new.invite(
inviter: inviter,
invitee_email: "test1@cloud.tuist.io",
organization_id: "1"
)
end
end
test "inviting user to the same organization twice fails" do
# Given
inviter = User.create!(email: "test@cloud.tuist.io", password: Devise.friendly_token.first(16))
organization = Organization.create!
Account.create!(owner: organization, name: "tuist")
inviter.add_role(:admin, organization)
invitee_email = "test1@cloud.tuist.io"
# When / Then
OrganizationInviteService.new.invite(
inviter: inviter,
invitee_email: invitee_email,
organization_id: organization.id
)
assert_raises(OrganizationInviteService::Error::DuplicateInvitation) do
OrganizationInviteService.new.invite(
inviter: inviter,
invitee_email: invitee_email,
organization_id: organization.id
)
end
end
test "resend invitation" do
# Given
invitee_email = "test1@cloud.tuist.io"
organization = Organization.create!
Account.create!(owner: organization, name: "tuist")
inviter = User.create!(email: "test@cloud.tuist.io", password: Devise.friendly_token.first(16))
inviter.add_role(:admin, organization)
invitation = inviter.invitations.create!(
invitee_email: invitee_email,
organization_id: organization.id,
token: "token"
)
InvitationMailer.any_instance.expects(:invitation_mail).once
# When
got = OrganizationInviteService.new.resend_invite(
invitation_id: invitation.id,
)
# Then
assert_equal got.inviter, inviter
assert_equal got.invitee_email, invitee_email
assert_equal got.organization, organization
end
test "resend invitation fails when not found" do
# Given
organization = Organization.create!
Account.create!(owner: organization, name: "tuist")
inviter = User.create!(email: "test@cloud.tuist.io", password: Devise.friendly_token.first(16))
inviter.add_role(:admin, organization)
# When / Then
assert_raises(OrganizationInviteService::Error::InvitationNotFound) do
OrganizationInviteService.new.resend_invite(
invitation_id: 0
)
end
end
end