Skip to content

Commit 2865796

Browse files
luannmoreiragustavosbarreto
authored andcommitted
feat(ui): add invite link generation for team members
Introduced an option to generate an invite link instead of sending an email. Updated MemberInvite.vue to support toggling between email invite and link invite. Implemented Vuex state management for storing the generated invitation link. Added new Vuex actions and mutations for handling link generation. Refactored the invite logic to separate email and link invitation flows. Enhanced UI to display and copy the generated invite link. Updated unit tests to validate both email and link invitation workflows.
1 parent 1380ad8 commit 2865796

File tree

6 files changed

+438
-128
lines changed

6 files changed

+438
-128
lines changed

ui/src/api/client/api.ts

Lines changed: 212 additions & 43 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/components/Team/Member/MemberInvite.vue

Lines changed: 134 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -70,33 +70,68 @@
7070
<v-card-title class="text-center">
7171
Invite Member
7272
</v-card-title>
73+
<v-window v-model="formWindow">
74+
<v-window-item value="form-1">
75+
<v-card-text>
76+
<p
77+
class="mb-4"
78+
v-if="envVariables.isCloud"
79+
>
80+
If this email isn't associated with an existing account, we'll send an email to sign-up.
81+
</p>
7382

74-
<v-card-text>
75-
<p
76-
class="text-caption text-grey-lighten-4 mb-1"
77-
v-if="envVariables.isCloud"
78-
>
79-
If this email isn't associated with an existing account, we'll send an email to sign-up.
80-
</p>
81-
<v-text-field
82-
v-model="email"
83-
label="Email"
84-
:error-messages="emailError"
85-
required
86-
data-test="email-text"
87-
/>
88-
</v-card-text>
89-
90-
<v-card-text class="mt-n10">
91-
<v-select
92-
v-model="selectedRole"
93-
:items="items"
94-
label="Role"
95-
:error-messages="selectedRoleError"
96-
required
97-
data-test="role-select"
98-
/>
99-
</v-card-text>
83+
<v-text-field
84+
v-model="email"
85+
label="Email"
86+
:error-messages="emailError"
87+
required
88+
data-test="email-text"
89+
/>
90+
</v-card-text>
91+
92+
<v-card-text class="mt-n10">
93+
<v-select
94+
v-model="selectedRole"
95+
:items="items"
96+
label="Role"
97+
:error-messages="selectedRoleError"
98+
required
99+
data-test="role-select"
100+
/>
101+
102+
<v-checkbox
103+
v-model="getInvitationCheckbox"
104+
label="Get the invite link instead of sending an e-mail"
105+
hide-details
106+
data-test="link-request-checkbox"
107+
/>
108+
</v-card-text>
109+
</v-window-item>
110+
<v-window-item value="form-2">
111+
<v-card-text>
112+
<p class="mb-4">
113+
Share this link with the person you want to invite. They can use it to join your namespace.
114+
</p>
115+
<p class="mb-4"><strong>Note:</strong> This link is only valid for the email address you entered earlier.
116+
</p>
117+
<v-text-field
118+
v-model="invitationLink"
119+
@click="copyText(invitationLink)"
120+
@keypress="copyText(invitationLink)"
121+
readonly
122+
active
123+
density="compact"
124+
append-icon="mdi-content-copy"
125+
label="Invitation Link"
126+
data-test="invitation-link"
127+
/>
128+
<p class="text-caption text-grey-darken-1">
129+
The invitation link remains valid for 7 days, if the link does not work, ensure the invite has not expired.
130+
</p>
131+
</v-card-text>
132+
</v-window-item>
133+
134+
</v-window>
100135

101136
<v-card-actions>
102137
<v-spacer />
@@ -108,18 +143,20 @@
108143
<v-btn
109144
color="primary"
110145
data-test="invite-btn"
111-
@click="addMember()"
146+
@click="getInvitationCheckbox ? generateLinkInvite() : sendEmailInvite()"
147+
:disabled="!email || !selectedRole || !!emailError || formWindow === 'form-2'"
112148
>
113149
Invite
114150
</v-btn>
151+
115152
</v-card-actions>
116153
</v-card>
117154
</v-dialog>
118155
</div>
119156
</template>
120157

