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:
parent
09a7d23003
commit
62c29ee9de
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user