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 { ProjectMember, ProjectMemberCursor, ProjectMembersApi, ProjectMembersPage } from '@/types/projectMembers';
import { HttpClient } from '@/utils/httpClient';
export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
/**
* 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);
}
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/projects';
/**
* 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);
}
/**
* 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.
*

View File

@ -66,9 +66,9 @@
</div>
</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 PasswordShownIcon from '@/../static/images/common/passwordShown.svg';
@ -77,125 +77,111 @@ import PasswordHiddenIcon from '@/../static/images/common/passwordHidden.svg';
const textType = 'text';
const passwordType = 'password';
export default defineComponent({
name: 'VInput',
components: {
PasswordHiddenIcon,
PasswordShownIcon,
ErrorIcon,
},
props: {
additionalLabel: {
type: String,
default: '',
},
currentLimit: {
type: Number,
default: 0,
},
isOptional: Boolean,
isLimitShown: Boolean,
isMultiline: Boolean,
isLoading: Boolean,
initValue: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'default',
},
isPassword: Boolean,
height: {
type: String,
default: '48px',
},
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);
const props = withDefaults(defineProps<{
additionalLabel?: string,
initValue?: string,
label?: string,
height?: string,
width?: string,
error?: string,
placeholder?: string,
roleDescription?: string,
currentLimit?: number,
maxSymbols?: number,
isOptional?: boolean,
isLimitShown?: boolean,
isMultiline?: boolean,
isLoading?: boolean,
isPassword?: boolean,
isWhite?: boolean,
withIcon?: boolean,
disabled?: boolean,
}>(), {
additionalLabel: '',
initValue: '',
placeholder: '',
label: '',
error: '',
roleDescription: 'input-container',
height: '48px',
width: '100%',
currentLimit: 0,
maxSymbols: Number.MAX_SAFE_INTEGER,
isOptional: false,
isLimitShown: false,
isLoading: false,
isPassword: false,
isWhite: false,
withIcon: false,
disabled: false,
});
onBeforeMount(() => {
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;
const emit = defineEmits(['showPasswordStrength', 'hidePasswordStrength', 'setData']);
ctx.emit('setData', value.value);
},
/**
* Triggers input type between text and password to show/hide symbols.
*/
changeVision(): void {
isPasswordShown.value = !isPasswordShown.value;
type.value = isPasswordShown.value ? textType : passwordType;
},
value,
isPasswordShown,
type,
};
},
const value = ref('');
const isPasswordShown = ref(false);
const type = ref(textType);
const isPasswordHiddenState = computed(() => {
return props.isPassword && !isPasswordShown.value;
});
const isPasswordShownState = computed(() => {
return props.isPassword && isPasswordShown.value;
});
/**
* 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>
@ -245,12 +231,14 @@ export default defineComponent({
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
line-height: 21px;
color: #354049;
& .add-label {
font-size: 'font_medium' sans-serif;
font-size: 12px;
line-height: 18px;
color: var(--c-grey-5) !important;
}
}

View File

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

View File

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

View File

@ -148,7 +148,11 @@ async function processSearchQuery(search: string): Promise<void> {
pmStore.setSearchQuery(search);
}
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) {
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();
async function addProjectMembers(emails: string[], projectID: string): Promise<void> {
await api.add(projectID, emails);
async function inviteMembers(emails: string[], projectID: string): Promise<void> {
await api.invite(projectID, emails);
}
async function deleteProjectMembers(projectID: string): Promise<void> {
@ -109,7 +109,7 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
return {
state,
addProjectMembers,
inviteMembers,
deleteProjectMembers,
getProjectMembers,
setSearchQuery,

View File

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

View File

@ -65,6 +65,8 @@
<VInput
label="Email Address"
placeholder="user@example.com"
:init-value="email"
:disabled="!!pathEmail"
:error="emailError"
role-description="email"
@setData="setEmail"
@ -194,10 +196,12 @@ const isRecoveryCodeState = ref(false);
const isBadLoginMessageShown = ref(false);
const isDropdownShown = ref(false);
const pathEmail = ref<string | null>(null);
const returnURL = ref(RouteConfig.ProjectDashboard.path);
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 registerPath: string = RouteConfig.Register.path;
@ -238,6 +242,11 @@ const captchaConfig = computed((): MultiCaptchaConfig => {
* Makes activated banner visible on successful account activation.
*/
onMounted(() => {
pathEmail.value = route.query.email as string ?? null;
if (pathEmail.value) {
setEmail(pathEmail.value);
}
isActivatedBannerShown.value = !!route.query.activated;
isActivatedError.value = route.query.activated === 'false';
@ -320,6 +329,10 @@ function clickSatellite(address): void {
* Toggles satellite selection dropdown visibility (Tardigrade).
*/
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;
}

View File

@ -128,40 +128,6 @@ describe('actions', () => {
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 () {
const store = useProjectMembersStore();