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 { 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.
|
||||
*
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user