Initial commit

This commit is contained in:
Luka Radenovic 2021-09-27 12:17:33 +02:00
commit fa30c04815
117 changed files with 33513 additions and 0 deletions

78
src/App.tsx Normal file
View file

@ -0,0 +1,78 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useMatch, Router, navigate, RouteComponentProps, Redirect } from '@reach/router';
import { Toaster } from 'react-hot-toast';
import { isValid } from 'src/services/api';
import { useAuth } from 'src/services/auth';
import { Apps, Dashboard, Users, Login, AppSingle } from './modules';
import { Layout } from './components';
type AppProps = RouteComponentProps;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function App(_: AppProps) {
const { auth } = useAuth();
const isLoginPage = useMatch('/login');
const [initialized, setInitialized] = useState(false);
const [initializedToken, setInitializedToken] = useState<string | null>(null);
useEffect(() => {
if (!isValid(auth) && !isLoginPage) {
navigate('/login');
}
}, [auth, isLoginPage]);
useEffect(() => {
if (isValid(auth) && (!initialized || initializedToken !== auth.token)) {
setInitialized(true);
setInitializedToken(auth.token);
}
}, [auth, initialized, initializedToken]);
return (
<>
<Helmet>
<title>Stackspin</title>
<meta name="description" content="Stackspin" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
</Helmet>
<div className="app bg-gray-50 min-h-screen flex flex-col">
{!isValid(auth) ? (
<Router>
<Login path="/login" />
</Router>
) : (
<Layout>
<Router>
<Dashboard path="/dashboard" />
<Users path="/users" />
<Apps path="/apps" />
<AppSingle path="/apps/:id" />
</Router>
</Layout>
)}
{isValid(auth) ? <Redirect from="/" to="/dashboard" noThrow /> : <Redirect from="/" to="/login" noThrow />}
{/* Place to load notifications */}
<div
aria-live="assertive"
className="fixed inset-0 flex items-end px-4 py-6 pointer-events-none sm:p-6 sm:items-start"
>
<div className="w-full flex flex-col items-center space-y-4 sm:items-end" />
</div>
</div>
<Toaster />
</>
);
}
export default App;

19
src/common/const.ts Normal file
View file

@ -0,0 +1,19 @@
export const DEFAULT_API_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
export const DEFAULT_API_DATE_FORMAT = 'yyyy-MM-dd';
export const DEFAULT_DATE_FORMAT = 'dd/MM/yyyy HH:mm';
export const MAX_TABLE_ROWS = 15;
export const VALIDATION_MESSAGES = {
required: (name: string) => `${name} cannot be blank`,
invalidEmail: () => 'Email address is invalid',
passwordConfirmation: () => 'Passwords do not match',
min: (name: string, min: number) => `${name} must be at least ${min}`,
};
export const BACK_TO_PAGE = 'prevPage';
export const BACK_TO_SEARCH = 'prevSearch';
export const RETURN_TO = 'returnTo';
export const RICH_EDITOR_DEFAULT_TAB = 'richEditorTab';

1
src/common/index.ts Normal file
View file

@ -0,0 +1 @@
export { isTouched, addParamsToLink } from './util';

39
src/common/types.ts Normal file
View file

@ -0,0 +1,39 @@
export interface ImageApiData {
data: {
id: number;
fileId: number;
name: string;
isMain: boolean;
url: string;
smallUrl: string;
mediumUrl: string;
originalFileId: number;
mediumFileId: number;
smallFileId: number;
};
}
export interface ImageMediaData {
id: number;
fileId: number;
name: string;
isMain: boolean;
url: string;
smallUrl: string;
mediumUrl: string;
originalFileId: number;
mediumFileId: number;
smallFileId: number;
smallFile?: {
id: number;
name: string;
url: string;
};
}
export type ImageUploaderValue = File | string | ImageMediaData;
export type Breadcrumb = {
label: string;
href: string;
};

View file

@ -0,0 +1,17 @@
import urlcat from 'urlcat';
import { BACK_TO_PAGE, BACK_TO_SEARCH, RETURN_TO, RICH_EDITOR_DEFAULT_TAB } from '../const';
type UrlParams = {
currentPage?: number;
prevSearch?: string;
returnTo?: [string, number | string];
richEditorTab?: string;
};
export function addParamsToLink(url: string, params: UrlParams) {
return urlcat(url, {
[BACK_TO_PAGE]: params.currentPage,
[BACK_TO_SEARCH]: params.prevSearch,
[RETURN_TO]: params.returnTo?.join(':'),
[RICH_EDITOR_DEFAULT_TAB]: params.richEditorTab,
});
}

2
src/common/util/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { isTouched } from './is-touched';
export { addParamsToLink } from './add-params-to-link';

View file

@ -0,0 +1,6 @@
import { get } from 'lodash';
import { FormState } from 'react-hook-form';
export function isTouched<T>(formState: FormState<T>, fieldName: keyof T | string) {
return formState.isSubmitted || get(formState.touchedFields, fieldName);
}

View file

@ -0,0 +1,92 @@
import React, { Fragment } from 'react';
import { toast } from 'react-hot-toast';
import { Transition } from '@headlessui/react';
import { XCircleIcon, CheckCircleIcon } from '@heroicons/react/outline';
import { XIcon } from '@heroicons/react/solid';
export enum ToastType {
Success = 'success',
Error = 'error',
}
export const showToast = (text: string, type?: ToastType) => {
switch (type) {
case ToastType.Error:
toast.custom(
(t) => (
<Transition
show={t.visible}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<XCircleIcon className="h-6 w-6 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-gray-900">{text}</p>
</div>
<div className="ml-4 flex-shrink-0 flex">
<button
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => toast.dismiss(t.id)}
>
<span className="sr-only">Close</span>
<XIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</Transition>
),
{ position: 'top-right' },
);
break;
default:
toast.custom(
(t) => (
<Transition
show={t.visible}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-6 w-6 text-primary" aria-hidden="true" />
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-gray-900">{text}</p>
</div>
<div className="ml-4 flex-shrink-0 flex">
<button
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => toast.dismiss(t.id)}
>
<span className="sr-only">Close</span>
<XIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</Transition>
),
{ position: 'top-right' },
);
}
};

View file

@ -0,0 +1,134 @@
import React, { Fragment } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react';
import { MenuIcon, XIcon } from '@heroicons/react/outline';
import { Link, RouteComponentProps } from '@reach/router';
import { useAuth } from 'src/services/auth';
const navigation = [
{ name: 'Dashboard', to: '/dashboard' },
{ name: 'Apps', to: '/apps' },
{ name: 'Users', to: '/users' },
];
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(' ');
}
type HeaderProps = RouteComponentProps;
const Header: React.FC<HeaderProps> = () => {
const { logOut } = useAuth();
return (
<Disclosure as="nav" className="bg-white shadow">
{({ open }) => (
<>
<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}
getProps={({ isCurrent }) => {
// the object returned here is passed to the
// anchor element's props
return {
className: isCurrent
? 'border-primary-400 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium',
'aria-current': isCurrent ? 'page' : undefined,
};
}}
>
{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>
<img
className="h-8 w-8 rounded-full"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt=""
/>
</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' : '', 'block px-4 py-2 text-sm text-gray-700')}
>
Sign out
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</div>
<Disclosure.Panel className="sm:hidden">
<div className="pt-2 pb-4 space-y-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.to}
getProps={({ isCurrent }) => {
// the object returned here is passed to the
// anchor element's props
return {
className: isCurrent
? 'bg-primary-50 border-primary-400 text-primary-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium'
: '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',
'aria-current': isCurrent ? 'page' : undefined,
};
}}
>
{item.name}
</Link>
))}
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};
export default Header;

View file

@ -0,0 +1 @@
export { default as Header } from './Header';

View file

@ -0,0 +1,17 @@
import React from 'react';
import { RouteComponentProps } from '@reach/router';
import { Header } from '../Header';
type DashboardProps = RouteComponentProps;
const Layout: React.FC<DashboardProps> = ({ children }) => {
return (
<>
<Header />
{children}
</>
);
};
export default Layout;

View file

@ -0,0 +1 @@
export { default as Layout } from './Layout';

View file

@ -0,0 +1,89 @@
import React, { Fragment, useRef } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationIcon } from '@heroicons/react/outline';
type ConfirmationModalProps = {
open: boolean;
onClose: () => void;
title: string;
body: string;
};
export const ConfirmationModal = ({ open, onClose, title, body }: ConfirmationModalProps) => {
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-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationIcon className="h-6 w-6 text-red-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>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={onClose}
>
Delete
</button>
<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}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View file

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

View file

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

View file

