Skip to content
Snippets Groups Projects
Commit b3d263a1 authored by Daniel Göbel's avatar Daniel Göbel
Browse files

Merge branch 'feature/121-handle-invitation-links' into 'main'

Resolve "Handle invitation links"

Closes #121

See merge request !118
parents b5b61432 b69d2c35
No related branches found
No related tags found
1 merge request!118Resolve "Handle invitation links"
...@@ -20,5 +20,9 @@ export type UserOutExtended = { ...@@ -20,5 +20,9 @@ export type UserOutExtended = {
* Lifesicence ID of the user * Lifesicence ID of the user
*/ */
lifescience_id?: (string | null); lifescience_id?: (string | null);
/**
* Timestamp when the invitation token was created as UNIX timestamp
*/
invitation_token_created_at?: (number | null);
}; };
...@@ -8,14 +8,16 @@ import { OpenAPI } from '../core/OpenAPI'; ...@@ -8,14 +8,16 @@ import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request'; import { request as __request } from '../core/request';
export class AuthService { export class AuthService {
/** /**
* Redirect to LifeScience OIDC Login * Kickstart the login flow
* Redirect route to OIDC provider to kickstart the login process. * 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 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 * @returns void
* @throws ApiError * @throws ApiError
*/ */
public static authLogin( public static authLogin(
invitationToken?: string,
provider?: OIDCProvider, provider?: OIDCProvider,
next?: string, next?: string,
): CancelablePromise<void> { ): CancelablePromise<void> {
...@@ -23,6 +25,7 @@ export class AuthService { ...@@ -23,6 +25,7 @@ export class AuthService {
method: 'GET', method: 'GET',
url: '/auth/login', url: '/auth/login',
query: { query: {
'invitation_token': invitationToken,
'provider': provider, 'provider': provider,
'next': next, 'next': next,
}, },
...@@ -65,6 +68,7 @@ export class AuthService { ...@@ -65,6 +68,7 @@ export class AuthService {
} }
/** /**
* Logout * Logout
* Logout the user from the system by deleting the bearer cookie.
* @returns void * @returns void
* @throws ApiError * @throws ApiError
*/ */
......
...@@ -13,7 +13,7 @@ import { request as __request } from '../core/request'; ...@@ -13,7 +13,7 @@ import { request as __request } from '../core/request';
export class UserService { export class UserService {
/** /**
* Create User * 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. * Permission `user:create` required.
* @param requestBody * @param requestBody
...@@ -172,4 +172,31 @@ export class UserService { ...@@ -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`,
},
});
}
} }
...@@ -45,7 +45,7 @@ function createUser() { ...@@ -45,7 +45,7 @@ function createUser() {
formState.loading = true; formState.loading = true;
formState.registeredUserName = formState.user.display_name; formState.registeredUserName = formState.user.display_name;
userRepository userRepository
.createUser(formState.user) .inviteUser(formState.user)
.then((user) => { .then((user) => {
emit("user-created", user); emit("user-created", user);
formState.validated = false; formState.validated = false;
...@@ -76,11 +76,11 @@ onMounted(() => { ...@@ -76,11 +76,11 @@ onMounted(() => {
<template> <template>
<bootstrap-toast toast-id="create-user-success-toast"> <bootstrap-toast toast-id="create-user-success-toast">
Successfully registered user {{ formState.registeredUserName }} Successfully invited user {{ formState.registeredUserName }}
</bootstrap-toast> </bootstrap-toast>
<bootstrap-toast toast-id="create-user-error-toast" color-class="danger"> <bootstrap-toast toast-id="create-user-error-toast" color-class="danger">
<template #default <template #default
>Couldn't regsiter user >Couldn't invite user
{{ formState.registeredUserName }} {{ formState.registeredUserName }}
</template> </template>
<template #body>Error: {{ formState.errorMessage }}</template> <template #body>Error: {{ formState.errorMessage }}</template>
...@@ -90,7 +90,7 @@ onMounted(() => { ...@@ -90,7 +90,7 @@ onMounted(() => {
:static-backdrop="true" :static-backdrop="true"
modal-label="Create user" modal-label="Create user"
> >
<template #header>Register a user</template> <template #header>Invite a user</template>
<template #body> <template #body>
<form <form
id="create-user-form" id="create-user-form"
......
...@@ -31,8 +31,9 @@ const router = createRouter({ ...@@ -31,8 +31,9 @@ const router = createRouter({
title: "Login", title: "Login",
}, },
props: (route) => ({ props: (route) => ({
returnPath: route.query.return_path ?? undefined, returnPath: route.query.next ?? undefined,
loginError: route.query.login_error ?? undefined, loginError: route.query.login_error ?? undefined,
invitationToken: route.query.invitation_token ?? undefined,
}), }),
}, },
{ {
......
...@@ -113,12 +113,18 @@ export const useUserStore = defineStore({ ...@@ -113,12 +113,18 @@ export const useUserStore = defineStore({
roles: roles, roles: roles,
}); });
}, },
createUser(userIn: UserIn): Promise<UserOutExtended> { inviteUser(userIn: UserIn): Promise<UserOutExtended> {
return UserService.userCreateUser(userIn).then((user) => { return UserService.userCreateUser(userIn).then((user) => {
useNameStore().addNameToMapping(user.uid, user.display_name); useNameStore().addNameToMapping(user.uid, user.display_name);
return user; 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[]> { searchUser(searchString: string): Promise<UserOut[]> {
return UserService.userSearchUsers(searchString).then((users) => { return UserService.userSearchUsers(searchString).then((users) => {
const nameStore = useNameStore(); const nameStore = useNameStore();
......
...@@ -11,6 +11,7 @@ const router = useRouter(); ...@@ -11,6 +11,7 @@ const router = useRouter();
const props = defineProps<{ const props = defineProps<{
returnPath?: string; returnPath?: string;
loginError?: string; loginError?: string;
invitationToken?: string;
}>(); }>();
const store = useUserStore(); const store = useUserStore();
...@@ -24,9 +25,17 @@ onBeforeMount(() => { ...@@ -24,9 +25,17 @@ onBeforeMount(() => {
} }
}); });
const returnPathQuery = computed<string>(() => const loginPath = computed<string>(() => {
props.returnPath ? `&next=${encodeURI(props.returnPath)}` : "", 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(() => { onMounted(() => {
errorToast = new Toast("#loginErrorToast"); errorToast = new Toast("#loginErrorToast");
...@@ -69,15 +78,16 @@ onMounted(() => { ...@@ -69,15 +78,16 @@ onMounted(() => {
<div <div
class="card text-center ms-md-auto position-fixed top-50 start-50 translate-middle shadow" 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"> <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 Login to this service with LifeScience
</p> </p>
<a <a :href="loginPath" class="m-2">
:href="`${OpenAPI.BASE}/auth/login?provider=lifescience${returnPathQuery}`"
class="m-2"
>
<img src="/src/assets/images/ls-login.png" alt="[LS Login]" /> <img src="/src/assets/images/ls-login.png" alt="[LS Login]" />
</a> </a>
</div> </div>
......
...@@ -9,7 +9,8 @@ import CreateUserModal from "@/components/admin/CreateUserModal.vue"; ...@@ -9,7 +9,8 @@ import CreateUserModal from "@/components/admin/CreateUserModal.vue";
const userRepository = useUserStore(); const userRepository = useUserStore();
type RoleList = RoleEnum[]; type RoleList = RoleEnum[];
let successToast: Toast | undefined; let successRoleToast: Toast | undefined;
let successInvitationToast: Toast | undefined;
let errorToast: Toast | undefined; let errorToast: Toast | undefined;
const userState = reactive<{ const userState = reactive<{
...@@ -62,7 +63,7 @@ function saveUserRoles(index: number) { ...@@ -62,7 +63,7 @@ function saveUserRoles(index: number) {
.then((user) => { .then((user) => {
userState.users[index] = user; userState.users[index] = user;
userState.editUserRoles[index] = false; userState.editUserRoles[index] = false;
successToast?.show(); successRoleToast?.show();
}) })
.catch((err) => { .catch((err) => {
userState.errorMessage = err.body?.detail; userState.errorMessage = err.body?.detail;
...@@ -80,13 +81,29 @@ function resetUserRoles(index: number) { ...@@ -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(() => { 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"); errorToast = new Toast("#change-role-error-toast");
}); });
</script> </script>
<template> <template>
<bootstrap-toast toast-id="resend-invitation-success-toast">
Successfully resend invitation email
</bootstrap-toast>
<bootstrap-toast toast-id="change-role-success-toast"> <bootstrap-toast toast-id="change-role-success-toast">
Successfully change roles of user {{ userState.editedUser?.display_name }} Successfully change roles of user {{ userState.editedUser?.display_name }}
</bootstrap-toast> </bootstrap-toast>
...@@ -108,7 +125,7 @@ onMounted(() => { ...@@ -108,7 +125,7 @@ onMounted(() => {
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#create-user-modal" data-bs-target="#create-user-modal"
> >
Register User Invite User
</button> </button>
</div> </div>
<form @submit.prevent="searchUsers" id="admin-user-search-form"> <form @submit.prevent="searchUsers" id="admin-user-search-form">
...@@ -234,13 +251,35 @@ onMounted(() => { ...@@ -234,13 +251,35 @@ onMounted(() => {
</div> </div>
</td> </td>
<td class="text-end" v-else> <td class="text-end" v-else>
<button <div class="btn-group">
type="button" <button
class="btn btn-sm btn-secondary" type="button"
@click="userState.editUserRoles[index] = true" class="btn btn-sm btn-secondary"
> @click="userState.editUserRoles[index] = true"
Edit Roles >
</button> 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> </td>
</tr> </tr>
</tbody> </tbody>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment