web/satellite: use new invite functionality

This change uses the new project invite endpoint in place of the former
that adds invited users directly to a project's members. LoginArea
is updated to make the region/email from an invite email link uneditable.
VInput.vue's composition api code has also been updated to match other
components.

Issue: https://github.com/storj/storj/issues/5741

Change-Id: Ia3f82f5675fba442bb079dc8659b5396a25b9f03
This commit is contained in:
Wilfred Asomani 2023-06-07 21:24:03 +00:00 committed by Wilfred Asomani
parent 09a7d23003
commit 62c29ee9de
9 changed files with 155 additions and 185 deletions

View File

@ -3,31 +3,11 @@
import { BaseGql } from '@/api/baseGql'; import { BaseGql } from '@/api/baseGql';
import { ProjectMember, ProjectMemberCursor, ProjectMembersApi, ProjectMembersPage } from '@/types/projectMembers'; import { ProjectMember, ProjectMemberCursor, ProjectMembersApi, ProjectMembersPage } from '@/types/projectMembers';
import { HttpClient } from '@/utils/httpClient';
export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi { export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
private readonly http: HttpClient = new HttpClient();
/** private readonly ROOT_PATH: string = '/api/v0/projects';
* Used for adding team members to project.
*
* @param projectId
* @param emails
*/
public async add(projectId: string, emails: string[]): Promise<void> {
const query =
`mutation($projectId: String!, $emails:[String!]!) {
addProjectMembers(
publicId: $projectId,
email: $emails
) {publicId}
}`;
const variables = {
projectId,
emails,
};
await this.mutate(query, variables);
}
/** /**
* Used for deleting team members from project. * Used for deleting team members from project.
@ -106,6 +86,22 @@ export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
return this.getProjectMembersList(response.data.project.members); return this.getProjectMembersList(response.data.project.members);
} }
/**
* Handles inviting users to a project.
*
* @throws Error
*/
public async invite(projectID: string, emails: string[]): Promise<void> {
const path = `${this.ROOT_PATH}/${projectID}/invite`;
const body = { emails };
const httpResponse = await this.http.post(path, JSON.stringify(body));
if (httpResponse.ok) return;
const result = await httpResponse.json();
throw new Error(result.error || 'Failed to send project invitations');
}
/** /**
* Method for mapping project members page from json to ProjectMembersPage type. * Method for mapping project members page from json to ProjectMembersPage type.
* *

View File

@ -66,9 +66,9 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, onBeforeMount, ref } from 'vue'; import { computed, onBeforeMount, ref, watch } from 'vue';
import ErrorIcon from '@/../static/images/register/ErrorInfo.svg'; import ErrorIcon from '@/../static/images/register/ErrorInfo.svg';
import PasswordShownIcon from '@/../static/images/common/passwordShown.svg'; import PasswordShownIcon from '@/../static/images/common/passwordShown.svg';
@ -77,125 +77,111 @@ import PasswordHiddenIcon from '@/../static/images/common/passwordHidden.svg';
const textType = 'text'; const textType = 'text';
const passwordType = 'password'; const passwordType = 'password';
export default defineComponent({ const props = withDefaults(defineProps<{
name: 'VInput', additionalLabel?: string,
components: { initValue?: string,
PasswordHiddenIcon, label?: string,
PasswordShownIcon, height?: string,
ErrorIcon, width?: string,
}, error?: string,
props: { placeholder?: string,
additionalLabel: { roleDescription?: string,
type: String, currentLimit?: number,
default: '', maxSymbols?: number,
}, isOptional?: boolean,
currentLimit: { isLimitShown?: boolean,
type: Number, isMultiline?: boolean,
default: 0, isLoading?: boolean,
}, isPassword?: boolean,
isOptional: Boolean, isWhite?: boolean,
isLimitShown: Boolean, withIcon?: boolean,
isMultiline: Boolean, disabled?: boolean,
isLoading: Boolean, }>(), {
initValue: { additionalLabel: '',
type: String, initValue: '',
default: '', placeholder: '',
}, label: '',
label: { error: '',
type: String, roleDescription: 'input-container',
default: '', height: '48px',
}, width: '100%',
placeholder: { currentLimit: 0,
type: String, maxSymbols: Number.MAX_SAFE_INTEGER,
default: 'default', isOptional: false,
}, isLimitShown: false,
isPassword: Boolean, isLoading: false,
height: { isPassword: false,
type: String, isWhite: false,
default: '48px', withIcon: false,
}, disabled: false,
width: { });
type: String,
default: '100%',
},
error: {
type: String,
default: '',
},
maxSymbols: {
type: Number,
default: Number.MAX_SAFE_INTEGER,
},
isWhite: Boolean,
withIcon: Boolean,
disabled: Boolean,
roleDescription: {
type: String,
default: 'input-container',
},
},
emits: ['showPasswordStrength', 'hidePasswordStrength', 'setData'],
setup(props, ctx) {
const value = ref('');
const isPasswordShown = ref(false);
const type = ref(textType);
onBeforeMount(() => { const emit = defineEmits(['showPasswordStrength', 'hidePasswordStrength', 'setData']);
type.value = props.isPassword ? passwordType : textType;
value.value = props.initValue;
});
return {
isPasswordHiddenState: computed(() => {
return props.isPassword && !isPasswordShown.value;
}),
isPasswordShownState: computed(() => {
return props.isPassword && isPasswordShown.value;
}),
/**
* Returns style objects depends on props.
*/
style: computed(() => {
return {
inputStyle: {
width: props.width,
height: props.height,
padding: props.withIcon ? '0 30px 0 50px' : '',
},
labelStyle: {
color: props.isWhite ? 'white' : '#354049',
},
errorStyle: {
color: props.isWhite ? 'white' : '#FF5560',
},
};
}),
showPasswordStrength(): void {
ctx.emit('showPasswordStrength');
},
hidePasswordStrength(): void {
ctx.emit('hidePasswordStrength');
},
/**
* triggers on input.
*/
onInput(event: Event): void {
const target = event.target as HTMLInputElement;
value.value = target.value;
ctx.emit('setData', value.value); const value = ref('');
}, const isPasswordShown = ref(false);
/** const type = ref(textType);
* Triggers input type between text and password to show/hide symbols.
*/ const isPasswordHiddenState = computed(() => {
changeVision(): void { return props.isPassword && !isPasswordShown.value;
isPasswordShown.value = !isPasswordShown.value; });
type.value = isPasswordShown.value ? textType : passwordType;
}, const isPasswordShownState = computed(() => {
value, return props.isPassword && isPasswordShown.value;
isPasswordShown, });
type,
}; /**
}, * Returns style objects depends on props.
*/
const style = computed(() => {
return {
inputStyle: {
width: props.width,
height: props.height,
padding: props.withIcon ? '0 30px 0 50px' : '',
},
labelStyle: {
color: props.isWhite ? 'white' : '#354049',
},
errorStyle: {
color: props.isWhite ? 'white' : '#FF5560',
},
};
});
function showPasswordStrength(): void {
emit('showPasswordStrength');
}
function hidePasswordStrength(): void {
emit('hidePasswordStrength');
}
/**
* triggers on input.
*/
function onInput(event: Event): void {
const target = event.target as HTMLInputElement;
value.value = target.value;
emit('setData', target.value);
}
/**
* Triggers input type between text and password to show/hide symbols.
*/
function changeVision(): void {
isPasswordShown.value = !isPasswordShown.value;
type.value = isPasswordShown.value ? textType : passwordType;
}
watch(() => props.initValue, (val, oldVal) => {
if (val === oldVal) return;
value.value = val;
});
onBeforeMount(() => {
type.value = props.isPassword ? passwordType : textType;
}); });
</script> </script>
@ -245,12 +231,14 @@ export default defineComponent({
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
font-size: 16px; font-size: 16px;
line-height: 21px; line-height: 21px;
color: #354049; color: #354049;
& .add-label { & .add-label {
font-size: 'font_medium' sans-serif; font-size: 12px;
line-height: 18px;
color: var(--c-grey-5) !important; color: var(--c-grey-5) !important;
} }
} }