@ -0,0 +1,178 @@
import { ArrowSmDownIcon, ArrowSmUpIcon } from '@heroicons/react/solid';
import React, { useEffect } from 'react';
import { useTable, useRowSelect, Column, IdType, useSortBy } from 'react-table';
export interface ReactTableProps<T extends Record<string, unknown>> {
columns: Column<T>[];
data: T[];
onRowClick?(row: T): void;
pagination?: boolean;
getSelectedRowIds?(rows: Record<IdType<T>, boolean>): void;
selectable?: boolean;
}
const IndeterminateCheckbox = React.forwardRef(({ indeterminate, ...rest }: any, ref) => {
const defaultRef = React.useRef(null);
const resolvedRef: any = ref || defaultRef;
React.useEffect(() => {
resolvedRef.current.indeterminate = indeterminate;
}, [resolvedRef, indeterminate]);
return (
<>
<input
type="checkbox"
ref={resolvedRef}
{...rest}
className="focus:ring-primary-800 h-4 w-4 text-primary-700 border-gray-300 rounded"
/>
</>
);
});
export const Table = <T extends Record<string, unknown>>({
columns,
data,
pagination = false,
onRowClick,
getSelectedRowIds,
selectable = false,
}: ReactTableProps<T>) => {
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
pageCount,
state: { selectedRowIds },
} = useTable(
{
columns,
data,
},
useSortBy,
useRowSelect,
selectable
? (hooks) => {
hooks.visibleColumns.push((columns2) => [
{
id: 'selection',
Header: ({ getToggleAllRowsSelectedProps }: { getToggleAllRowsSelectedProps: any }) => (
<div>
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} />
</div>
),
Cell: ({ row }: { row: any }) => (
<div>
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
</div>
),
width: 16,
},
...columns2,
]);
}
: () => {},
);
useEffect(() => {
if (selectedRowIds && getSelectedRowIds) {
getSelectedRowIds(selectedRowIds);
}
}, [selectedRowIds, getSelectedRowIds]);
return (
<>
<table className="min-w-full divide-y divide-gray-200 table-auto" {...getTableProps()}>
<thead className="bg-gray-50">
{headerGroups.map((headerGroup: any, index: any) => (
<tr {...headerGroup.getHeaderGroupProps()} key={index!}>
{headerGroup.headers.map((column: any) => (
<th
key={column}
{...column.getHeaderProps([
{
style: {
width: column.width ? column.width : 'auto !important',
},
},
column.getSortByToggleProps(),
])}
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
<div className="flex items-center">
<span>{column.render('Header')}</span>
{(column as any).isSorted ? (
(column as any).isSortedDesc ? (
<ArrowSmDownIcon className="w-4 h-4 text-gray-400 ml-1" />
) : (
<ArrowSmUpIcon className="w-4 h-4 text-gray-400 ml-1" />
)
) : (
''
)}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row: any, rowIndex) => {
prepareRow(row);
return (
<tr
key={row}
{...row.getRowProps()}
className={rowIndex % 2 === 0 ? 'bg-white group' : 'bg-gray-50 group'}
onClick={onRowClick ? () => onRowClick(row.original as T) : () => {}}
>
{row.cells.map((cell: any) => {
return (
<td
key={cell}
{...cell.getCellProps()}
className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{pagination && pageCount > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-100 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<a
href="#"
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Previous
</a>
<a
href="#"
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Next
</a>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">1</span> to <span className="font-medium">3</span> of{' '}
<span className="font-medium">3</span> results
</p>
</div>
</div>
</div>
)}
</>
);
};

View file

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

View file

@ -0,0 +1,8 @@
import React from 'react';
import { TabPanelProps } from './types';
export const TabPanel = ({ children, isActive }: TabPanelProps) => {
if (!isActive) return null;
return <div className="pt-8 pb-4">{children}</div>;
};

View file

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { TabPanel } from './TabPanel';
import { TabsProps } from './types';
export const Tabs = ({ tabs }: TabsProps) => {
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
const handleTabPress = (index: number) => () => {
setActiveTabIndex(index);
};
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
return (
<>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a tab
</label>
<select
id="tabs"
name="tabs"
className="block w-full focus:ring-primary-500 focus:border-primary-500 border-gray-300 rounded-md"
// defaultValue={tabs ? tabs.find((tab) => tab.current).name : undefined}
>
{tabs.map((tab) => (
<option key={tab.name}>{tab.name}</option>
))}
</select>
</div>
<div className="hidden sm:block">
<nav className="flex space-x-4" aria-label="Tabs">
{tabs.map((tab, tabIndex) => (
<a
onClick={handleTabPress(tabIndex)}
key={tab.name}
className={classNames(
activeTabIndex === tabIndex
? 'bg-gray-100 text-gray-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50',
'px-3 py-2 font-medium text-sm rounded-md cursor-pointer',
)}
aria-current={activeTabIndex === tabIndex ? 'page' : undefined}
>
{tab.name}
</a>
))}
</nav>
</div>
{tabs.map(({ component, name, disabled }, index) =>
disabled ? (
<React.Fragment key={name} />
) : (
<TabPanel key={name} isActive={activeTabIndex === index}>
{component}
</TabPanel>
),
)}
</>
);
};

View file

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

View file

@ -0,0 +1,21 @@
type Tab = {
disabled?: boolean;
name: string;
component: JSX.Element;
};
export interface TabsProps {
tabs: Tab[];
}
export interface TabPanelProps {
isActive: boolean;
children: JSX.Element | JSX.Element[];
}
export interface TabProps {
title: string;
isActive: boolean;
onPress(): void;
disabled: boolean;
}

4
src/components/index.ts Normal file
View file

@ -0,0 +1,4 @@
export { Layout } from './Layout';
export { Header } from './Header';
export { Table } from './Table';
export { Tabs } from './Tabs';

7
src/index.css vendored Normal file
View file

@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
div[tabindex] {
flex: 1;
}

53
src/index.tsx Normal file
View file

@ -0,0 +1,53 @@
import React from 'react';
import ReactDOM from 'react-dom';
import axios from 'axios';
import { Provider } from 'react-redux';
import { Router } from '@reach/router';
import { PersistGate } from 'redux-persist/integration/react';
import { isValid } from './services/api';
import { configureStore } from './redux';
import { AuthActionTypes } from './services/auth/redux';
import App from './App';
import './index.css';
const { store, persistor } = configureStore();
axios.interceptors.request.use(
(config) => {
const { auth } = store.getState();
if (isValid(auth)) {
config.headers.Authorization = `Bearer ${auth.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// when we receive 401 error, we sign out the logged in user
axios.interceptors.response.use(
(response: any) => response,
(error: any) => {
if (error.response !== undefined && error.response.status === 401) {
store.dispatch({ type: AuthActionTypes.SIGN_OUT, payload: error });
}
return Promise.reject(error);
},
);
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<Router>
<App path="*" />
</Router>
</PersistGate>
</Provider>
</React.StrictMode>,
document.getElementById('root'),
);

View file

@ -0,0 +1,158 @@
import React, { Fragment } from 'react';
import Editor from 'react-simple-code-editor';
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-yaml';
import 'prismjs/themes/prism.css';
import { initialEditorYaml } from './consts';
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
export const Advanced = () => {
const [code, setCode] = React.useState(initialEditorYaml);
return (
<>
<div className="grid grid-flow-col grid-cols-2 gap-8">
<div>
<div>
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg">
<span className="text-gray-600 text-lg leading-6 font-medium">Edit Configuration</span>
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-primary-500">
Versions
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
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="z-10 origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href="#"
className={classNames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'block px-4 py-2 text-sm',
)}
>
Save Version
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href="#"
className={classNames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'block px-4 py-2 text-sm',
)}
>
View Version History
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href="#"
className={classNames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'block px-4 py-2 text-sm',
)}
>
Restore Current Version
</a>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
<div className="px-4 py-5 sm:p-6 border border-t-0 border-gray-200">
<Editor
value={code}
onValueChange={(value) => setCode(value)}
highlight={(value) => highlight(value, languages.js, 'yaml')}
preClassName="font-mono whitespace-normal font-light"
textareaClassName="font-mono overflow-auto font-light"
className="font-mono text-sm font-light"
/>
</div>
</div>
</div>
<div>
<div className="bg-gray-100 overflow-hidden rounded-lg">
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
<span className="text-gray-600 text-lg leading-6 font-medium">Current Configuration</span>
</div>
<div className="px-4 py-5 sm:p-6 overflow-x-auto">
<pre className="font-mono text-sm font-light">
{`luck: except
natural: still
near: though
search:
- feature
- - 1980732354.689713
- hour
- butter:
ordinary: 995901949.8974948
teeth: true
whole:
- -952367353
- - talk: -1773961379
temperature: false
oxygen: true
laugh:
flag:
in: 2144751662
hospital: -1544066384.1973226
law: congress
great: stomach`}
</pre>
</div>
</div>
</div>
</div>
<div className="flex justify-end mt-10">
<button
type="button"
className="mr-3 inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Cancel
</button>
<button
type="button"
className="mr-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Verify
</button>
<button
type="button"
className="inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Save changes
</button>
</div>
</>
);
};

View file

@ -0,0 +1,97 @@
import React from 'react';
import { Link, RouteComponentProps, RouterProps } from '@reach/router';
import { ChevronRightIcon } from '@heroicons/react/solid';
import { XCircleIcon } from '@heroicons/react/outline';
import { Tabs } from 'src/components';
import { GeneralTab } from './GeneralTab';
import { Secrets } from './Secrets';
import { Advanced } from './Advanced';
type AppSingleProps = RouteComponentProps & RouterProps;
const pages = [
{ name: 'Apps', to: '/apps', current: true },
{ name: 'Nextcloud', to: '', current: false },
];
const tabs = [
{ name: 'General', component: <GeneralTab /> },
{ name: 'Secrets & Passwords', component: <Secrets /> },
{ name: 'Advanced Configuration', component: <Advanced /> },
];
export const AppSingle: React.FC<AppSingleProps> = () => {
return (
<>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow">
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div className="flex items-center">
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
<span>Dashboard</span>
</Link>
</div>
</li>
{pages.map((page) => (
<li key={page.name}>
<div className="flex items-center">
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
<Link
to={page.to}
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
aria-current={page.current ? 'page' : undefined}
>
{page.name}
</Link>
</div>
</li>
))}
</ol>
</nav>
</div>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow rounded-sm mb-5">
<div className="px-4 py-5 sm:p-6 flex justify-between items-center">
<div className="mr-4 flex items-center">
<img
className="h-24 w-24 rounded-md overflow-hidden mr-4"
src="./../assets/nextcloud.svg"
alt="Nextcloud"
/>
<div>
<h2 className="text-2xl leading-8 font-bold">Nextcloud</h2>
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
</div>
</div>
<div className="flex flex-col">
<button
type="button"
className="mb-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-yellow-900 bg-yellow-300 hover:bg-yellow-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
>
<XCircleIcon className="-ml-0.5 mr-2 h-4 w-4 text-yellow-900" aria-hidden="true" />
Disable App
</button>
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
>
View Documentation
</button>
</div>
</div>
</div>
<div className="bg-white shadow rounded-sm">
<div className="px-4 py-5 sm:p-6">
<Tabs tabs={tabs} />
</div>
</div>
</div>
</>
);
};

218
src/modules/apps/Apps.tsx Normal file
View file

@ -0,0 +1,218 @@
import React, { useState, useCallback } from 'react';
import { RouteComponentProps, navigate, Link } from '@reach/router';
import { ChevronRightIcon, SearchIcon, PlusIcon } from '@heroicons/react/solid';
import { CogIcon, TrashIcon } from '@heroicons/react/outline';
import { ConfirmationModal } from 'src/components/Modal';
import { Table } from 'src/components';
type AppsProps = RouteComponentProps;
const pages = [{ name: 'Apps', href: '#', current: true }];
export const Apps: React.FC<AppsProps> = () => {
const [selectedRowsIds, setSelectedRowsIds] = useState({});
const [deleteModal, setDeleteModal] = useState(false);
const deleteModalOpen = () => setDeleteModal(true);
const deleteModalClose = () => setDeleteModal(false);
const columns: any = React.useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
Cell: (e: any) => {
return (
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<img className="h-10 w-10 rounded-md overflow-hidden" src={e.cell.row.original.assetSrc} alt="" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{e.cell.row.original.name}</div>
</div>
</div>
);
},
width: 'auto',
},
{
Header: 'Status',
accessor: 'status',
Cell: (e: any) => {
return (
<div className="flex items-center">
<div className="flex-shrink-0 h-4 w-4 rounded-full bg-green-600" />
<div className="ml-2 text-sm text-green-600">{e.cell.row.original.status}</div>
</div>
);
},
width: 'auto',
},
{
Header: ' ',
Cell: (e: any) => {
return (
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => navigate(`/apps/${e.cell.row.original.id}`)}
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Configure
</button>
</div>
);
},
width: 'auto',
},
],
[],
);
const data: any[] = React.useMemo(
() => [
{
id: 1,
name: 'Nextcloud',
status: 'Active for everyone',
assetSrc: './assets/nextcloud.svg',
},
{
id: 2,
name: 'Wekan',
status: 'Active for everyone',
assetSrc: './assets/wekan.svg',
},
{
id: 3,
name: 'Rocketchat',
status: 'Active for everyone',
assetSrc: './assets/rocketchat.svg',
},
{
id: 4,
name: 'Wordpress',
status: 'Active for everyone',
assetSrc: './assets/wordpress.svg',
},
],
[],
);
const selectedRows = useCallback((rows: Record<string, boolean>) => {
setSelectedRowsIds(rows);
}, []);
return (
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow">
<nav className="flex mb-8" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div className="flex items-center">
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
<span>Dashboard</span>
</Link>
</div>
</li>
{pages.map((page) => (
<li key={page.name}>
<div className="flex items-center">
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
<a
href={page.href}
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
aria-current={page.current ? 'page' : undefined}
>
{page.name}
</a>
</div>
</li>
))}
</ol>
</nav>
<div className="pb-5 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
<h1 className="text-3xl leading-6 font-bold text-gray-900">Apps</h1>
<div className="mt-3 sm:mt-0 sm:ml-4">
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
>
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Add new app
</button>
</div>
</div>
<div className="flex justify-between w-100 my-3 items-center">
<div>
<div className="mr-3 inline-block">
<label htmlFor="location" className="block text-sm font-medium text-gray-700 sr-only">
Location
</label>
<select
id="location"
name="location"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-200 focus:outline-none focus:ring-primary-dark focus:border-primary-dark sm:text-sm rounded-md"
defaultValue="All apps"
>
<option>All apps</option>
<option>Owner</option>
<option>Admins</option>
<option>Members</option>
</select>
</div>
<div className="mb-5 inline-block">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 sr-only">
Search candidates
</label>
<div className="mt-1 flex rounded-md shadow-sm">
<div className="relative flex items-stretch flex-grow focus-within:z-10">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="email"
id="email"
className="focus:ring-primary-dark focus:border-primary-dark block w-full rounded-md pl-10 sm:text-sm border-gray-200"
placeholder="Search Apps"
/>
</div>
</div>
</div>
</div>
{selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && (
<button
onClick={deleteModalOpen}
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"
>
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Delete
</button>
)}
</div>
<div className="flex flex-col">
<div className="-my-2 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="shadow border-b border-gray-200 sm:rounded-lg">
<Table data={data} columns={columns} getSelectedRowIds={selectedRows} selectable />
</div>
</div>
</div>
</div>
<ConfirmationModal
open={deleteModal}
onClose={deleteModalClose}
title="Delete service"
body="Are you sure you want to delete this service? All of your data will be permanently removed. This action cannot be undone."
/>
</div>
);
};

View file

@ -0,0 +1,99 @@
import React, { Fragment, useRef } from 'react';
import { Dialog, Transition } from '@headlessui/react';
type ChangeSecretModalProps = {
open: boolean;
onClose: () => void;
onSave?: () => void;
};
export const ChangeSecretModal = ({ open, onClose, onSave = () => {} }: ChangeSecretModalProps) => {
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="mt-3 text-center sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
Change secret
</Dialog.Title>
<div className="my-4">
<p className="text-sm text-gray-500">
<span className="font-bold text-red-500">CAUTION:</span> changing this might cause the app to
break.
</p>
</div>
<div className="mt-2">
<label htmlFor="appSecret" className="block text-sm font-medium text-gray-700">
App Secret
</label>
<div className="mt-1">
<input
type="text"
name="appSecret"
id="appSecret"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="Enter New App Secret"
/>
</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="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={onSave}
>
Save Changes
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-200 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-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={onClose}
ref={cancelButtonRef}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View file

@ -0,0 +1,161 @@
import React, { useState } from 'react';
import { RadioGroup } from '@headlessui/react';
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
const settings = [
{ name: 'Public access', description: 'This project would be available to anyone who has the link' },
{ name: 'Private to Project Members', description: 'Only members of this project would be able to access' },
{ name: 'Private to you', description: 'You are the only one able to access this project' },
];
export const GeneralTab = () => {
const [selected, setSelected] = useState(settings[0]);
return (
<div className="py-4">
<div>
<div className="md:grid md:grid-cols-3 md:gap-6">
<div className="md:col-span-1">
<h3 className="text-lg font-medium leading-6 text-gray-900">Privacy</h3>
<p className="mt-1 text-sm text-gray-500">Change your app privacy</p>
</div>
<div className="mt-5 md:mt-0 md:col-span-2">
<div className="mt-1">
<RadioGroup value={selected} onChange={setSelected}>
<RadioGroup.Label className="sr-only">Privacy setting</RadioGroup.Label>
<div className="bg-white rounded-md -space-y-px">
{settings.map((setting, settingIdx) => (
<RadioGroup.Option
key={setting.name}
value={setting}
className={({ checked }) =>
classNames(
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
settingIdx === settings.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
checked ? 'bg-primary-50 border-primary-400 z-10' : 'border-gray-200',
'relative border p-4 flex cursor-pointer focus:outline-none',
)
}
>
{({ active, checked }) => (
<>
<span
className={classNames(
checked ? 'bg-primary-600 border-transparent' : 'bg-white border-gray-300',
active ? 'ring-2 ring-offset-2 ring-primary-100' : '',
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center',
)}
aria-hidden="true"
>
<span className="rounded-full bg-white w-1.5 h-1.5" />
</span>
<div className="ml-3 flex flex-col">
<RadioGroup.Label
as="span"
className={classNames(
checked ? 'text-primary-900' : 'text-gray-900',
'block text-sm font-medium',
)}
>
{setting.name}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={classNames(checked ? 'text-primary-900' : 'text-gray-500', 'block text-sm')}
>
{setting.description}
</RadioGroup.Description>
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
</div>
</div>
</div>
<div className="mt-10">
<div className="md:grid md:grid-cols-3 md:gap-6">
<div className="md:col-span-1">
<h3 className="text-lg font-medium leading-6 text-gray-900">Notifications</h3>
<p className="mt-1 text-sm text-gray-500">Change you notifications settings</p>
</div>
<div className="mt-5 md:mt-0 md:col-span-2">
<fieldset className="space-y-5 -mt-4">
<legend className="sr-only">Notifications</legend>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="comments"
aria-describedby="comments-description"
name="comments"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="comments" className="font-medium text-gray-700">
Comments
</label>
<p id="comments-description" className="text-gray-500">
Get notified when someones posts a comment on a posting.
</p>
</div>
</div>
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="candidates"
aria-describedby="candidates-description"
name="candidates"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="candidates" className="font-medium text-gray-700">
Candidates
</label>
<p id="candidates-description" className="text-gray-500">
Get notified when a candidate applies for a job.
</p>
</div>
</div>
</div>
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="offers"
aria-describedby="offers-description"
name="offers"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="offers" className="font-medium text-gray-700">
Offers
</label>
<p id="offers-description" className="text-gray-500">
Get notified when a candidate accepts or rejects an offer.
</p>
</div>
</div>
</div>
</fieldset>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,134 @@
import React, { useState } from 'react';
import { ClipboardIcon, KeyIcon } from '@heroicons/react/outline';
import { showToast } from 'src/common/util/show-toast';
import { Table } from 'src/components';
import { ChangeSecretModal } from './ChangeSecretModal';
export const Secrets = () => {
const [secretModal, setSecretModal] = useState(false);
const secretModalOpen = () => setSecretModal(true);
const secretModalClose = () => setSecretModal(false);
const secretsModalSave = () => {
showToast('The secret has been saved successfully.');
setSecretModal(false);
};
const columns: any = React.useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
width: '25%',
},
{
Header: 'Secret',
accessor: 'secret',
Cell: (e: any) => {
const [reveal, setReveal] = useState(false);
return (
<div className="flex items-center">
{!reveal ? (
<>
<div className="w-48 bg-gray-200 h-4 rounded-full mr-4" />
<button
onClick={() => setReveal(true)}
type="button"
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded text-gray-700 hover:bg-gray-200 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Reveal secret
</button>
</>
) : (
<div>{e.cell.row.original.secret}</div>
)}
</div>
);
},
},
{
Header: ' ',
Cell: () => {
return (
<div className="flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => showToast('The secret has been copied to your clipboard.')}
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 mr-3"
>
<ClipboardIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Copy Secret
</button>
<button
onClick={secretModalOpen}
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<KeyIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Change secret
</button>
</div>
);
},
},
],
[],
);
const data: any[] = React.useMemo(
() => [
{
id: 1,
name: 'nextcloud_mariadb_password',
secret: 'xEtC2dvUsBVzoTA19fOyDF4IwpPSWW62I92F59s69EO9vp56',
},
{
id: 2,
name: 'nextcloud_mariadb_root_password',
secret: `OB.IHD:HPY{x<)>@+B'p(LUU@k-Wv=w(!@)?7Zq%@nE7GY]!`,
},
{
id: 3,
name: 'nextcloud_password (admin login)',
secret: `#B&>4A;O#XEF]_dE*zxF26>8fF:akgGL8gi#yALK{ZY[P$eE`,
},
{
id: 4,
name: 'onlyoffice_jwt_secret',
secret: `U),xxEbt*g~t[4:Jl6RU8Tih#3ExinoV|K?.gL/R%?;gzK)R`,
},
{
id: 5,
name: 'onlyoffice_postgresql_password',
secret: `U),xxEbt*g~t[4:Jl6RU8Tih#3ExinoV|K?.gL/R%?;gzK)R`,
},
{
id: 6,
name: 'onlyoffice_rabbitmq_password',
secret: `r7sFEUL&U(a;3yR;CaC7P,bPwUEKZOT=IyjJq%AWniY!ncP[`,
},
],
[],
);
return (
<>
<div className="py-4">
<div className="flex flex-col">
<div className="-my-2 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="border border-gray-200">
<Table data={data} columns={columns} />
</div>
</div>
</div>
</div>
</div>
<ChangeSecretModal open={secretModal} onClose={secretModalClose} onSave={secretsModalSave} />
</>
);
};

View file

@ -0,0 +1,23 @@
export const initialEditorYaml = () => {
return `luck: except
natural: still
near: though
search:
- feature
- - 1980732354.689713
- hour
- butter:
ordinary: 995901949.8974948
teeth: true
whole:
- -952367353
- - talk: -1773961379
temperature: false
oxygen: true
laugh:
flag:
in: 2144751662
hospital: -1544066384.1973226
law: congress
great: stomach`;
};

View file

@ -0,0 +1,2 @@
export { Apps } from './Apps';
export { AppSingle } from './AppSingle';

View file

@ -0,0 +1,85 @@
import React from 'react';
import { Link, navigate, RouteComponentProps } from '@reach/router';
import clsx from 'clsx';
import { DASHBOARD_APPS, DASHBOARD_QUICK_ACCESS } from './consts';
export const Dashboard: React.FC<RouteComponentProps> = () => {
return (
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow">
<nav className="flex mb-8" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div className="flex items-center">
<a href="#" className="text-sm font-medium text-gray-500 hover:text-gray-700">
<span>Dashboard</span>
</a>
</div>
</li>
</ol>
</nav>
<div className="pb-5 sm:flex sm:items-center sm:justify-between">
<h1 className="text-3xl leading-6 font-bold text-gray-900">Dashboard</h1>
</div>
<div className="grid grid-cols-3 gap-4 my-10">
{DASHBOARD_APPS.map((app) => {
return (
<div className="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-100" key={app.name}>
<div className="px-4 py-5 sm:p-6">
<div className="mr-4 flex items-center">
<img className="h-16 w-16 rounded-md overflow-hidden mr-4" src={app.assetSrc} alt="Nextcloud" />
<div>
<h2 className="text-2xl leading-8 font-bold">{app.name}</h2>
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
</div>
</div>
</div>
<div className="px-4 py-4 sm:px-6 flex justify-end">
<button
type="button"
className="mr-3 inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
>
View Documentation
</button>
<button
onClick={() => navigate(`/apps/${app.id}`)}
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Configure
</button>
</div>
</div>
);
})}
</div>
<div className="max-w-4xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow">
<div className="pb-4 border-b border-gray-200 sm:flex sm:items-center">
<h3 className="text-lg leading-6 font-medium text-gray-900">Quick access</h3>
</div>
<dl className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2">
{DASHBOARD_QUICK_ACCESS.map((item) => (
<Link
to={item.to}
key={item.name}
className={clsx('bg-white rounded-lg overflow-hidden sm:p-2 flex items-center group', {
'opacity-40 cursor-default': !item.active,
})}
>
<div className="w-16 h-16 flex items-center justify-center bg-primary-100 group-hover:bg-primary-200 transition-colors rounded-lg mr-4">
<item.icon className="h-6 w-6 text-primary-900" aria-hidden="true" />
</div>
<div>
<dt className="truncate text-sm leading-5 font-medium">{item.name}</dt>
<dd className="mt-1 text-gray-500 text-sm leading-5 font-normal">{item.description}</dd>
</div>
</Link>
))}
</dl>
</div>
</div>
);
};

View file

@ -0,0 +1,100 @@
import {
ChartBarIcon,
CloudIcon,
UsersIcon,
CogIcon,
SwitchHorizontalIcon,
ClockIcon,
ShieldCheckIcon,
InformationCircleIcon,
} from '@heroicons/react/outline';
export const DASHBOARD_APPS = [
{
id: 1,
name: 'Nextcloud',
assetSrc: '/assets/nextcloud.svg',
},
{
id: 2,
name: 'Wekan',
assetSrc: '/assets/wekan.svg',
},
{
id: 3,
name: 'Rocketchat',
assetSrc: '/assets/rocketchat.svg',
},
{
id: 4,
name: 'Wordpress',
assetSrc: '/assets/wordpress.svg',
},
];
export const DASHBOARD_QUICK_ACCESS = [
{
id: 1,
name: 'Users →',
to: '/users',
description: 'Manage users and their permissions',
icon: UsersIcon,
active: true,
},
{
id: 2,
name: 'Apps →',
to: '/apps',
description: 'Stay on top of your deadlines, or dont — its up to you',
icon: CloudIcon,
active: true,
},
{
id: 3,
name: 'Monitoring →',
to: '/dashboard',
description: 'Monitor your system with Grafana',
icon: ChartBarIcon,
active: false,
},
{
id: 4,
name: 'Data Migration →',
to: '/dashboard',
description: 'Import your data from other platforms and services',
icon: SwitchHorizontalIcon,
active: false,
},
{
id: 5,
name: 'Account Settings →',
to: '/dashboard',
description: 'Manage your organisations profile and preferences',
icon: CogIcon,
active: false,
},
{
id: 6,
name: 'Backup & Restore →',
to: '/dashboard',
description: 'Backup or restore your data',
icon: ClockIcon,
active: false,
},
{
id: 7,
name: 'Security →',
to: '/dashboard',
description: 'Configure security settings, view alerts and analytics',
icon: ShieldCheckIcon,
active: false,
},
{
id: 8,
name: 'Support →',
to: '/dashboard',
description: 'Access documentation and forum',
icon: InformationCircleIcon,
active: false,
},
];

View file

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

4
src/modules/index.ts Normal file
View file

@ -0,0 +1,4 @@
export { Login } from './login';
export { Dashboard } from './dashboard';
export { Apps, AppSingle } from './apps';
export { Users } from './users';

134
src/modules/login/Login.tsx Normal file
View file

@ -0,0 +1,134 @@
import React, { useEffect } from 'react';
import { navigate, RouteComponentProps } from '@reach/router';
import * as yup from 'yup';
import clsx from 'clsx';
import { Controller, useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { LockClosedIcon } from '@heroicons/react/solid';
import { useAuth } from 'src/services/auth';
import { isValid } from 'src/services/api';
import { VALIDATION_MESSAGES } from 'src/common/const';
import { showToast, ToastType } from 'src/common/util/show-toast';
const validationSchema = yup.object({
email: yup.string().required(VALIDATION_MESSAGES.required('Email')),
password: yup.string().required(VALIDATION_MESSAGES.required('Password')),
});
type LoginProps = RouteComponentProps;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function Login(_: LoginProps) {
const { auth, logIn } = useAuth();
const { handleSubmit, formState, control } = useForm<{
email: string;
password: string;
remember: boolean;
}>({
resolver: yupResolver(validationSchema),
mode: 'all',
defaultValues: { email: process.env.REACT_APP_USERNAME, password: process.env.REACT_APP_PASSWORD },
});
const onSubmit = handleSubmit(async (values) => {
const res = await logIn(values.email, values.password);
// @ts-ignore
if (res.ok) {
navigate('/dashboard');
showToast('Logged in!');
} else {
showToast('Username or password incorrect', ToastType.Error);
}
});
useEffect(() => {
if (isValid(auth)) {
navigate('/dashboard');
}
}, [auth]);
if (isValid(auth)) {
return null;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="flex justify-center">
<img className="hidden lg:block" src="assets/logo.svg" alt="Stackspin" />
<h2 className="mt-6 text-center text-xl font-bold text-gray-900 sr-only">Sign in</h2>
</div>
<form className="mt-8 space-y-6" action="#" onSubmit={onSubmit} noValidate>
<input type="hidden" name="remember" defaultValue="true" />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<Controller
control={control}
name="email"
defaultValue=""
render={({ field }) => {
return (
<>
<label htmlFor="email-address" className="sr-only">
Username
</label>
<input
{...field}
id="email-address"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-dark focus:border-primary-dark focus:z-10 sm:text-sm"
placeholder="Username"
/>
</>
);
}}
/>
</div>
<div>
<Controller
control={control}
name="password"
defaultValue=""
render={({ field }) => {
return (
<>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
{...field}
id="password"
type="password"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-dark focus:border-primary-dark focus:z-10 sm:text-sm"
placeholder="Password"
/>
</>
);
}}
/>
</div>
</div>
<div>
<button
type="submit"
className={clsx(
'group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-dark hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
{
'is-loading': formState.isSubmitting,
},
)}
disabled={formState.isSubmitting || !formState.isValid}
>
<span className="absolute left-0 inset-y-0 flex items-center pl-3">
<LockClosedIcon className="h-5 w-5 text-white group-hover:text-primary-light" aria-hidden="true" />
</span>
Sign in
</button>
</div>
</form>
</div>
</div>
);
}

View file

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

211
src/modules/users/Users.tsx Normal file
View file

@ -0,0 +1,211 @@
import React, { useState, useCallback, useEffect } from 'react';
import { RouteComponentProps, Link } from '@reach/router';
import { ChevronRightIcon, SearchIcon, PlusIcon } from '@heroicons/react/solid';
import { CogIcon, KeyIcon, TrashIcon } from '@heroicons/react/outline';
import { useUsers } from 'src/services/users';
import { Table } from 'src/components';
import { ConfirmationModal } from 'src/components/Modal';
import { UserModal } from './components/UserModal';
const pages = [{ name: 'Users', href: '#', current: true }];
export const Users: React.FC<RouteComponentProps> = () => {
const [selectedRowsIds, setSelectedRowsIds] = useState({});
const [deleteModal, setDeleteModal] = useState(false);
const [configureModal, setConfigureModal] = useState(false);
const { loadUsers, users } = useUsers();
// TODO: Check why it needs any
const allUsers: any = users.items;
useEffect(() => {
const asyncFc = async () => {
try {
await loadUsers();
} catch {
// continue
}
};
asyncFc();
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []);
const deleteModalOpen = () => setDeleteModal(true);
const deleteModalClose = () => setDeleteModal(false);
const configureModalOpen = () => setConfigureModal(true);
const configureModalClose = () => setConfigureModal(false);
const columns: any = React.useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
width: 'auto',
},
{
Header: 'Email',
accessor: 'email',
width: 'auto',
},
{
Header: 'Status',
accessor: 'status',
width: 'auto',
},
{
Header: 'Last Sign-In',
accessor: 'last_login',
width: 'auto',
},
{
Header: ' ',
Cell: () => {
return (
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={configureModalOpen}
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Configure
</button>
</div>
);
},
width: 'auto',
},
],
[],
);
const selectedRows = useCallback((rows: Record<string, boolean>) => {
setSelectedRowsIds(rows);
}, []);
return (
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow">
<nav className="flex mb-8" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div className="flex items-center">
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
<span>Dashboard</span>
</Link>
</div>
</li>
{pages.map((page) => (
<li key={page.name}>
<div className="flex items-center">
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
<a
href={page.href}
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
aria-current={page.current ? 'page' : undefined}
>
{page.name}
</a>
</div>
</li>
))}
</ol>
</nav>
<div className="pb-5 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>
<div className="mt-3 sm:mt-0 sm:ml-4">
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
>
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Add new user
</button>
</div>
</div>
<div className="flex justify-between w-100 my-3 items-center">
<div>
<div className="mr-3 inline-block shadow-sm">
<label htmlFor="location" className="block text-sm font-medium text-gray-700 sr-only">
Location
</label>
<select
id="location"
name="location"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-200 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
defaultValue="All users"
>
<option>All users</option>
<option>Owner</option>
<option>Admins</option>
<option>Members</option>
</select>
</div>
<div className="mb-5 inline-block">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 sr-only">
Search candidates
</label>
<div className="mt-1 flex rounded-md shadow-sm">
<div className="relative flex items-stretch flex-grow focus-within:z-10">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="email"
id="email"
className="focus:ring-primary-500 focus:border-primary-500 block w-full rounded-md pl-10 sm:text-sm border-gray-200"
placeholder="Search Users"
/>
</div>
</div>
</div>
</div>
{selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && (
<div className="flex items-center">
<button
type="button"
className="mr-3 inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<KeyIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Edit app access
</button>
<button
onClick={deleteModalOpen}
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"
>
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Delete
</button>
</div>
)}
</div>
<div className="flex flex-col">
<div className="-my-2 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="shadow border-b border-gray-200 sm:rounded-lg">
<Table data={allUsers} columns={columns} getSelectedRowIds={selectedRows} selectable />
</div>
</div>
</div>
</div>
<ConfirmationModal
open={deleteModal}
onClose={deleteModalClose}
title="Delete user"
body="Are you sure you want to delete this user? All of your data will be permanently removed. This action cannot be undone."
/>
<UserModal open={configureModal} onClose={configureModalClose} onSave={configureModalClose} />
</div>
);
};

View file

@ -0,0 +1,85 @@
import React, { Fragment, useRef } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { Tabs } from 'src/components';
import { UserDetailsTab } from './components/UserDetailsTab';
import { AdvancedTab } from './components/AdvancedTab';
import { AppAccessTab } from './components/AppAccess';
type UserModalProps = {
open: boolean;
onClose: () => void;
onSave?: () => void;
};
const tabs = [
{ name: 'User Details', component: <UserDetailsTab /> },
{ name: 'Advanced', component: <AdvancedTab /> },
{ name: 'App Access', component: <AppAccessTab /> },
];
export const UserModal = ({ open, onClose, onSave = () => {} }: UserModalProps) => {
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-2xl sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<Tabs tabs={tabs} />
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={onSave}
>
Save Changes
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-200 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-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={onClose}
ref={cancelButtonRef}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View file

@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { InformationCircleIcon } from '@heroicons/react/solid';
import { Switch } from '@headlessui/react';
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
export const AdvancedTab = () => {
const [enabled, setEnabled] = useState(false);
return (
<div>
<div className="rounded-md py-2 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon className="h-5 w-5 text-blue-500" aria-hidden="true" />
</div>
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm text-gray-900">You can give or revoke app access for this user</p>
</div>
</div>
</div>
<div className="mb-6">
<Switch.Group as="div" className="flex items-center justify-between">
<span className="flex-grow flex flex-col">
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
User
</Switch.Label>
<Switch.Description as="span" className="text-sm text-gray-500">
Gives this user access only to assigned apps.
</Switch.Description>
</span>
<Switch
checked={enabled}
onChange={setEnabled}
className={classNames(
enabled ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
)}
>
<span
aria-hidden="true"
className={classNames(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</Switch>
</Switch.Group>
</div>
<div className="mb-6">
<Switch.Group as="div" className="flex items-center justify-between">
<span className="flex-grow flex flex-col">
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
Admin
</Switch.Label>
<Switch.Description as="span" className="text-sm text-gray-500">
Gives this user access to all apps and permission to change other users roles
</Switch.Description>
</span>
<Switch
checked={enabled}
onChange={setEnabled}
className={classNames(
enabled ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
)}
>
<span
aria-hidden="true"
className={classNames(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</Switch>
</Switch.Group>
</div>
<div className="mb-6">
<Switch.Group as="div" className="flex items-center justify-between">
<span className="flex-grow flex flex-col">
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
Super Admin
</Switch.Label>
<Switch.Description as="span" className="text-sm text-gray-500">
Will transfer all Super Admin rights to this user
</Switch.Description>
</span>
<Switch
checked={enabled}
onChange={setEnabled}
className={classNames(
enabled ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
)}
>
<span
aria-hidden="true"
className={classNames(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</Switch>
</Switch.Group>
</div>
</div>
);
};

View file

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { Switch } from '@headlessui/react';
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
export const AppAccessTab = () => {
const [enabled, setEnabled] = useState(false);
return (
<div>
<div className="mb-6">
<Switch.Group as="div" className="flex items-center justify-between">
<div className="flex-grow flex items-center">
<div className="flex-shrink-0 h-10 w-10 mr-4">
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/wekan.svg" alt="" />
</div>
<div className="flex flex-col">
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
Wekan
</Switch.Label>
</div>
</div>
<Switch
checked={enabled}
onChange={setEnabled}
className={classNames(
enabled ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
)}
>
<span
aria-hidden="true"
className={classNames(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</Switch>
</Switch.Group>
</div>
<div className="mb-6">
<Switch.Group as="div" className="flex items-center justify-between">
<div className="flex-grow flex items-center">
<div className="flex-shrink-0 h-10 w-10 mr-4">
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/wordpress.svg" alt="" />
</div>
<div className="flex flex-col">
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
Wordpress
</Switch.Label>
</div>
</div>
<Switch
checked={enabled}
onChange={setEnabled}
className={classNames(
enabled ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
)}
>
<span
aria-hidden="true"
className={classNames(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</Switch>
</Switch.Group>
</div>
<div className="mb-6">
<Switch.Group as="div" className="flex items-center justify-between">
<div className="flex-grow flex items-center">
<div className="flex-shrink-0 h-10 w-10 mr-4">
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/nextcloud.svg" alt="" />
</div>
<div className="flex flex-col">
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
Nextcloud
</Switch.Label>
</div>
</div>
<Switch
checked={enabled}
onChange={setEnabled}
className={classNames(
enabled ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
)}
>
<span
aria-hidden="true"
className={classNames(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</Switch>
</Switch.Group>
</div>
<div className="mb-6">
<Switch.Group as="div" className="flex items-center justify-between">
<div className="flex-grow flex items-center">
<div className="flex-shrink-0 h-10 w-10 mr-4">
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/rocketchat.svg" alt="" />
</div>
<div className="flex flex-col">
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
Rocketchat
</Switch.Label>
</div>
</div>
<Switch
checked={enabled}
onChange={setEnabled}
className={classNames(
enabled ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
)}
>
<span
aria-hidden="true"
className={classNames(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</Switch>
</Switch.Group>
</div>
</div>
);
};

View file

@ -0,0 +1,90 @@
import React from 'react';
export const UserDetailsTab = () => {
return (
<div className="space-y-8 divide-y divide-gray-200">
<div>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Personal Information</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">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
autoComplete="given-name"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<div className="mt-1">
<input
type="email"
name="email"
id="email"
autoComplete="family-name"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700">
Current Password
</label>
<div className="mt-1">
<input
type="password"
name="currentPassword"
id="currentPassword"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<div className="mt-1">
<input
type="password"
name="confirmPassword"
id="confirmPassword"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div className="sm:col-span-3">
<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>
</div>
</div>
);
};

View file

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

View file

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

View file

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

1
src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

2
src/redux/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { configureStore } from './store';
export * from './types';

35
src/redux/store.ts Normal file
View file

@ -0,0 +1,35 @@
import { createStore, compose, applyMiddleware, combineReducers } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { reducer as authReducer } from 'src/services/auth';
import { reducer as usersReducer } from 'src/services/users';
import { State } from './types';
const persistConfig = {
key: 'root',
storage,
whitelist: ['auth'],
};
const appReducer = combineReducers<State>({
auth: authReducer,
users: usersReducer,
});
const persistedReducer = persistReducer(persistConfig, appReducer);
const middlewares = [thunkMiddleware];
// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
export const configureStore = () => {
const store = createStore(persistedReducer, composeEnhancers(applyMiddleware(...middlewares)));
const persistor = persistStore(store);
return { store, persistor };
};

11
src/redux/types.ts Normal file
View file

@ -0,0 +1,11 @@
import { Store } from 'redux';
import { AuthState } from 'src/services/auth/redux';
import { UsersState } from 'src/services/users/redux';
export interface AppStore extends Store, State {}
export interface State {
auth: AuthState;
users: UsersState;
}

View file

@ -0,0 +1,42 @@
import _ from 'lodash';
import axios from 'axios';
import { api } from './config';
import { ApiConfig } from './types';
/**
* Function which creates an API call for given api config
*/
export const createApiCall = (apiConfig: ApiConfig) => async () => {
const hostname = apiConfig.hostname || api.hostname;
const { path } = apiConfig;
const method = _.get(apiConfig, 'method', 'GET');
const contentType =
_.includes(['POST', 'PUT', 'PATCH'], method) && !apiConfig.formData
? {
'Content-Type': 'application/json',
}
: {};
const passedHeaders = _.get(apiConfig, 'headers', {});
const headers = {
...contentType,
...passedHeaders,
};
const axiosConfig = {
method,
headers,
url: `${hostname}${path}`,
timeout: 30000,
data: apiConfig.formData || apiConfig.body,
};
return axios(axiosConfig);
};
/**
* Performs an API call for given api config and returns a promise
*/
export const performApiCall = (apiConfig: ApiConfig) => {
const apiCall = createApiCall(apiConfig);
return apiCall();
};

View file

@ -0,0 +1,3 @@
export const api = {
hostname: process.env.REACT_APP_API_URL,
};

View file

@ -0,0 +1,5 @@
export * from './redux';
export { api } from './config';
export { createApiCall, performApiCall } from './apiCall';

View file

@ -0,0 +1,186 @@
import _ from 'lodash';
import urlcat from 'urlcat';
import { createApiCall } from '../apiCall';
import { StartAction, SuccessAction, FailureAction } from './types';
import { ApiConfig } from '../types';
/**
* Function which creates a thunk action for given api config and associated action types
* @param {string|Record<string, unknown>} apiConfig if string it is interprated as path in api endpoint
* @param {string[]} actionTypes action types for start, success and failure
*/
export const createApiAction = (apiConfig: ApiConfig, actionTypes: string[]) => async (dispatch: any) => {
const apiCall = createApiCall(apiConfig);
const startAction = (): StartAction => ({
type: actionTypes[0],
payload: null,
});
const successAction = (payload: any): SuccessAction => ({
type: actionTypes[1],
payload,
});
const failureAction = (error: string): FailureAction => ({
type: actionTypes[2],
payload: { error },
});
dispatch(startAction());
try {
const response = await apiCall();
let res;
const additionalData = apiConfig.additionalData || {};
if (!_.isEmpty(additionalData)) {
res = await dispatch(successAction({ ...response.data, ...additionalData }));
} else {
res = await dispatch(successAction(response.data));
}
return { ok: true, res };
} catch (e) {
const error = _.get(e, 'response.data', {
errorMessage: e.message || 'Undefined error, please try again.',
});
await dispatch(failureAction(error));
return {
ok: false,
errorMessage: error.message,
status: e?.response?.status,
};
}
};
/**
* Creates opinionated CRUD actions
*/
export function createCrudApiActions<T>(
basePath: string,
startActionType: string,
failureActionType: string,
fetchActionType: string,
addActionType: string,
updateActionType: string,
deleteActionType: string,
transformItemForApi: (data: T) => any,
pathTemplates: { load?: string; add?: string; change?: string } = {
load: '/',
add: '/',
change: '/:id',
},
): any {
const { load, add, change } = pathTemplates;
const fetchItems = (
pageNumber?: number,
pageSize?: number,
params: Record<string, unknown> = {},
template: string = load!,
) =>
createApiAction(
{
path: urlcat(basePath, template, { pageNumber, pageSize, ...params }),
method: 'GET',
},
[startActionType, fetchActionType, failureActionType],
);
const addItem = (item: T, params: Record<string, unknown> = {}) =>
createApiAction(
{
path: urlcat(basePath, add!, { ...params }),
method: 'POST',
body: transformItemForApi(item),
},
[startActionType, addActionType, failureActionType],
);
const updateItem = (item: T, params: Record<string, unknown> = {}) =>
createApiAction(
{
// @ts-ignore
path: urlcat(basePath, change!, { id: item.id, ...params }),
method: 'PUT',
body: transformItemForApi(item),
},
[startActionType, updateActionType, failureActionType],
);
const deleteItem = (itemId: number, params: Record<string, unknown> = {}) =>
createApiAction(
{
path: urlcat(basePath, change!, { id: itemId, ...params }),
method: 'DELETE',
},
[startActionType, deleteActionType, failureActionType],
);
return [fetchItems, addItem, updateItem, deleteItem];
}
/**
* Creates opinionated CRUD actions
*/
export function createCrudApiActionsWithoutPaging<T extends { id?: number | string | null }>(
basePath: string,
startActionType: string,
failureActionType: string,
fetchActionType: string,
addActionType: string,
updateActionType: string,
deleteActionType: string,
transformItemForApi: (data: T) => any,
pathTemplates: { load?: string; add?: string; change?: string } = {
load: '/',
add: '/',
change: '/:id',
},
): any {
const { load, add, change } = pathTemplates;
const fetchItems = (params: Record<string, unknown> = {}, template: string = load!) =>
createApiAction(
{
path: urlcat(basePath, template, params),
method: 'GET',
},
[startActionType, fetchActionType, failureActionType],
);
const addItem = (item: T, params: Record<string, unknown> = {}) =>
createApiAction(
{
path: urlcat(basePath, add!, { ...params }),
method: 'POST',
body: transformItemForApi(item),
},
[startActionType, addActionType, failureActionType],
);
const updateItem = (item: T, params: Record<string, unknown> = {}) =>
createApiAction(
{
path: urlcat(basePath, change!, { id: item.id, ...params }),
method: 'PUT',
body: transformItemForApi(item),
},
[startActionType, updateActionType, failureActionType],
);
const deleteItem = (itemId: number, params: Record<string, unknown> = {}) =>
createApiAction(
{
path: urlcat(basePath, change!, { id: itemId, ...params }),
method: 'DELETE',
},
[startActionType, deleteActionType, failureActionType],
);
return [fetchItems, addItem, updateItem, deleteItem];
}

View file

@ -0,0 +1,14 @@
export {
createApiReducer,
createCrudApiReducer,
createCrudApiReducerWithoutPaging,
chainReducers,
INITIAL_API_STATUS,
INITIAL_API_STATE,
} from './reducers';
export { createApiAction, createCrudApiActions, createCrudApiActionsWithoutPaging } from './actions';
export { isEmpty, isLoading, isValid, isError, getError, getErrorMessage } from './traits';
export * from './types';

View file

@ -0,0 +1,176 @@
import { StartAction, SuccessAction, FailureAction, ApiStatus, ApiState } from './types';
export const INITIAL_API_STATUS: ApiStatus = {
isEmpty: true,
isLoading: false,
isValid: false,
isError: false,
dateTime: Date.now(),
error: null,
errorMessage: null,
};
export const INITIAL_API_STATE: ApiState = {
_status: INITIAL_API_STATUS,
items: [],
};
type AllActions = StartAction | SuccessAction | FailureAction;
/**
* Use this function to create any reducer which handles api communication
* @param {Array} actionTypes Array of action types associated with fetchThunk e.g. USER_REQUEST,
* USER_SUCCESS and USER_ERROR
* @param {function} dataAdapter function which will be called to transform response body
* to format you want to use in state
* @param {function} errorAdapter function which will be called to transform error
* to format you want to use in state
* @returns {function} reducer handles api requests, saves response body as data and status
*/
export const createApiReducer =
(
actionTypes: string[],
dataAdapter: (data: any, state?: any) => any = (data: any) => data,
errorAdapter = (data: any) => data,
) =>
(state = INITIAL_API_STATE, action: AllActions) => {
const [REQUEST, SUCCESS, ERROR] = actionTypes;
switch (action.type) {
case REQUEST:
return {
...state,
_status: {
...state._status,
isLoading: true,
dateTime: Date.now(),
},
};
case SUCCESS:
return {
_status: {
isEmpty: false,
isLoading: false,
isValid: true,
isError: false,
dateTime: Date.now(),
error: null,
errorMessage: null,
},
...dataAdapter(action.payload, state),
};
case ERROR:
return {
...state,
_status: {
isEmpty: false,
isLoading: false,
isValid: false,
isError: true,
error: action.payload,
errorMessage: errorAdapter(action.payload),
},
};
default:
return state;
}
};
/**
* This is higher order reducer
* Use this if you want your state is combination of results of multiple reducers
* under the same key, e.g. you can get the same data from multiple endpoints
*/
export const chainReducers =
(initialState: any, ...args: any[]) =>
(state = initialState, action: any) =>
args.reduce((newState, reducer) => reducer(newState, action), state);
/**
* Creates an opinionated CRUD reducer
*/
export function createCrudApiReducer<T>(
startActionType: string,
failureActionType: string,
fetchActionType: string,
addActionType: string,
updateActionType: string,
deleteActionType: string,
transformItem: (data: any) => T,
keyField = 'id',
) {
const fetchReducer = createApiReducer(
[startActionType, fetchActionType, failureActionType],
(data) => ({ _meta: data.meta, items: (data.data || data).map(transformItem) }),
(data) => data,
);
const addReducer = createApiReducer(
[startActionType, addActionType, failureActionType],
(data, state) => ({ items: [...state.items, transformItem(data)] }),
(data) => data,
);
const updateReducer = createApiReducer(
[startActionType, updateActionType, failureActionType],
(data, state) => ({
items: state.items.map((i: any) => (i[keyField] === data[keyField] ? transformItem(data) : i)),
}),
(data) => data,
);
const deleteReducer = createApiReducer(
[startActionType, deleteActionType, failureActionType],
(data, state) => ({
items: state.items.filter((i: any) => i[keyField] !== data[keyField]),
}),
(data) => data,
);
return chainReducers(INITIAL_API_STATE, fetchReducer, addReducer, updateReducer, deleteReducer);
}
/**
* Creates an opinionated CRUD reducer without paging
*/
export function createCrudApiReducerWithoutPaging<T>(
startActionType: string,
failureActionType: string,
fetchActionType: string,
addActionType: string,
updateActionType: string,
deleteActionType: string,
transformItem: (data: any) => T,
keyField = 'id',
) {
const fetchReducer = createApiReducer(
[startActionType, fetchActionType, failureActionType],
(data) => ({ items: data.map(transformItem) }),
(data) => data,
);
const addReducer = createApiReducer(
[startActionType, addActionType, failureActionType],
(data, state) => ({ items: [...state.items, transformItem(data)] }),
(data) => data,
);
const updateReducer = createApiReducer(
[startActionType, updateActionType, failureActionType],
(data, state) => ({
items: state.items.map((i: any) => (i[keyField] === data[keyField] ? transformItem(data) : i)),
}),
(data) => data,
);
const deleteReducer = createApiReducer(
[startActionType, deleteActionType, failureActionType],
(data, state) => ({
items: state.items.filter((i: any) => i[keyField] !== data[keyField]),
}),
(data) => data,
);
return chainReducers(INITIAL_API_STATE, fetchReducer, addReducer, updateReducer, deleteReducer);
}

View file

@ -0,0 +1,29 @@
import _ from 'lodash';
export const isEmpty = (data: any): boolean => {
return _.get(data, '_status.isEmpty', true);
};
export const isLoading = (data: any): boolean => {
return _.get(data, '_status.isLoading', false);
};
export const isValid = (data: any): boolean => {
return _.get(data, '_status.isValid', false);
};
export const isError = (data: any): boolean => {
return _.get(data, '_status.isError', false);
};
export const getError = (data: any): any => {
return _.get(data, '_status.error', null);
};
export const getErrorMessage = (data: any): string | null => {
return _.get(data, '_status.errorMessage', null);
};
export const getTotalPages = (data: any): number | undefined => {
return _.get(data, '_meta.totalPages', undefined);
};

View file

@ -0,0 +1,29 @@
export interface StartAction {
type: string;
payload: any;
}
export interface SuccessAction {
type: string;
payload: any;
}
export interface FailureAction {
type: string;
payload: { error: string };
}
export interface ApiStatus {
isEmpty: boolean;
isLoading: boolean;
isValid: boolean;
isError: boolean;
dateTime: number;
error: any;
errorMessage: string | null;
}
export interface ApiState<T = any> {
_status: ApiStatus;
items: T[];
}

29
src/services/api/types.ts Normal file
View file

@ -0,0 +1,29 @@
import { Method } from 'axios';
export interface ApiConfig {
hostname?: string;
path: string;
method?: Method;
headers?: any;
formData?: any;
body?: any;
additionalData?: any;
}
export interface SimpleSnackBarConfig {
serviceName: any;
}
export interface SnackBarConfigWithMessages {
getMessage: {
success(response: any): string;
error(error: any): string;
};
}
export type SnackBarConfig = SnackBarConfigWithMessages | SimpleSnackBarConfig;
export enum SortDirection {
Ascending = 'asc',
Descending = 'desc',
}

42
src/services/auth/api.ts Normal file
View file

@ -0,0 +1,42 @@
import { performApiCall } from 'src/services/api';
export const sendPasswordResetLink = async (email: string): Promise<boolean> => {
try {
await performApiCall({
path: `/auth/forgot-password`,
method: 'POST',
body: { email },
});
return true;
} catch {
return false;
}
};
export const resetPassword = async (email: string, code: string, password: string): Promise<boolean> => {
try {
await performApiCall({
path: `/auth/reset-password`,
method: 'POST',
body: { email, code, password },
});
return true;
} catch {
return false;
}
};
export const verifyEmail = async (email: string, activationCode: string): Promise<boolean> => {
try {
await performApiCall({
path: `/auth/activation`,
method: 'POST',
body: { email, activationCode },
});
return true;
} catch {
return false;
}
};

View file

@ -0,0 +1 @@
export { useAuth } from './use-auth';

View file

@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getAuth, signIn, signOut, register as apiRegister, refreshUser as apiRefreshUser } from '../redux';
export function useAuth() {
const dispatch = useDispatch();
const auth = useSelector(getAuth);
const logIn = useCallback(
(email: string, password: string) => {
return dispatch(signIn(email, password));
},
[dispatch],
);
const register = useCallback(
(email: string, password: string, firstName: string, lastName: string) => {
return dispatch(apiRegister(email, password, firstName, lastName));
},
[dispatch],
);
const logOut = useCallback(() => {
return dispatch(signOut());
}, [dispatch]);
const refreshUser = useCallback(() => {
return dispatch(apiRefreshUser());
}, [dispatch]);
return {
auth,
logIn,
logOut,
register,
refreshUser,
};
}

View file

@ -0,0 +1,3 @@
export { useAuth } from './hooks';
export { getAuth, reducer, signIn, signOut, register, AuthActionTypes, getIsAuthLoading } from './redux';
export * from './types';

View file

@ -0,0 +1,56 @@
import { createApiAction } from 'src/services/api';
import { SuccessAction } from 'src/services/api/redux/types';
export enum AuthActionTypes {
SIGN_IN_START = 'auth/sign_in_start',
SIGN_IN_SUCCESS = 'auth/sign_in_success',
SIGN_IN_FAILURE = 'auth/sign_in_failure',
SIGN_OUT = 'auth/SIGN_OUT',
REGISTRATION_START = 'auth/registration_start',
REGISTRATION_FAILURE = 'auth/registration_failure',
}
const signOutAction = (): SuccessAction => ({
type: AuthActionTypes.SIGN_OUT,
payload: null,
});
export const signIn = (email: string, password: string) =>
createApiAction(
{
path: '/login',
method: 'POST',
body: { username: email, password },
},
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
);
export const refreshUser = () =>
createApiAction(
{
path: '/dashboard',
method: 'GET',
},
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
);
export function signOut() {
return (dispatch: any) => {
dispatch(signOutAction());
};
}
export const register = (email: string, password: string, firstName: string, lastName: string) =>
createApiAction(
{
path: '/auth/register',
method: 'POST',
body: {
email,
firstName,
lastName,
password,
},
},
[AuthActionTypes.REGISTRATION_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.REGISTRATION_FAILURE],
);

View file

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

View file

@ -0,0 +1,23 @@
import { createApiReducer, chainReducers, INITIAL_API_STATUS, INITIAL_API_STATE } from 'src/services/api';
import { AuthState } from './types';
import { AuthActionTypes } from './actions';
const initialState: AuthState = {
token: null,
_status: INITIAL_API_STATUS,
};
const auth = createApiReducer(
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
(data) => ({ token: data.access_token }),
(data) => data.error.message,
);
const signOut = createApiReducer(
['', AuthActionTypes.SIGN_OUT, ''],
() => INITIAL_API_STATE,
() => INITIAL_API_STATE,
);
export default chainReducers(initialState, auth, signOut);

View file

@ -0,0 +1,9 @@
import { State } from 'src/redux';
import { isLoading } from 'src/services/api';
export const getAuth = (state: State) => state.auth;
export const getIsAuthLoading = (state: State) => isLoading(getAuth(state));
export const getToken = (state: State) => state.auth.token;

View file

@ -0,0 +1,7 @@
import { ApiStatus } from 'src/services/api/redux/types';
import { Auth } from '../types';
export interface AuthState extends Auth {
_status: ApiStatus;
}

View file

@ -0,0 +1,3 @@
export interface Auth {
token: string | null;
}

11
src/services/users/api.ts Normal file
View file

@ -0,0 +1,11 @@
import { performApiCall } from 'src/services/api';
import { User } from './types';
import { transformUser } from './transformations';
export const fetchMemberDetails = async (memberId: number): Promise<User> => {
const res = await performApiCall({
path: `/members/${memberId}`,
});
return transformUser(res.data);
};

View file

@ -0,0 +1 @@
export { useUsers } from './use-users';

View file

@ -0,0 +1,16 @@
import { useDispatch, useSelector } from 'react-redux';
import { getUsers, fetchUsers } from '../redux';
export function useUsers() {
const dispatch = useDispatch();
const users = useSelector(getUsers);
function loadUsers() {
return dispatch(fetchUsers());
}
return {
users,
loadUsers,
};
}

View file

@ -0,0 +1,7 @@
export * from './types';
export { reducer, fetchCurrentUser, getCurrentUser } from './redux';
export { useUsers } from './hooks';
export { fetchMemberDetails } from './api';

View file

@ -0,0 +1,64 @@
import { createApiAction, createCrudApiActions } from 'src/services/api';
import { transformUserForApi } from '../transformations';
import { User } from '../types';
import { CurrentUserUpdateAPI } from './types';
export enum UserActionTypes {
CHANGE_CURRENT_USER_START = 'users/fetch_current_user_start',
CHANGE_CURRENT_USER_FAILURE = 'users/fetch_current_user_failure',
FETCH_CURRENT_USER_SUCCESS = 'users/fetch_current_user_success',
UPDATE_CURRENT_USER_SUCCESS = 'users/update_current_user_success',
CHANGE_USERS_START = 'users/change_users_start',
CHANGE_USERS_FAILURE = 'users/change_users_failure',
FETCH_USERS_SUCCESS = 'users/fetch_users_success',
ADD_ACCOUNT_USER_SUCCESS = 'users/add_account_user_success',
UPDATE_ACCOUNT_USER_SUCCESS = 'users/update_account_user_success',
DELETE_ACCOUNT_USER_SUCCESS = 'users/delete_account_user_success',
CHANGE_USER_ROLES_START = 'users/change_user_roles_start',
CHANGE_USER_ROLES_FAILURE = 'users/change_user_roles_failure',
FETCH_USER_ROLES_SUCCESS = 'users/fetch_user_roles_success',
ADD_USER_ROLE_SUCCESS = 'users/add_user_role_success',
UPDATE_USER_ROLE_SUCCESS = 'users/update_user_role_success',
DELETE_USER_ROLE_SUCCESS = 'users/delete_user_role_success',
}
export const fetchCurrentUser = () =>
createApiAction(
{
path: '/users/me',
method: 'GET',
},
[
UserActionTypes.CHANGE_CURRENT_USER_START,
UserActionTypes.FETCH_CURRENT_USER_SUCCESS,
UserActionTypes.CHANGE_CURRENT_USER_FAILURE,
],
);
export const updateCurrentUser = (data: CurrentUserUpdateAPI) =>
createApiAction(
{
path: '/users/me',
method: 'PATCH',
body: data,
},
[
UserActionTypes.CHANGE_CURRENT_USER_START,
UserActionTypes.UPDATE_CURRENT_USER_SUCCESS,
UserActionTypes.CHANGE_CURRENT_USER_FAILURE,
],
);
export const [fetchUsers, addUser, updateUser, deleteUser] = createCrudApiActions<User>(
'/users',
UserActionTypes.CHANGE_USERS_START,
UserActionTypes.CHANGE_USERS_FAILURE,
UserActionTypes.FETCH_USERS_SUCCESS,
UserActionTypes.ADD_ACCOUNT_USER_SUCCESS,
UserActionTypes.UPDATE_ACCOUNT_USER_SUCCESS,
UserActionTypes.DELETE_ACCOUNT_USER_SUCCESS,
transformUserForApi,
);

View file

@ -0,0 +1,4 @@
export * from './actions';
export { default as reducer } from './reducers';
export { getCurrentUser, getUsers } from './selectors';
export * from './types';

View file

@ -0,0 +1,61 @@
import { combineReducers } from 'redux';
import { createApiReducer, chainReducers, INITIAL_API_STATE, createCrudApiReducer } from 'src/services/api';
import { AuthActionTypes } from 'src/services/auth/redux/actions';
import { transformUser } from '../transformations';
import { User } from '../types';
import { UserActionTypes } from './actions';
const signInReducer = createApiReducer(
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
transformUser,
(data) => data,
);
const fetchCurrentUserReducer = createApiReducer(
[
UserActionTypes.CHANGE_CURRENT_USER_START,
UserActionTypes.FETCH_CURRENT_USER_SUCCESS,
UserActionTypes.CHANGE_CURRENT_USER_FAILURE,
],
transformUser,
(data) => data,
);
const updateCurrentUserReducer = createApiReducer(
[
UserActionTypes.CHANGE_CURRENT_USER_START,
UserActionTypes.UPDATE_CURRENT_USER_SUCCESS,
UserActionTypes.CHANGE_CURRENT_USER_FAILURE,
],
transformUser,
(data) => data,
);
const signOutReducer = createApiReducer(
['', AuthActionTypes.SIGN_OUT, ''],
() => INITIAL_API_STATE,
() => INITIAL_API_STATE,
);
const usersReducer = createCrudApiReducer<User>(
UserActionTypes.CHANGE_USERS_START,
UserActionTypes.CHANGE_USERS_FAILURE,
UserActionTypes.FETCH_USERS_SUCCESS,
UserActionTypes.ADD_ACCOUNT_USER_SUCCESS,
UserActionTypes.UPDATE_ACCOUNT_USER_SUCCESS,
UserActionTypes.DELETE_ACCOUNT_USER_SUCCESS,
transformUser,
);
export default combineReducers({
currentUser: chainReducers(
INITIAL_API_STATE,
signInReducer,
fetchCurrentUserReducer,
updateCurrentUserReducer,
signOutReducer,
),
users: usersReducer,
});

View file

@ -0,0 +1,4 @@
import { State } from 'src/redux';
export const getCurrentUser = (state: State) => state.users.currentUser;
export const getUsers = (state: State) => state.users.users;

View file

@ -0,0 +1,22 @@
import { ApiState, ApiStatus } from 'src/services/api/redux';
import { User } from '../types';
export interface CurrentUserState extends User {
_status: ApiStatus;
}
export interface UsersState {
currentUser: CurrentUserState;
users: ApiState<User>;
}
export interface CurrentUserUpdateAPI {
phoneNumber?: string;
email?: string;
language?: string;
country?: string;
firstName?: string;
lastName?: string;
password?: string;
}

View file

@ -0,0 +1,23 @@
import _ from 'lodash';
import { FormUser, User, UserApiRequest } from './types';
export const transformUserForApi = (user: FormUser): UserApiRequest => ({
id: user.id,
email: user.email,
name: user.name,
status: user.status,
last_login: user.last_login,
});
export const transformUser = (response: any): User => {
const userResponse = _.get(response, 'user', response);
return {
id: userResponse.id,
email: userResponse.email,
name: userResponse.name,
last_login: userResponse.last_login,
status: userResponse.status,
};
};

View file

@ -0,0 +1,26 @@
export interface User {
id: number | null;
email: string;
name: string;
last_login: string;
status: string;
}
export interface FormUser extends User {
password?: string;
confirmPassword?: string;
}
export interface UserRole {
id?: number;
name: string;
isAdministrator: boolean;
}
export interface UserApiRequest {
id: number | null;
email: string;
name: string;
last_login: string;
status: string;
}