121158
<script setup lang="ts">
122-
import { ref } from "vue";
159+
import { computed, ref } from "vue";
123160
import { useField } from "vee-validate";
124161
import * as yup from "yup";
125162
import axios, { AxiosError } from "axios";
@@ -128,6 +165,7 @@ import hasPermission from "@/utils/permission";
128165
import { useStore } from "@/store";
129166
import { actions, authorizer } from "@/authorizer";
130167
import {
168+
INotificationsCopy,
131169
INotificationsError,
132170
INotificationsSuccess,
133171
} from "@/interfaces/INotifications";
@@ -139,6 +177,9 @@ const items = ["administrator", "operator", "observer"];
139177
const emit = defineEmits(["update"]);
140178
const store = useStore();
141179
const dialog = ref(false);
180+
const getInvitationCheckbox = ref(false);
181+
const invitationLink = computed(() => store.getters["namespaces/getInvitationLink"]);
182+
const formWindow = ref("form-1");
142183
143184
const {
144185
value: email,
@@ -152,7 +193,6 @@ const {
152193
const {
153194
value: selectedRole,
154195
errorMessage: selectedRoleError,
155-
setErrors: setSelectedRoleError,
156196
resetField: resetSelectedRole,
157197
} = useField<string>("selectedRole", yup.string().required(), {
158198
initialValue: "",
@@ -170,20 +210,6 @@ const hasAuthorization = () => {
170210
return false;
171211
};
172212
173-
const hasErrors = () => {
174-
if (selectedRole.value === "") {
175-
setSelectedRoleError("Select a role");
176-
return true;
177-
}
178-
179-
if (email.value === "") {
180-
setEmailError("This field is required");
181-
return true;
182-
}
183-
184-
return false;
185-
};
186-
187213
const getAvatar = (index: number) => multiavatar(`${Math.floor(Math.random() * (Number.MAX_SAFE_INTEGER - index + 1)) + index}`);
188214
189215
const resetFields = () => {
@@ -194,51 +220,82 @@ const resetFields = () => {
194220
const close = () => {
195221
resetFields();
196222
dialog.value = false;
223+
formWindow.value = "form-1";
197224
};
198225
199226
const update = () => {
200227
emit("update");
201228
close();
202229
};
203230
204-
const addMember = async () => {
205-
if (!hasErrors()) {
206-
try {
207-
await store.dispatch("namespaces/addUser", {
208-
email: email.value,
209-
tenant_id: store.getters["auth/tenant"],
210-
role: selectedRole.value,
211-
});
212-
213-
store.dispatch(
214-
"snackbar/showSnackbarSuccessAction",
215-
INotificationsSuccess.namespaceNewMember,
216-
);
217-
update();
218-
resetFields();
219-
} catch (error: unknown) {
220-
store.dispatch(
221-
"snackbar/showSnackbarErrorAction",
222-
INotificationsError.namespaceNewMember,
223-
);
224-
if (axios.isAxiosError(error)) {
225-
const axiosError = error as AxiosError;
226-
switch (axiosError.response?.status) {
227-
case 409:
228-
setEmailError("This user is already a member of this namespace.");
229-
break;
230-
case 404:
231-
setEmailError("This user does not exist.");
232-
break;
233-
default:
234-
handleError(error);
235-
}
236-
}
231+
const copyText = (value: string | undefined) => {
232+
if (value) {
233+
navigator.clipboard.writeText(value);
234+
store.dispatch("snackbar/showSnackbarCopy", INotificationsCopy.invitationLink);
235+
}
236+
};
237+
238+
const handleInviteError = (error: unknown) => {
239+
store.dispatch(
240+
"snackbar/showSnackbarErrorAction",
241+
INotificationsError.namespaceNewMember,
242+
);
243+
244+
if (axios.isAxiosError(error)) {
245+
const axiosError = error as AxiosError;
246+
switch (axiosError.response?.status) {
247+
case 409:
248+
setEmailError("This user is already a member of this namespace.");
249+
break;
250+
case 404:
251+
setEmailError("This user does not exist.");
252+
break;
253+
default:
254+
handleError(error);
237255
}
238256
}
239257
};
240258
241-
defineExpose({ emailError });
259+
const generateLinkInvite = async () => {
260+
try {
261+
await store.dispatch("namespaces/generateInvitationLink", {
262+
email: email.value,
263+
tenant_id: store.getters["auth/tenant"],
264+
role: selectedRole.value,
265+
});
266+
267+
store.dispatch(
268+
"snackbar/showSnackbarSuccessAction",
269+
INotificationsSuccess.namespaceNewMember,
270+
);
271+
272+
formWindow.value = "form-2";
273+
} catch (error) {
274+
handleInviteError(error);
275+
}
276+
};
277+
278+
const sendEmailInvite = async () => {
279+
try {
280+
await store.dispatch("namespaces/sendEmailInvitation", {
281+
email: email.value,
282+
tenant_id: store.getters["auth/tenant"],
283+
role: selectedRole.value,
284+
});
285+
286+
store.dispatch(
287+
"snackbar/showSnackbarSuccessAction",
288+
INotificationsSuccess.namespaceNewMember,
289+
);
290+
291+
update();
292+
resetFields();
293+
} catch (error) {
294+
handleInviteError(error);
295+
}
296+
};
297+
298+
defineExpose({ emailError, formWindow, invitationLink });
242299
</script>
243300

244301
<style lang="scss" scoped>

ui/src/interfaces/INotifications.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,5 +126,6 @@ export enum INotificationsCopy {
126126
recoveryCodes = "Recovery Codes",
127127
tenantId = "Tenant ID",
128128
copyKey = "API Key",
129-
connector = "Connector host"
129+
connector = "Connector host",
130+
invitationLink = "Invitation Link"
130131
}

ui/src/store/api/namespaces.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ export const putNamespace = async (data: INamespaceResponse) => namespacesApi.ed
1919
},
2020
});
2121

22-
export const addUserToNamespace = async (data: INamespaceResponse) => namespacesApi.addNamespaceMember(data.tenant_id, {
22+
export const sendNamespaceLink = async (data: INamespaceResponse) => namespacesApi.addNamespaceMember(data.tenant_id, {
23+
email: data.email,
24+
role: data.role,
25+
});
26+
27+
export const generateNamespaceLink = async (data: INamespaceResponse) => namespacesApi.generateInvitationLink(data.tenant_id, {
2328
email: data.email,
2429
role: data.role,
2530
});

ui/src/store/modules/namespaces.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface NamespacesState {
1313
numberNamespaces: number;
1414
owner: boolean;
1515
userStatus: string;
16+
invitationLink: string;
1617
}
1718

1819
export const namespaces: Module<NamespacesState, State> = {
@@ -26,6 +27,7 @@ export const namespaces: Module<NamespacesState, State> = {
2627
numberNamespaces: 0,
2728
owner: false,
2829
userStatus: "",
30+
invitationLink: "",
2931
},
3032

3133
getters: {
@@ -35,6 +37,7 @@ export const namespaces: Module<NamespacesState, State> = {
3537
owner: (state) => state.owner,
3638
billing: (state) => state.billing,
3739
getUserStatus: (state) => state.userStatus,
40+
getInvitationLink: (state) => state.invitationLink,
3841
},
3942

4043
mutations: {
@@ -81,6 +84,10 @@ export const namespaces: Module<NamespacesState, State> = {
8184
setOwnerStatus: (state, status) => {
8285
state.owner = status;
8386
},
87+
88+
setInvitationLink: (state, link) => {
89+
state.invitationLink = link;
90+
},
8491
},
8592

8693
actions: {
@@ -127,8 +134,23 @@ export const namespaces: Module<NamespacesState, State> = {
127134
}
128135
},
129136

130-
addUser: async (context, data) => {
131-
await apiNamespace.addUserToNamespace(data);
137+
sendEmailInvitation: async (context, data) => {
138+
try {
139+
await apiNamespace.sendNamespaceLink(data);
140+
} catch (error) {
141+
console.error(error);
142+
throw error;
143+
}
144+
},
145+
146+
generateInvitationLink: async (context, data) => {
147+
try {
148+
const res = await apiNamespace.generateNamespaceLink(data);
149+
context.commit("setInvitationLink", res.data.link);
150+
} catch (error) {
151+
console.error(error);
152+
throw error;
153+
}
132154
},
133155

134156
editUser: async (context, data) => {

0 commit comments

Comments
 (0)