Skip to content
Snippets Groups Projects
Commit 62e5bafe authored by Arie Peterson's avatar Arie Peterson
Browse files

Merge branch '119-as-admin-create-recovery-links-from-dashboard' into 'main'

Resolve "As admin, create recovery links from dashboard"

Closes #119

See merge request !94
parents b427ea99 96354b8c
No related branches found
No related tags found
1 merge request!94Resolve "As admin, create recovery links from dashboard"
Pipeline #38908 passed with stages
in 7 minutes and 38 seconds
Showing
with 201 additions and 18 deletions
......@@ -24,4 +24,4 @@ COPY . .
EXPOSE 5000
# Define our command to be run when launching the container
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:5000", "--workers", "4", "--reload", "--capture-output", "--enable-stdio-inheritance", "--log-level", "DEBUG"]
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:5000", "--workers", "8", "--reload", "--capture-output", "--enable-stdio-inheritance", "--log-level", "DEBUG"]
......@@ -34,6 +34,14 @@ class UserService:
res = KratosApi.get("/admin/identities/{}".format(id)).json()
return UserService.__insertAppRoleToUser(id, res)
@staticmethod
def create_recovery_link(id):
kratos_data = {
"identity_id": id
}
res = KratosApi.post("/admin/recovery/link", kratos_data).json()
return res
@staticmethod
def post_user(data):
kratos_data = {
......
......@@ -28,6 +28,13 @@ def get_user(id):
res = UserService.get_user(id)
return jsonify(res)
@api_v1.route("/users/<string:id>/recovery", methods=["POST"])
@jwt_required()
@cross_origin()
@admin_required()
def get_user_recovery(id):
res = UserService.create_recovery_link(id)
return jsonify(res)
@api_v1.route("/users", methods=["POST"])
@jwt_required()
......
......@@ -4,7 +4,7 @@ WORKDIR /home/node/app
# First copy only files necessary for installing dependencies, so that we can
# cache that step even when our own source code changes.
COPY package.json yarn.lock .
COPY package.json yarn.lock ./
RUN yarn install
......
import React, { Fragment, useRef } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { InformationCircleIcon } from '@heroicons/react/outline';
type InfoModalProps = {
open: boolean;
onClose: () => void;
title: string;
body: string;
dynamicData: string;
};
export const InfoModal = ({ open, onClose, title, body, dynamicData }: InfoModalProps) => {
const cancelButtonRef = useRef(null);
return (
<Transition.Root show={open} as={Fragment}>
<Dialog
as="div"
auto-reopen="true"
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={cancelButtonRef}
onClose={onClose}
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<InformationCircleIcon className="h-6 w-6 text-blue-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{title}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">{body}</p>
</div>
<div className="mt-2">
<p className="text-sm text-gray-500">{dynamicData}</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={onClose}
ref={cancelButtonRef}
>
Close
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};
export { InfoModal } from './InfoModal';
export { ConfirmationModal } from './ConfirmationModal';
export { InfoModal } from './InfoModal';
export { Modal } from './Modal';
export { StepsModal } from './StepsModal';
......@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import { TrashIcon } from '@heroicons/react/outline';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { Banner, Modal, ConfirmationModal } from 'src/components';
import { Banner, Modal, ConfirmationModal, InfoModal } from 'src/components';
import { Input, Select } from 'src/components/Form';
import { User, UserRole, useUsers } from 'src/services/users';
import { useAuth } from 'src/services/auth';
......@@ -12,10 +12,12 @@ import { UserModalProps } from './types';
export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps) => {
const [deleteModal, setDeleteModal] = useState(false);
const [passwordLinkModal, setPasswordLinkModal] = useState(false);
const [isAdminRoleSelected, setAdminRoleSelected] = useState(true);
const [isPersonalModal, setPersonalModal] = useState(false);
const {
user,
recoveryLink,
loadUser,
loadPersonalInfo,
editUserById,
......@@ -23,6 +25,7 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
createNewUser,
userModalLoading,
deleteUserById,
getRecoveryLinkUserById,
clearSelectedUser,
} = useUsers();
const { currentUser, isAdmin } = useAuth();
......@@ -108,6 +111,15 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
const deleteModalOpen = () => setDeleteModal(true);
const deleteModalClose = () => setDeleteModal(false);
const passwordLinkModalOpen = () => {
if (userId) {
getRecoveryLinkUserById(userId);
}
setPasswordLinkModal(true);
};
const passwordLinkModalClose = () => setPasswordLinkModal(false);
const handleDelete = () => {
if (userId) {
deleteUserById(userId);
......@@ -119,6 +131,40 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
deleteModalClose();
};
// Button with delete option.
const buttonDelete = () => {
return (
userId &&
user.email !== currentUser?.email && (
<button
onClick={deleteModalOpen}
type="button"
className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Delete
</button>
)
);
};
// Button to generate password link
const buttonPasswordLink = () => {
return (
userId &&
isAdmin && (
<button
onClick={passwordLinkModalOpen}
type="button"
className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm
font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Password Link
</button>
)
);
};
return (
<>
<Modal
......@@ -127,17 +173,10 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
onSave={handleSave}
isLoading={userModalLoading}
leftActions={
userId &&
user.email !== currentUser?.email && (
<button
onClick={deleteModalOpen}
type="button"
className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Delete
</button>
)
<>
{buttonDelete()}
{buttonPasswordLink()}
</>
}
useCancelButton
>
......@@ -206,7 +245,6 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
)}
</div>
</div>
{isAdmin && !userModalLoading && (
<div>
<div className="mt-8">
......@@ -270,7 +308,6 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
</div>
</div>
</Modal>
<ConfirmationModal
onDeleteAction={handleDelete}
open={deleteModal}
......@@ -278,6 +315,13 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
title="Delete user"
body="Are you sure you want to delete this user? All of the user data will be permanently removed. This action cannot be undone."
/>
<InfoModal
open={passwordLinkModal}
onClose={passwordLinkModalClose}
title="Password reset link"
body="Below is a password reset link. Copy this link and send to the user to reset the password without using e-mail"
dynamicData={recoveryLink}
/>
</>
);
};
......@@ -3,6 +3,6 @@ export { Header } from './Header';
export { Table } from './Table';
export { Banner } from './Banner';
export { Tabs } from './Tabs';
export { Modal, ConfirmationModal, StepsModal } from './Modal';
export { Modal, ConfirmationModal, InfoModal, StepsModal } from './Modal';
export { UserModal } from './UserModal';
export { ProgressSteps } from './ProgressSteps';
......@@ -10,13 +10,15 @@ import {
deleteUser,
clearCurrentUser,
createBatchUsers,
fetchRecoveryLink,
} from '../redux';
import { getUserById, getUserModalLoading, getUserslLoading } from '../redux/selectors';
import { getUserById, getRecoveryLink, getUserModalLoading, getUserslLoading } from '../redux/selectors';
export function useUsers() {
const dispatch = useDispatch();
const users = useSelector(getUsers);
const user = useSelector(getUserById);
const recoveryLink = useSelector(getRecoveryLink);
const userModalLoading = useSelector(getUserModalLoading);
const userTableLoading = useSelector(getUserslLoading);
......@@ -55,10 +57,14 @@ export function useUsers() {
function deleteUserById(id: string) {
return dispatch(deleteUser(id));
}
function getRecoveryLinkUserById(id: string) {
return dispatch(fetchRecoveryLink(id));
}
return {
users,
user,
recoveryLink,
loadUser,
loadUsers,
loadPersonalInfo,
......@@ -68,6 +74,7 @@ export function useUsers() {
userTableLoading,
createNewUser,
deleteUserById,
getRecoveryLinkUserById,
clearSelectedUser,
createUsers,
};
......
......@@ -9,6 +9,7 @@ import {
transformRequestMultipleUsers,
transformRequestUser,
transformUser,
transformRecoveryLink,
} from '../transformations';
export enum UserActionTypes {
......@@ -17,6 +18,7 @@ export enum UserActionTypes {
UPDATE_USER = 'users/update_user',
CREATE_USER = 'users/create_user',
DELETE_USER = 'users/delete_user',
FETCH_RECOVERY_LINK = 'users/recoverylink_user',
SET_USER_MODAL_LOADING = 'users/user_modal_loading',
SET_USERS_LOADING = 'users/users_loading',
CREATE_BATCH_USERS = 'users/create_batch_users',
......@@ -185,6 +187,22 @@ export const createUser = (user: any) => async (dispatch: Dispatch<any>) => {
dispatch(setUserModalLoading(false));
};
export const fetchRecoveryLink = (id: string) => async (dispatch: Dispatch<any>) => {
try {
const { data } = await performApiCall({
path: `/users/${id}/recovery`,
method: 'POST',
});
dispatch({
type: UserActionTypes.FETCH_RECOVERY_LINK,
payload: transformRecoveryLink(data),
});
} catch (err) {
console.error(err);
}
};
export const deleteUser = (id: string) => async (dispatch: Dispatch<any>) => {
dispatch(setUserModalLoading(true));
......
......@@ -19,6 +19,11 @@ const usersReducer = (state: any = initialUsersState, action: any) => {
...state,
userModalLoading: action.payload,
};
case UserActionTypes.FETCH_RECOVERY_LINK:
return {
...state,
recoveryLink: action.payload,
};
case UserActionTypes.SET_USERS_LOADING:
return {
...state,
......
......@@ -4,3 +4,4 @@ export const getUsers = (state: State) => state.users.users;
export const getUserById = (state: State) => state.users.user;
export const getUserModalLoading = (state: State) => state.users.userModalLoading;
export const getUserslLoading = (state: State) => state.users.usersLoading;
export const getRecoveryLink = (state: State) => state.users.recoveryLink;
......@@ -10,6 +10,7 @@ export interface UsersState {
currentUser: CurrentUserState;
users: User[];
user: User;
recoveryLink: string;
userModalLoading: boolean;
usersLoading: boolean;
}
......
......@@ -91,3 +91,7 @@ export const transformBatchResponse = (response: any): any => {
failed: response.failed,
};
};
export const transformRecoveryLink = (response: any): string => {
return response.recovery_link;
};
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