From b69d2c35c57b89aa0b25190d0af84331fffd1b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Fri, 24 May 2024 14:41:34 +0200 Subject: [PATCH] Resolve "Handle invitation links" --- src/client/models/UserOutExtended.ts | 4 ++ src/client/services/AuthService.ts | 8 +++- src/client/services/UserService.ts | 29 ++++++++++- src/components/admin/CreateUserModal.vue | 8 ++-- src/router/index.ts | 3 +- src/stores/users.ts | 8 +++- src/views/LoginView.vue | 28 +++++++---- src/views/admin/AdminUsersView.vue | 61 +++++++++++++++++++----- 8 files changed, 120 insertions(+), 29 deletions(-) diff --git a/src/client/models/UserOutExtended.ts b/src/client/models/UserOutExtended.ts index 388029c..4b0aa20 100644 --- a/src/client/models/UserOutExtended.ts +++ b/src/client/models/UserOutExtended.ts @@ -20,5 +20,9 @@ export type UserOutExtended = { * Lifesicence ID of the user */ lifescience_id?: (string | null); + /** + * Timestamp when the invitation token was created as UNIX timestamp + */ + invitation_token_created_at?: (number | null); }; diff --git a/src/client/services/AuthService.ts b/src/client/services/AuthService.ts index 27d3f3f..2ca0168 100644 --- a/src/client/services/AuthService.ts +++ b/src/client/services/AuthService.ts @@ -8,14 +8,16 @@ import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class AuthService { /** - * Redirect to LifeScience OIDC Login + * Kickstart the login flow * Redirect route to OIDC provider to kickstart the login process. + * @param invitationToken Unique token to validate an invitation * @param provider The OIDC provider to use for login - * @param next Will be appended to redirect response in the callback route as URL query parameter `next_path` + * @param next Will be appended to redirect response in the callback route as URL query parameter `next` * @returns void * @throws ApiError */ public static authLogin( + invitationToken?: string, provider?: OIDCProvider, next?: string, ): CancelablePromise<void> { @@ -23,6 +25,7 @@ export class AuthService { method: 'GET', url: '/auth/login', query: { + 'invitation_token': invitationToken, 'provider': provider, 'next': next, }, @@ -65,6 +68,7 @@ export class AuthService { } /** * Logout + * Logout the user from the system by deleting the bearer cookie. * @returns void * @throws ApiError */ diff --git a/src/client/services/UserService.ts b/src/client/services/UserService.ts index e8a4680..b9da482 100644 --- a/src/client/services/UserService.ts +++ b/src/client/services/UserService.ts @@ -13,7 +13,7 @@ import { request as __request } from '../core/request'; export class UserService { /** * Create User - * Create a new user in the system and notify him. The smtp MUST be the same as the one saved by the OIDC provider. + * Create a new user in the system and notify him. * * Permission `user:create` required. * @param requestBody @@ -172,4 +172,31 @@ export class UserService { }, }); } + /** + * Resend Invitation + * Resend the invitation link for an user that has an open invitation. + * + * Permission `user:create` required. + * @param uid UID of a user + * @returns UserOutExtended Successful Response + * @throws ApiError + */ + public static userResendInvitation( + uid: string, + ): CancelablePromise<UserOutExtended> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{uid}/invitation', + path: { + 'uid': uid, + }, + errors: { + 400: `Error decoding JWT Token`, + 401: `Not Authenticated`, + 403: `Not Authorized`, + 404: `Entity not Found`, + 422: `Validation Error`, + }, + }); + } } diff --git a/src/components/admin/CreateUserModal.vue b/src/components/admin/CreateUserModal.vue index 3556d8d..e73772f 100644 --- a/src/components/admin/CreateUserModal.vue +++ b/src/components/admin/CreateUserModal.vue @@ -45,7 +45,7 @@ function createUser() { formState.loading = true; formState.registeredUserName = formState.user.display_name; userRepository - .createUser(formState.user) + .inviteUser(formState.user) .then((user) => { emit("user-created", user); formState.validated = false; @@ -76,11 +76,11 @@ onMounted(() => { <template> <bootstrap-toast toast-id="create-user-success-toast"> - Successfully registered user {{ formState.registeredUserName }} + Successfully invited user {{ formState.registeredUserName }} </bootstrap-toast> <bootstrap-toast toast-id="create-user-error-toast" color-class="danger"> <template #default - >Couldn't regsiter user + >Couldn't invite user {{ formState.registeredUserName }} </template> <template #body>Error: {{ formState.errorMessage }}</template> @@ -90,7 +90,7 @@ onMounted(() => { :static-backdrop="true" modal-label="Create user" > - <template #header>Register a user</template> + <template #header>Invite a user</template> <template #body> <form id="create-user-form" diff --git a/src/router/index.ts b/src/router/index.ts index 7c4188f..5b0b11e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -31,8 +31,9 @@ const router = createRouter({ title: "Login", }, props: (route) => ({ - returnPath: route.query.return_path ?? undefined, + returnPath: route.query.next ?? undefined, loginError: route.query.login_error ?? undefined, + invitationToken: route.query.invitation_token ?? undefined, }), }, { diff --git a/src/stores/users.ts b/src/stores/users.ts index 6cb49f2..259749f 100644 --- a/src/stores/users.ts +++ b/src/stores/users.ts @@ -113,12 +113,18 @@ export const useUserStore = defineStore({ roles: roles, }); }, - createUser(userIn: UserIn): Promise<UserOutExtended> { + inviteUser(userIn: UserIn): Promise<UserOutExtended> { return UserService.userCreateUser(userIn).then((user) => { useNameStore().addNameToMapping(user.uid, user.display_name); return user; }); }, + resendInvitationEmail(uid: string): Promise<UserOutExtended> { + return UserService.userResendInvitation(uid).then((user) => { + useNameStore().addNameToMapping(user.uid, user.display_name); + return user; + }); + }, searchUser(searchString: string): Promise<UserOut[]> { return UserService.userSearchUsers(searchString).then((users) => { const nameStore = useNameStore(); diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index d8fff7e..1c041e6 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -11,6 +11,7 @@ const router = useRouter(); const props = defineProps<{ returnPath?: string; loginError?: string; + invitationToken?: string; }>(); const store = useUserStore(); @@ -24,9 +25,17 @@ onBeforeMount(() => { } }); -const returnPathQuery = computed<string>(() => - props.returnPath ? `&next=${encodeURI(props.returnPath)}` : "", -); +const loginPath = computed<string>(() => { + const loginUrl = new URL(`${OpenAPI.BASE}/auth/login?provider=lifescience`); + console.log(props); + if (props.returnPath) { + loginUrl.searchParams.append("next", encodeURI(props.returnPath)); + } + if (props.invitationToken) { + loginUrl.searchParams.append("invitation_token", props.invitationToken); + } + return loginUrl.href; +}); onMounted(() => { errorToast = new Toast("#loginErrorToast"); @@ -69,15 +78,16 @@ onMounted(() => { <div class="card text-center ms-md-auto position-fixed top-50 start-50 translate-middle shadow" > - <div class="card-header">Login</div> + <div v-if="invitationToken" class="card-header">Invitation</div> + <div v-else class="card-header">Login</div> <div class="card-body"> - <p class="card-text text-secondary"> + <p v-if="invitationToken" class="card-text text-secondary"> + Connect your newly created CloWM account with your LifeScience account + </p> + <p v-else class="card-text text-secondary"> Login to this service with LifeScience </p> - <a - :href="`${OpenAPI.BASE}/auth/login?provider=lifescience${returnPathQuery}`" - class="m-2" - > + <a :href="loginPath" class="m-2"> <img src="/src/assets/images/ls-login.png" alt="[LS Login]" /> </a> </div> diff --git a/src/views/admin/AdminUsersView.vue b/src/views/admin/AdminUsersView.vue index e390a01..378cee0 100644 --- a/src/views/admin/AdminUsersView.vue +++ b/src/views/admin/AdminUsersView.vue @@ -9,7 +9,8 @@ import CreateUserModal from "@/components/admin/CreateUserModal.vue"; const userRepository = useUserStore(); type RoleList = RoleEnum[]; -let successToast: Toast | undefined; +let successRoleToast: Toast | undefined; +let successInvitationToast: Toast | undefined; let errorToast: Toast | undefined; const userState = reactive<{ @@ -62,7 +63,7 @@ function saveUserRoles(index: number) { .then((user) => { userState.users[index] = user; userState.editUserRoles[index] = false; - successToast?.show(); + successRoleToast?.show(); }) .catch((err) => { userState.errorMessage = err.body?.detail; @@ -80,13 +81,29 @@ function resetUserRoles(index: number) { ); } +function resendInvitationEmail(uid: string) { + userState.loading = true; + userRepository + .resendInvitationEmail(uid) + .then(() => { + successInvitationToast?.show(); + }) + .finally(() => { + userState.loading = false; + }); +} + onMounted(() => { - successToast = new Toast("#change-role-success-toast"); + successRoleToast = new Toast("#change-role-success-toast"); + successInvitationToast = new Toast("#resend-invitation-success-toast"); errorToast = new Toast("#change-role-error-toast"); }); </script> <template> + <bootstrap-toast toast-id="resend-invitation-success-toast"> + Successfully resend invitation email + </bootstrap-toast> <bootstrap-toast toast-id="change-role-success-toast"> Successfully change roles of user {{ userState.editedUser?.display_name }} </bootstrap-toast> @@ -108,7 +125,7 @@ onMounted(() => { data-bs-toggle="modal" data-bs-target="#create-user-modal" > - Register User + Invite User </button> </div> <form @submit.prevent="searchUsers" id="admin-user-search-form"> @@ -234,13 +251,35 @@ onMounted(() => { </div> </td> <td class="text-end" v-else> - <button - type="button" - class="btn btn-sm btn-secondary" - @click="userState.editUserRoles[index] = true" - > - Edit Roles - </button> + <div class="btn-group"> + <button + type="button" + class="btn btn-sm btn-secondary" + @click="userState.editUserRoles[index] = true" + > + Edit Roles + </button> + <button + v-if="user.invitation_token_created_at" + type="button" + class="btn btn-secondary btn-sm dropdown-toggle dropdown-toggle-split" + data-bs-toggle="dropdown" + aria-expanded="false" + > + <span class="visually-hidden">Toggle Dropdown</span> + </button> + <ul class="dropdown-menu"> + <li> + <a + class="dropdown-item" + :class="{ disabled: userState.loading }" + :aria-disabled="userState.loading" + @click="resendInvitationEmail(user.uid)" + >Resend invitation email</a + > + </li> + </ul> + </div> </td> </tr> </tbody> -- GitLab