View File

@ -24,6 +24,7 @@
placeholder="email@email.com" placeholder="email@email.com"
role-description="email" role-description="email"
:error="formError" :error="formError"
:max-symbols="72"
@setData="(str) => setInput(index, str)" @setData="(str) => setInput(index, str)"
/> />
</div> </div>
@ -192,7 +193,7 @@ async function onAddUsersClick(): Promise<void> {
} }
try { try {
await pmStore.addProjectMembers(emailArray, projectsStore.state.selectedProject.id); await pmStore.inviteMembers(emailArray, projectsStore.state.selectedProject.id);
} catch (_) { } catch (_) {
await notify.error(`Error during adding project members.`, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL); await notify.error(`Error during adding project members.`, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL);
isLoading.value = false; isLoading.value = false;

View File

@ -109,6 +109,7 @@ async function respondToInvitation(response: ProjectInvitationResponse): Promise
projectsStore.selectProject(invite.value.projectID); projectsStore.selectProject(invite.value.projectID);
LocalData.setSelectedProjectId(invite.value.projectID); LocalData.setSelectedProjectId(invite.value.projectID);
notify.success('Invite accepted!');
analytics.pageVisit(RouteConfig.ProjectDashboard.path); analytics.pageVisit(RouteConfig.ProjectDashboard.path);
router.push(RouteConfig.ProjectDashboard.path); router.push(RouteConfig.ProjectDashboard.path);
} }

