Feat/logged in user data

This commit is contained in:
Valentino 2022-02-10 13:34:44 +00:00
parent 41df7429d2
commit 595372bef0
15 changed files with 351 additions and 141 deletions

View file

@ -1,10 +1,11 @@
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react'; import { Disclosure, Menu, Transition } from '@headlessui/react';
import { MenuIcon, XIcon } from '@heroicons/react/outline'; import { MenuIcon, XIcon } from '@heroicons/react/outline';
import { useAuth } from 'src/services/auth'; import { useAuth } from 'src/services/auth';
import Gravatar from 'react-gravatar'; import Gravatar from 'react-gravatar';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import clsx from 'clsx'; import clsx from 'clsx';
import { CurrentUserModal } from './components/CurrentUserModal';
const navigation = [ const navigation = [
{ name: 'Dashboard', to: '/dashboard' }, { name: 'Dashboard', to: '/dashboard' },
@ -19,130 +20,138 @@ function classNames(...classes: any[]) {
interface HeaderProps {} interface HeaderProps {}
const Header: React.FC<HeaderProps> = () => { const Header: React.FC<HeaderProps> = () => {
const { logOut } = useAuth(); const [currentUserModal, setCurrentUserModal] = useState(false);
const { logOut, currentUser } = useAuth();
const { pathname } = useLocation(); const { pathname } = useLocation();
return ( const currentUserModalOpen = () => {
<Disclosure as="nav" className="bg-white shadow relative z-10"> setCurrentUserModal(true);
{({ open }) => ( };
<div className="relative"> const currentUserModalClose = () => setCurrentUserModal(false);
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
<div className="relative flex justify-between h-16">
<div className="absolute inset-y-0 left-0 flex items-center sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500">
<span className="sr-only">Open main menu</span>
{open ? (
<XIcon className="block h-6 w-6" aria-hidden="true" />
) : (
<MenuIcon className="block h-6 w-6" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
<div className="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
<div className="flex-shrink-0 flex items-center">
<img className="block lg:hidden" src="/assets/logo-small.svg" alt="Stackspin" />
<img className="hidden lg:block" src="/assets/logo.svg" alt="Stackspin" />
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{/* Current: "border-primary-500 text-gray-900", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" */}
{navigation.map((item) => (
<Link
key={item.name}
to={item.to}
className={clsx(
'border-primary-50 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium',
{
'border-primary-500 text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium':
pathname.includes(item.to),
},
)}
>
{item.name}
</Link>
))}
</div>
</div>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
{/* Profile dropdown */}
<Menu as="div" className="ml-3 relative">
<div>
<Menu.Button className="bg-white rounded-full flex text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
<span className="sr-only">Open user menu</span>
<span className="inline-flex items-center justify-center h-8 w-8 rounded-full bg-gray-500 overflow-hidden">
<Gravatar email="" size={32} rating="pg" protocol="https://" />
</span>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Item>
{({ active }) => (
<a
onClick={() => logOut()}
className={classNames(
active ? 'bg-gray-100 cursor-pointer' : '',
'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
)}
>
Configure profile
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
onClick={() => logOut()}
className={classNames(
active ? 'bg-gray-100 cursor-pointer' : '',
'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
)}
>
Sign out
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</div>
<Disclosure.Panel className="sm:hidden"> return (
<div className="pt-2 pb-4 space-y-1"> <>
{navigation.map((item) => ( <Disclosure as="nav" className="bg-white shadow relative z-10">
<Link {({ open }) => (
key={item.name} <div className="relative">
to={item.to} <div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
// getProps={({ isCurrent }) => { <div className="relative flex justify-between h-16">
// // the object returned here is passed to the <div className="absolute inset-y-0 left-0 flex items-center sm:hidden">
// // anchor element's props {/* Mobile menu button */}
// return { <Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500">
// className: isCurrent <span className="sr-only">Open main menu</span>
// ? 'bg-primary-50 border-primary-400 text-primary-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium' {open ? (
// : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium', <XIcon className="block h-6 w-6" aria-hidden="true" />
// 'aria-current': isCurrent ? 'page' : undefined, ) : (
// }; <MenuIcon className="block h-6 w-6" aria-hidden="true" />
// }} )}
> </Disclosure.Button>
{item.name} </div>
</Link> <div className="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
))} <Link to="/" className="flex-shrink-0 flex items-center">
<img className="block lg:hidden" src="/assets/logo-small.svg" alt="Stackspin" />
<img className="hidden lg:block" src="/assets/logo.svg" alt="Stackspin" />
</Link>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{/* Current: "border-primary-500 text-gray-900", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" */}
{navigation.map((item) => (
<Link
key={item.name}
to={item.to}
className={clsx(
'border-primary-50 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium',
{
'border-primary-500 text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium':
pathname.includes(item.to),
},
)}
>
{item.name}
</Link>
))}
</div>
</div>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
{/* Profile dropdown */}
<Menu as="div" className="ml-3 relative">
<div>
<Menu.Button className="bg-white rounded-full flex text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
<span className="sr-only">Open user menu</span>
<span className="inline-flex items-center justify-center h-8 w-8 rounded-full bg-gray-500 overflow-hidden">
<Gravatar email={currentUser.email || ''} size={32} rating="pg" protocol="https://" />
</span>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Item>
{({ active }) => (
<a
onClick={() => currentUserModalOpen()}
className={classNames(
active ? 'bg-gray-100 cursor-pointer' : '',
'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
)}
>
Configure profile
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
onClick={() => logOut()}
className={classNames(
active ? 'bg-gray-100 cursor-pointer' : '',
'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
)}
>
Sign out
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</div> </div>
</Disclosure.Panel>
</div> <Disclosure.Panel className="sm:hidden">
)} <div className="pt-2 pb-4 space-y-1">
</Disclosure> {navigation.map((item) => (
<Link
key={item.name}
to={item.to}
className={clsx(
'border-transparent text-gray-500 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium',
{
'bg-primary-50 border-primary-400 text-primary-700 block pl-3 pr-4 py-2': pathname.includes(
item.to,
),
},
)}
>
{item.name}
</Link>
))}
</div>
</Disclosure.Panel>
</div>
)}
</Disclosure>
<CurrentUserModal open={currentUserModal} onClose={currentUserModalClose} user={currentUser} />
</>
); );
}; };

View file

@ -0,0 +1,152 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Modal, Banner } from 'src/components';
import { Input } from 'src/components/Form';
import { useUsers } from 'src/services/users';
import { CurrentUser } from 'src/services/auth';
import { appAccessList } from './consts';
import { UserModalProps } from './types';
export const CurrentUserModal = ({ open, onClose, user }: UserModalProps) => {
const { editUserById, userModalLoading } = useUsers();
const { control, reset, handleSubmit } = useForm<CurrentUser>({
defaultValues: {
name: null,
email: null,
id: null,
preferredUsername: null,
},
});
useEffect(() => {
if (user) {
reset(user);
}
return () => {
reset({ name: null, email: null, id: null });
};
}, [user, reset]);
const handleSave = async () => {
try {
if (user) {
await handleSubmit((data) => editUserById(data))();
}
onClose();
} catch (e: any) {
// Continue
}
};
const handleKeyPress = (e: any) => {
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
handleSave();
}
};
const handleClose = () => {
onClose();
};
return (
<Modal onClose={handleClose} open={open} onSave={handleSave} isLoading={userModalLoading} useCancelButton>
<div className="bg-white px-4">
<div className="space-y-4 divide-y divide-gray-200">
<div>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Profile</h3>
</div>
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Input control={control} name="name" label="Name" onKeyPress={handleKeyPress} />
</div>
<div className="sm:col-span-3">
<Input control={control} name="email" label="Email" type="email" onKeyPress={handleKeyPress} />
</div>
<div className="sm:col-span-6">
<Banner title="Editing user status, roles and app access coming soon." titleSm="Comming soon!" />
</div>
<div className="sm:col-span-3 opacity-40 cursor-default pointer-events-none select-none">
{/* <Select control={control} name="status" label="Status" options={['Active', 'Inactive']} /> */}
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Status
</label>
<div className="mt-1">
<select
id="status"
name="status"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
>
<option>Active</option>
<option>Inactive</option>
<option>Banned</option>
</select>
</div>
</div>
<div className="sm:col-span-3 opacity-40 cursor-default pointer-events-none select-none">
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Role
</label>
<div className="mt-1">
<select
id="status"
name="status"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
>
<option>User</option>
<option>Admin</option>
<option>Super Admin</option>
</select>
</div>
</div>
</div>
</div>
<div className="opacity-40 cursor-default pointer-events-none select-none">
<div className="mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">App Access</h3>
</div>
<div>
<div className="flow-root mt-6">
<ul className="-my-5 divide-y divide-gray-200 ">
{appAccessList.map((app: any) => {
return (
<li className="py-4" key={app.name}>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0 flex-1 flex items-center">
<img className="h-10 w-10 rounded-md overflow-hidden" src={app.image} alt={app.name} />
<h3 className="ml-4 text-md leading-6 font-medium text-gray-900">{app.name}</h3>
</div>
<div>
<select
id={app.name}
name={app.name}
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
>
<option>User</option>
<option>Admin</option>
<option>Super Admin</option>
</select>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};

View file

@ -0,0 +1,18 @@
export const appAccessList = [
{
image: '/assets/wekan.svg',
name: 'Wekan',
},
{
image: '/assets/wordpress.svg',
name: 'Wordpress',
},
{
image: '/assets/nextcloud.svg',
name: 'NextCloud',
},
{
image: '/assets/zulip.svg',
name: 'Zulip',
},
];

View file

@ -0,0 +1 @@
export { CurrentUserModal } from './CurrentUserModal';

View file

@ -0,0 +1,7 @@
import { CurrentUser } from 'src/services/auth';
export type UserModalProps = {
open: boolean;
onClose: () => void;
user: CurrentUser;
};

View file

@ -0,0 +1 @@
export { CurrentUserModal } from './CurrentUserModal';

View file

@ -40,14 +40,12 @@ axios.interceptors.response.use(
); );
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <Provider store={store}>
<Provider store={store}> <PersistGate loading={null} persistor={persistor}>
<PersistGate loading={null} persistor={persistor}> <BrowserRouter>
<BrowserRouter> <App />
<App /> </BrowserRouter>
</BrowserRouter> </PersistGate>
</PersistGate> </Provider>,
</Provider>
</React.StrictMode>,
document.getElementById('root'), document.getElementById('root'),
); );

View file

@ -66,7 +66,7 @@ export const Users: React.FC = () => {
const { row } = props; const { row } = props;
return ( return (
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity"> <div className="text-right lg:opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => configureModalOpen(row.original.id)} onClick={() => configureModalOpen(row.original.id)}
type="button" type="button"
@ -90,7 +90,7 @@ export const Users: React.FC = () => {
return ( return (
<div className="relative"> <div className="relative">
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow"> <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
<div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between"> <div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
<h1 className="text-3xl leading-6 font-bold text-gray-900">Users</h1> <h1 className="text-3xl leading-6 font-bold text-gray-900">Users</h1>
<div className="mt-3 sm:mt-0 sm:ml-4"> <div className="mt-3 sm:mt-0 sm:ml-4">
@ -144,9 +144,9 @@ export const Users: React.FC = () => {
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="-my-2 sm:-mx-6 lg:-mx-8"> <div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"> <div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow border-b border-gray-200 sm:rounded-lg"> <div className="shadow border-b border-gray-200 sm:rounded-lg overflow-hidden">
<Table data={filterSearch as any} columns={columns} getSelectedRowIds={selectedRows} selectable /> <Table data={filterSearch as any} columns={columns} getSelectedRowIds={selectedRows} selectable />
</div> </div>
</div> </div>

View file

@ -5,11 +5,13 @@ import { Modal, Banner } from 'src/components';
import { Input } from 'src/components/Form'; import { Input } from 'src/components/Form';
import { useUsers } from 'src/services/users'; import { useUsers } from 'src/services/users';
import { CurrentUserState } from 'src/services/users/redux'; import { CurrentUserState } from 'src/services/users/redux';
import { useAuth } from 'src/services/auth';
import { appAccessList } from './consts'; import { appAccessList } from './consts';
import { UserModalProps } from './types'; import { UserModalProps } from './types';
export const UserModal = ({ open, onClose, userId }: UserModalProps) => { export const UserModal = ({ open, onClose, userId }: UserModalProps) => {
const { user, loadUser, editUserById, createNewUser, userModalLoading, deleteUserById } = useUsers(); const { user, loadUser, editUserById, createNewUser, userModalLoading, deleteUserById } = useUsers();
const { currentUser } = useAuth();
const { control, reset, handleSubmit } = useForm<CurrentUserState>({ const { control, reset, handleSubmit } = useForm<CurrentUserState>({
defaultValues: { defaultValues: {
@ -78,11 +80,12 @@ export const UserModal = ({ open, onClose, userId }: UserModalProps) => {
onSave={handleSave} onSave={handleSave}
isLoading={userModalLoading} isLoading={userModalLoading}
leftActions={ leftActions={
userId && ( userId &&
user.email !== currentUser.email && (
<button <button
onClick={handleDelete} onClick={handleDelete}
type="button" type="button"
className="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" 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" /> <TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Delete Delete

View file

@ -1,9 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { getAuthToken, signIn, signOut } from '../redux'; import { getAuthToken, getCurrentUser, signIn, signOut } from '../redux';
export function useAuth() { export function useAuth() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser);
const authToken = useSelector(getAuthToken); const authToken = useSelector(getAuthToken);
const logIn = useCallback( const logIn = useCallback(
@ -19,6 +20,7 @@ export function useAuth() {
return { return {
authToken, authToken,
currentUser,
logIn, logIn,
logOut, logOut,
}; };

View file

@ -1,4 +1,4 @@
export { signIn, signOut, AuthActionTypes } from './actions'; export { signIn, signOut, AuthActionTypes } from './actions';
export { default as reducer } from './reducers'; export { default as reducer } from './reducers';
export { getAuth, getIsAuthLoading, getAuthToken } from './selectors'; export { getAuth, getIsAuthLoading, getAuthToken, getCurrentUser } from './selectors';
export * from './types'; export * from './types';

View file

@ -2,15 +2,24 @@ import { createApiReducer, chainReducers, INITIAL_API_STATUS } from 'src/service
import { AuthState } from './types'; import { AuthState } from './types';
import { AuthActionTypes } from './actions'; import { AuthActionTypes } from './actions';
import { CurrentUser } from '../types';
const initialCurrentUserState: CurrentUser = {
email: null,
name: null,
preferredUsername: null,
id: null,
};
const initialState: AuthState = { const initialState: AuthState = {
token: null, token: null,
userInfo: initialCurrentUserState,
_status: INITIAL_API_STATUS, _status: INITIAL_API_STATUS,
}; };
const auth = createApiReducer( const auth = createApiReducer(
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE], [AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
(data) => ({ token: data.accessToken }), (data) => ({ token: data.accessToken, userInfo: data.userInfo }),
(data) => data.error.message, (data) => data.error.message,
); );

View file

@ -6,6 +6,8 @@ export const getAuth = (state: State) => state.auth;
export const getAuthToken = (state: State) => state.auth.token; export const getAuthToken = (state: State) => state.auth.token;
export const getCurrentUser = (state: State) => state.auth.userInfo;
export const getIsAuthLoading = (state: State) => isLoading(getAuth(state)); export const getIsAuthLoading = (state: State) => isLoading(getAuth(state));
export const getToken = (state: State) => state.auth.token; export const getToken = (state: State) => state.auth.token;

View file

@ -1,3 +1,11 @@
export interface Auth { export interface Auth {
token: string | null; token: string | null;
userInfo: CurrentUser;
}
export interface CurrentUser {
email: string | null;
name: string | null;
preferredUsername: string | null;
id: string | null;
} }

View file

@ -70,7 +70,7 @@ export const updateUserById = (user: any) => async (dispatch: Dispatch<any>) =>
payload: transformResponseUser(data), payload: transformResponseUser(data),
}); });
showToast('User updated sucessfully.', ToastType.Success); showToast('User updated successfully.', ToastType.Success);
dispatch(fetchUsers()); dispatch(fetchUsers());
} catch (err) { } catch (err) {
@ -95,7 +95,7 @@ export const createUser = (user: any) => async (dispatch: Dispatch<any>) => {
payload: transformResponseUser(data), payload: transformResponseUser(data),
}); });
showToast('User created sucessfully.', ToastType.Success); showToast('User created successfully.', ToastType.Success);
dispatch(fetchUsers()); dispatch(fetchUsers());
} catch (err: any) { } catch (err: any) {
@ -121,7 +121,7 @@ export const deleteUser = (id: string) => async (dispatch: Dispatch<any>) => {
payload: {}, payload: {},
}); });
showToast('User deleted sucessfully.', ToastType.Success); showToast('User deleted successfully.', ToastType.Success);
dispatch(fetchUsers()); dispatch(fetchUsers());
} catch (err) { } catch (err) {