View File

@ -148,7 +148,11 @@ async function processSearchQuery(search: string): Promise<void> {
pmStore.setSearchQuery(search); pmStore.setSearchQuery(search);
} }
try { try {
await pmStore.getProjectMembers(FIRST_PAGE, projectsStore.state.selectedProject.id); const id = projectsStore.state.selectedProject.id;
if (!id) {
return;
}
await pmStore.getProjectMembers(FIRST_PAGE, id);
} catch (error) { } catch (error) {
notify.error(`Unable to fetch project members. ${error.message}`, AnalyticsErrorEventSource.PROJECT_MEMBERS_HEADER); notify.error(`Unable to fetch project members. ${error.message}`, AnalyticsErrorEventSource.PROJECT_MEMBERS_HEADER);
} }

View File

@ -26,8 +26,8 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
const api: ProjectMembersApi = new ProjectMembersApiGql(); const api: ProjectMembersApi = new ProjectMembersApiGql();
async function addProjectMembers(emails: string[], projectID: string): Promise<void> { async function inviteMembers(emails: string[], projectID: string): Promise<void> {
await api.add(projectID, emails); await api.invite(projectID, emails);
} }
async function deleteProjectMembers(projectID: string): Promise<void> { async function deleteProjectMembers(projectID: string): Promise<void> {
@ -109,7 +109,7 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
return { return {
state, state,
addProjectMembers, inviteMembers,
deleteProjectMembers, deleteProjectMembers,
getProjectMembers, getProjectMembers,
setSearchQuery, setSearchQuery,

View File

@ -33,15 +33,16 @@ export enum ProjectMemberHeaderState {
* Exposes all ProjectMembers-related functionality * Exposes all ProjectMembers-related functionality
*/ */
export interface ProjectMembersApi { export interface ProjectMembersApi {
/** /**
* Add members to project by user emails. * Invite members to project by user emails.
* *
* @param projectId * @param projectId
* @param emails list of project members email to add * @param emails list of project members email to add
* *
* @throws Error * @throws Error
*/ */
add(projectId: string, emails: string[]): Promise<void>; invite(projectId: string, emails: string[]): Promise<void>;
/** /**
* Deletes ProjectMembers from project by project member emails * Deletes ProjectMembers from project by project member emails

View File

@ -65,6 +65,8 @@
<VInput <VInput
label="Email Address" label="Email Address"
placeholder="user@example.com" placeholder="user@example.com"
:init-value="email"
:disabled="!!pathEmail"
:error="emailError" :error="emailError"
role-description="email" role-description="email"
@setData="setEmail" @setData="setEmail"
@ -194,10 +196,12 @@ const isRecoveryCodeState = ref(false);
const isBadLoginMessageShown = ref(false); const isBadLoginMessageShown = ref(false);
const isDropdownShown = ref(false); const isDropdownShown = ref(false);
const pathEmail = ref<string | null>(null);
const returnURL = ref(RouteConfig.ProjectDashboard.path); const returnURL = ref(RouteConfig.ProjectDashboard.path);
const hcaptcha = ref<VueHcaptcha | null>(null); const hcaptcha = ref<VueHcaptcha | null>(null);
const mfaInput = ref<ConfirmMFAInput & ClearInput | null>(null); const mfaInput = ref<typeof ConfirmMFAInput & ClearInput | null>(null);
const forgotPasswordPath: string = RouteConfig.ForgotPassword.path; const forgotPasswordPath: string = RouteConfig.ForgotPassword.path;
const registerPath: string = RouteConfig.Register.path; const registerPath: string = RouteConfig.Register.path;
@ -238,6 +242,11 @@ const captchaConfig = computed((): MultiCaptchaConfig => {
* Makes activated banner visible on successful account activation. * Makes activated banner visible on successful account activation.
*/ */
onMounted(() => { onMounted(() => {
pathEmail.value = route.query.email as string ?? null;
if (pathEmail.value) {
setEmail(pathEmail.value);
}
isActivatedBannerShown.value = !!route.query.activated; isActivatedBannerShown.value = !!route.query.activated;
isActivatedError.value = route.query.activated === 'false'; isActivatedError.value = route.query.activated === 'false';
@ -320,6 +329,10 @@ function clickSatellite(address): void {
* Toggles satellite selection dropdown visibility (Tardigrade). * Toggles satellite selection dropdown visibility (Tardigrade).
*/ */
function toggleDropdown(): void { function toggleDropdown(): void {
if (pathEmail.value) {
// this page was opened from an email link, so don't allow satellite selection.
return;
}
isDropdownShown.value = !isDropdownShown.value; isDropdownShown.value = !isDropdownShown.value;
} }

View File

@ -128,40 +128,6 @@ describe('actions', () => {
expect(store.state.selectedProjectMembersEmails.length).toBe(0); expect(store.state.selectedProjectMembersEmails.length).toBe(0);
}); });
it('add project members', async function () {
const store = useProjectMembersStore();
vi.spyOn(ProjectMembersApiGql.prototype, 'add').mockReturnValue(Promise.resolve());
try {
await store.addProjectMembers([projectMember1.user.email], selectedProject.id);
throw TEST_ERROR;
} catch (err) {
expect(err).toBe(TEST_ERROR);
}
});
it('add project member throws error when api call fails', async function () {
const store = useProjectMembersStore();
vi.spyOn(ProjectMembersApiGql.prototype, 'add').mockImplementation(() => {
throw TEST_ERROR;
});
const stateDump = store.state;
try {
await store.addProjectMembers([projectMember1.user.email], selectedProject.id);
} catch (err) {
expect(err).toBe(TEST_ERROR);
expect(store.state).toBe(stateDump);
return;
}
fail(UNREACHABLE_ERROR);
});
it('delete project members', async function () { it('delete project members', async function () {
const store = useProjectMembersStore(); const store = useProjectMembersStore();