Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • xeruf/dashboard
  • stackspin/dashboard
2 results
Show changes
Showing
with 504 additions and 182 deletions
:root {
--colour-primary-50: #F2FFFF;
--colour-primary-100: #D6FDFF;
--colour-primary-200: #B6F7FB;
--colour-primary-300: #7AE5EA;
--colour-primary-400: #55C6CC;
--colour-primary-500: #39A9B1;
--colour-primary-600: #24929C;
--colour-primary-700: #157983;
--colour-primary-800: #135D66;
--colour-primary-900: #0F4F57;
--colour-primary-950: #0A353A;
--colour-primary-light: #54C6CC;
--colour-primary-default: #54C6CC;
--colour-primary-dark: #1E8290;
}
......@@ -4,7 +4,7 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { useAuth } from 'src/services/auth';
import { Dashboard, Users, Login, Apps, AppSingle } from './modules';
import { Dashboard, Users, Login, Apps, AppSingle, ResourcesDashboard } from './modules';
import { Layout } from './components';
import { LoginCallback } from './modules/login/LoginCallback';
......@@ -27,6 +27,7 @@ function App() {
<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" />
<link rel="stylesheet" href="/style.css" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
</Helmet>
......@@ -41,7 +42,7 @@ function App() {
) : (
<Layout>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard isAdmin={isAdmin} />} />
<Route path="/users" element={<ProtectedRoute />}>
<Route index element={<Users />} />
</Route>
......@@ -49,6 +50,9 @@ function App() {
<Route path=":slug" element={<AppSingle />} />
<Route index element={<Apps />} />
</Route>
<Route path="/resources" element={<ProtectedRoute />}>
<Route index element={<ResourcesDashboard />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
</Layout>
......
import React, { Fragment, useEffect, useState } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react';
import { MenuIcon, XIcon } from '@heroicons/react/outline';
import { MenuIcon, XIcon, ExclamationIcon } from '@heroicons/react/outline';
import { performApiCall } from 'src/services/api';
import { useAuth } from 'src/services/auth';
import { useApps } from 'src/services/apps';
import md5 from 'md5';
import Gravatar from 'react-gravatar';
import { Link, useLocation } from 'react-router-dom';
import clsx from 'clsx';
......@@ -15,6 +16,7 @@ const navigation = [
{ name: 'Dashboard', to: '/dashboard', requiresAdmin: false },
{ name: 'Users', to: '/users', requiresAdmin: true },
{ name: 'Apps', to: '/apps', requiresAdmin: true },
{ name: 'System resources', to: '/resources', requiresAdmin: true },
];
function classNames(...classes: any[]) {
......@@ -32,11 +34,13 @@ function filterNavigationByDashboardRole(isAdmin: boolean) {
export interface Environment {
HYDRA_PUBLIC_URL: string;
KRATOS_PUBLIC_URL: string;
TELEPRESENCE: boolean;
}
const defaultEnvironment: Environment = {
HYDRA_PUBLIC_URL: 'error-failed-to-load-env-from-backend',
KRATOS_PUBLIC_URL: 'error-failed-to-load-env-from-backend',
TELEPRESENCE: false,
};
// eslint-disable-next-line @typescript-eslint/no-empty-interface
......@@ -89,13 +93,23 @@ const Header: React.FC<HeaderProps> = () => {
const kratosSettingsUrl = `${environment.KRATOS_PUBLIC_URL}/self-service/settings/browser`;
const adminTag = isAdmin ? (
<span className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-inset ring-gray-300">
<span className="inline-flex items-center rounded-md bg-primary-100 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-inset ring-gray-300">
Admin
</span>
) : null;
// banner that only shows if telepresence is active
const underConstruction = environment.TELEPRESENCE ? (
<div className="shadow bg-pink-500 h-6 text-xs text-white font-semibold uppercase flex justify-center items-center gap-2">
<ExclamationIcon className="h-4" />
<span className="h-4 m-0 p-0">Development environment</span>
<ExclamationIcon className="h-4" />
</div>
) : null;
return (
<>
{underConstruction}
<Disclosure as="nav" className="bg-white shadow relative z-10">
{({ open }) => (
<div className="relative">
......@@ -114,8 +128,8 @@ const Header: React.FC<HeaderProps> = () => {
</div>
<div className="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
<Link to="/" className="flex-shrink-0 flex items-center">
<img className="block lg:hidden" src="/assets/logo-small.svg" alt="Stackspin" />
<img className="hidden lg:block" src="/assets/logo.svg" alt="Stackspin" />
<img className="stackspin-logo-small block lg:hidden" src="/icons/logo-small.svg" alt="Stackspin" />
<img className="stackspin-logo hidden lg:block" src="/icons/logo.svg" alt="Stackspin" />
</Link>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{/* Current: "border-primary-500 text-gray-900", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" */}
......@@ -124,9 +138,9 @@ const Header: React.FC<HeaderProps> = () => {
key={item.name}
to={item.to}
className={clsx(
'border-primary-50 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium',
'border-primary-50 hover:border-gray-300 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium',
{
'border-primary-500 text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium':
'border-primary-500 hover:border-primary-500 text-gray-500 inline-flex items-center px-1 pt-1 text-sm font-medium':
pathname.includes(item.to),
},
)}
......@@ -145,7 +159,12 @@ const Header: React.FC<HeaderProps> = () => {
<Menu.Button className="bg-white rounded-full flex text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
<span className="sr-only">Open user menu</span>
<span className="inline-flex items-center justify-center h-8 w-8 rounded-full bg-gray-500 overflow-hidden">
<Gravatar email={currentUser?.email || undefined} size={32} rating="pg" protocol="https://" />
<Gravatar
md5={md5(currentUser?.email ?? 'no-user') || undefined}
size={32}
rating="pg"
protocol="https://"
/>
</span>
</Menu.Button>
</div>
......
......@@ -48,7 +48,7 @@ export const InfoModal = ({ open, onClose, title, body, dynamicData }: InfoModal
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="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-xl sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
......
......@@ -23,7 +23,24 @@ export const Modal: React.FC<ModalProps> = ({
return (
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="fixed z-10 inset-0 overflow-y-auto" initialFocus={cancelButtonRef} onClose={() => {}}>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{isLoading && (
<Dialog.Overlay className="isloading inset-0 bg-gray-400 z-20 bg-opacity-75 transition-opacity absolute flex justify-center items-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Dialog.Overlay>
)}
<div className="pt-4 h-full px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
......@@ -37,9 +54,9 @@ export const Modal: React.FC<ModalProps> = ({
</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">
{/* <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
</span> */}
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
......@@ -49,72 +66,56 @@ export const Modal: React.FC<ModalProps> = ({
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 relative">
{isLoading && (
<Dialog.Overlay className="inset-0 bg-gray-400 bg-opacity-75 transition-opacity absolute flex justify-center items-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Dialog.Overlay>
)}
{!useCancelButton && (
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center">
<img className="rounded-md" width={32} src={img} alt={title} />
<span className="ml-2 uppercase font-bold">{title}</span>
</div>
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-gray-200 p-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}
>
<XIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</button>
</div>
)}
<div className="bg-white px-4 p-6">{children}</div>
{onSave && (
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex">
{leftActions}
<div className="ml-auto sm:flex sm:flex-row-reverse">
<div className="w-full h-full flex justify-center items-center">
<div className="bg-white h-4/5 overflow-auto flex flex-col rounded-lg text-left shadow-xl transform transition-all sm:max-w-2xl sm:w-full">
{!useCancelButton && (
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center">
<img className="rounded-md" width={32} src={img} alt={title} />
<span className="ml-2 uppercase font-bold">{title}</span>
</div>
<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 ${
saveButtonDisabled ? 'opacity-50' : ''
}`}
onClick={onSave}
ref={saveButtonRef}
disabled={saveButtonDisabled}
className="w-full inline-flex justify-center rounded-md border border-gray-200 p-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}
>
{saveButtonTitle}
<XIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</button>
{useCancelButton && (
</div>
)}
<div className="bg-white px-4 p-6 relative overflow-y-auto">{children}</div>
{onSave && (
<div className="bg-gray-50 px-4 py-5 flex flex-shrink-0 w-full border-0 border-t-2 border-gray-300">
<div className="mr-auto sm:flex gap-2">{leftActions}</div>
<div className="ml-auto sm:flex sm:flex-row-reverse">
<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}
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 ${
saveButtonDisabled ? 'opacity-50' : ''
}`}
onClick={onSave}
ref={saveButtonRef}
disabled={saveButtonDisabled}
>
{cancelButtonTitle}
{saveButtonTitle}
</button>
)}
{useCancelButton && (
<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}
>
{cancelButtonTitle}
</button>
)}
</div>
</div>
</div>
)}
)}
</div>
</div>
</Transition.Child>
</div>
......
......@@ -11,37 +11,37 @@
export const appAccessList = [
{
name: 'hedgedoc',
image: '/assets/hedgedoc.svg',
image: '/icons/hedgedoc.svg',
label: 'HedgeDoc',
documentationUrl: 'https://docs.hedgedoc.org/',
},
{
name: 'wekan',
image: '/assets/wekan.svg',
image: '/icons/wekan.svg',
label: 'Wekan',
documentationUrl: 'https://github.com/wekan/wekan/wiki',
},
{
name: 'wordpress',
image: '/assets/wordpress.svg',
image: '/icons/wordpress.svg',
label: 'Wordpress',
documentationUrl: 'https://wordpress.org/support/',
},
{
name: 'nextcloud',
image: '/assets/nextcloud.svg',
image: '/icons/nextcloud.svg',
label: 'Nextcloud',
documentationUrl: 'https://docs.nextcloud.com/server/latest/user_manual/en/',
},
{
name: 'zulip',
image: '/assets/zulip.svg',
image: '/icons/zulip.svg',
label: 'Zulip',
documentationUrl: 'https://docs.zulip.com/help/',
},
{
name: 'monitoring',
image: '/assets/monitoring.svg',
image: '/icons/monitoring.svg',
label: 'Monitoring',
documentationUrl: 'https://grafana.com/docs/',
},
......@@ -50,47 +50,8 @@ export const appAccessList = [
export const allAppAccessList = [
{
name: 'dashboard',
image: '/assets/logo-small.svg',
image: '/icons/logo-small.svg',
label: 'Dashboard',
},
...appAccessList,
];
// export const initialAppRoles = [
// {
// name: 'dashboard',
// role: UserRole.User,
// },
// {
// name: 'hedgedoc',
// role: UserRole.User,
// },
// {
// name: 'wekan',
// role: UserRole.User,
// },
// {
// name: 'wordpress',
// role: UserRole.User,
// },
// {
// name: 'nextcloud',
// role: UserRole.User,
// },
// {
// name: 'zulip',
// role: UserRole.User,
// },
// {
// name: 'monitoring',
// role: UserRole.NoAccess,
// },
// ];
// export const initialUserForm = {
// id: '',
// name: '',
// email: '',
// app_roles: initialAppRoles,
// status: '',
// };
import React, { useCallback, useState, useRef } from 'react';
import ReactTags from 'react-tag-autocomplete'; // https://www.npmjs.com/package/react-tag-autocomplete
// This tagging component is a bare bones Proof of Concept and needs tons more work
// import { User } from 'src/services/users/types';
// import { suggestions } from './country-list';
type Tag = {
id: string;
name: string;
};
// const tags: Tag[] = [];
export const Tags = () => {
const [tags, setTags] = useState<Tag[]>([]);
const [suggestions, setSuggestions] = useState([
{ id: 'Apples', name: 'Apples' },
{ id: 'Pears', name: 'Pears' },
{ id: 'Bananas', name: 'Bananas' },
{ id: 'Mangos', name: 'Mangos' },
{ id: 'Lemons', name: 'Lemons' },
{ id: 'Apricots', name: 'Apricots' },
]);
const reactTags = useRef(null);
const onDelete = useCallback(
(tagIndex) => {
setTags(tags.filter((_, i) => i !== tagIndex));
// console.log(tagIndex);
// console.log(tags[tagIndex]);
setSuggestions(suggestions.filter((s) => s.name !== tags[tagIndex].name));
},
[tags, suggestions],
);
const onAddition = useCallback(
(newTag) => {
setTags([...tags, { id: newTag.name, name: newTag.name }]);
if (suggestions.find((s) => s.name === newTag.name)) {
// console.log(`We already have ${newTag.name}, not adding`);
} else {
setSuggestions([...suggestions, { id: newTag.name, name: newTag.name }]);
}
},
[tags, suggestions],
);
return (
<>
<ReactTags
ref={reactTags}
tags={tags}
suggestions={suggestions}
onDelete={onDelete}
onAddition={onAddition}
allowNew
classNames={{
root: 'react-tags',
rootFocused: 'is-focused',
selected: 'react-tags__selected',
selectedTag:
'react-tags__selected-tag inline-flex items-center gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10',
selectedTagName: 'react-tags__selected-tag-name',
search: 'react-tags__search my-6',
// searchWrapper: 'react-tags__search-wrapper',
searchInput:
'react-tags__search-input block w-full rounded-md border-0 py-1.5 mx-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6',
suggestions: 'react-tags__suggestions',
suggestionActive: 'is-active',
suggestionDisabled: 'is-disabled',
// suggestionPrefix: 'react-tags__suggestion-prefix',
}}
/>
{suggestions.map((suggestion) => {
return (
<span
key={suggestion.id}
className="inline-flex items-center gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10"
>
{suggestion.name}
<button type="button" className="group relative -mr-1 h-3.5 w-3.5 rounded-sm hover:bg-gray-500/20" />
</span>
);
})}
{/*
{console.log(suggestions)}
{console.log(tags)} */}
</>
);
};
export { Tags } from './Tags';
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import { TrashIcon } from '@heroicons/react/outline';
import { TrashIcon, KeyIcon, QrcodeIcon } from '@heroicons/react/outline';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { Banner, Modal, ConfirmationModal, InfoModal } from 'src/components';
import { Input, Select } from 'src/components/Form';
......@@ -8,6 +8,9 @@ import { User, UserRole, useUsers } from 'src/services/users';
import { useAuth } from 'src/services/auth';
import { AppStatusEnum } from 'src/services/apps/types';
// Tagging component, work in progress. commented out for the time being
// import { Tags } from 'src/components/Tags';
import { HIDDEN_APPS } from 'src/modules/dashboard/consts';
// import { initialUserForm } from './consts';
......@@ -16,6 +19,8 @@ import { UserModalProps } from './types';
export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalProps) => {
const [deleteModal, setDeleteModal] = useState(false);
const [passwordLinkModal, setPasswordLinkModal] = useState(false);
const [totpModal, setTotpModal] = useState(false);
const [webAuthnModal, setWebAuthnModal] = useState(false);
const [isAdminRoleSelected, setAdminRoleSelected] = useState(true);
const [isPersonalModal, setPersonalModal] = useState(false);
const {
......@@ -30,6 +35,8 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
deleteUserById,
getRecoveryLinkUserById,
clearSelectedUser,
resetTotp,
resetWebAuthn,
} = useUsers();
const { currentUser, isAdmin } = useAuth();
......@@ -92,9 +99,15 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
// };
}, [user, reset, open]);
let dashboardIndex = 0;
apps.forEach((app, index) => {
if (app.name === 'dashboard') {
dashboardIndex = index;
}
});
const dashboardRole = useWatch({
control,
name: 'app_roles.0.role',
name: `app_roles.${dashboardIndex}.role`,
});
useEffect(() => {
......@@ -126,18 +139,18 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
setUserId(null);
};
const handleKeyPress = (e: any) => {
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
handleSave();
}
};
const handleClose = () => {
onClose();
clearSelectedUser();
setUserId(null);
};
const handleKeyPress = (e: any) => {
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
handleSave();
}
};
const deleteModalOpen = () => setDeleteModal(true);
const deleteModalClose = () => setDeleteModal(false);
......@@ -150,6 +163,28 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
const passwordLinkModalClose = () => setPasswordLinkModal(false);
const totpModalOpen = () => {
if (userId) {
resetTotp(userId);
clearSelectedUser();
setUserId(userId);
}
setTotpModal(true);
};
const totpModalClose = () => setTotpModal(false);
const webAuthnModalOpen = () => {
if (userId) {
resetWebAuthn(userId);
clearSelectedUser();
setUserId(userId);
}
setWebAuthnModal(true);
};
const webAuthnModalClose = () => setWebAuthnModal(false);
const handleDelete = () => {
if (userId) {
deleteUserById(userId);
......@@ -171,8 +206,8 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
type="button"
className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Delete
Delete user
<TrashIcon className="-mr-0.5 ml-2 h-4 w-4" aria-hidden="true" />
</button>
)
);
......@@ -187,9 +222,48 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
onClick={passwordLinkModalOpen}
type="button"
className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm
font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
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-700"
>
Password Link
<KeyIcon className="-mr-0.5 ml-2 h-4 w-4" aria-hidden="true" />
</button>
)
);
};
// Button to reset TOTP
const buttonTotp = () => {
return (
userId &&
isAdmin &&
user.totp && (
<button
onClick={totpModalOpen}
type="button"
className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 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-700"
>
Reset TOTP
<QrcodeIcon className="-mr-0.5 ml-2 h-4 w-4" aria-hidden="true" />
</button>
)
);
};
// Button to reset WebAuthn
const buttonWebAuthn = () => {
return (
userId &&
isAdmin &&
user.webauthn && (
<button
onClick={webAuthnModalOpen}
type="button"
className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 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-700"
>
Reset WebAuthn
<QrcodeIcon className="-mr-0.5 ml-2 h-4 w-4" aria-hidden="true" />
</button>
)
);
......@@ -197,24 +271,14 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
return (
<>
<Modal
onClose={handleClose}
open={open}
onSave={handleSave}
isLoading={userModalLoading}
leftActions={
<>
{buttonDelete()}
{buttonPasswordLink()}
</>
}
useCancelButton
>
<Modal onClose={handleClose} open={open} onSave={handleSave} isLoading={userModalLoading} useCancelButton>
<div className="bg-white px-4">
<div className="space-y-10 divide-y divide-gray-200">
<div>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">{userId ? 'Edit user' : 'Add new user'}</h3>
<div className="py-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{userId ? <span>Edit {user.email}</span> : 'Add new user'}
</h3>
</div>
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
......@@ -268,7 +332,7 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
</div>
{userId && (
<div className="sm:col-span-6">
<Input control={control} name="id" label="UUID" required={false} disabled />
<p className="text-gray-400 text-xs">User ID: {userId}</p>
</div>
)}
</>
......@@ -282,7 +346,7 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
</div>
{isAdminRoleSelected && (
<div className="sm:col-span-6">
<div className="sm:col-span-6 py-12">
<Banner
title="Admin users automatically have admin-level access to all apps."
titleSm="Admin user"
......@@ -335,6 +399,75 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
)}
</div>
)}
{/* This is the Tag section */}
{/* {isAdmin && !userModalLoading && (
<div>
<div className="mt-8">
<h3 className="text-lg leading-6 font-medium text-gray-900">Tags</h3>
</div>
<Tags />
</div>
)} */}
{isAdmin && userId && !userModalLoading && (
<div>
<div className="mt-8">
<h3 className="text-lg leading-6 font-medium text-gray-900">User Access</h3>
</div>
<div>
<div className="flow-root mt-6">
<ul className="-my-5 divide-y divide-gray-200">
<li className="py-4">
<div className="flex items-center justify-between">
<p className="leading-6 text-sm text-gray-500">
Generate password reset link for {user.email}
</p>
{buttonPasswordLink()}
</div>
</li>
<li className="py-4">
<div className="flex items-center justify-between">
<p className="leading-6 text-sm text-gray-500">Reset TOTP</p>
{user.totp ? (
<>{buttonTotp()}</>
) : (
<p
className="leading-6 text-sm text-gray-400 mb-4 sm:mb-0 inline-flex items-center px-4 py-2
font-medium rounded-md bg-gray-50"
>
No TOTP enrolled
</p>
)}
</div>
</li>
<li className="py-4">
<div className="flex items-center justify-between">
<p className="leading-6 text-sm text-gray-500">Reset WebAuthn</p>
{user.webauthn ? (
<>{buttonWebAuthn()}</>
) : (
<p
className="leading-6 text-sm text-gray-400 mb-4 sm:mb-0 inline-flex items-center px-4 py-2
font-medium rounded-md bg-gray-50"
>
No WebAuthn registered
</p>
)}
</div>
</li>
{user.email !== currentUser?.email && (
<li className="py-4">
<div className="flex items-center justify-between">
<p className="leading-6 text-sm text-gray-500">Delete {user.email}</p>
{buttonDelete()}
</div>
</li>
)}
</ul>
</div>
</div>
</div>
)}
</div>
</div>
</Modal>
......@@ -352,6 +485,20 @@ export const UserModal = ({ open, onClose, userId, setUserId, apps }: UserModalP
body="Below is a password reset link. Copy this link and send to the user to reset the password without using e-mail"
dynamicData={recoveryLink}
/>
<InfoModal
open={totpModal}
onClose={totpModalClose}
title="Reset TOTP"
body="You have successfully removed the user's TOTP registration."
dynamicData=""
/>
<InfoModal
open={webAuthnModal}
onClose={webAuthnModalClose}
title="Reset WebAuthn"
body="You have successfully removed the user's WebAuthn registration."
dynamicData=""
/>
</>
);
};
......@@ -11,37 +11,37 @@
export const appAccessList = [
{
name: 'hedgedoc',
image: '/assets/hedgedoc.svg',
image: '/icons/hedgedoc.svg',
label: 'HedgeDoc',
documentationUrl: 'https://docs.hedgedoc.org/',
},
{
name: 'wekan',
image: '/assets/wekan.svg',
image: '/icons/wekan.svg',
label: 'Wekan',
documentationUrl: 'https://github.com/wekan/wekan/wiki',
},
{
name: 'wordpress',
image: '/assets/wordpress.svg',
image: '/icons/wordpress.svg',
label: 'Wordpress',
documentationUrl: 'https://wordpress.org/support/',
},
{
name: 'nextcloud',
image: '/assets/nextcloud.svg',
image: '/icons/nextcloud.svg',
label: 'Nextcloud',
documentationUrl: 'https://docs.nextcloud.com/server/latest/user_manual/en/',
},
{
name: 'zulip',
image: '/assets/zulip.svg',
image: '/icons/zulip.svg',
label: 'Zulip',
documentationUrl: 'https://docs.zulip.com/help/',
},
{
name: 'monitoring',
image: '/assets/monitoring.svg',
image: '/icons/monitoring.svg',
label: 'Monitoring',
documentationUrl: 'https://grafana.com/docs/',
},
......@@ -50,7 +50,7 @@ export const appAccessList = [
export const allAppAccessList = [
{
name: 'dashboard',
image: '/assets/logo-small.svg',
image: '/icons/logo-small.svg',
label: 'Dashboard',
},
...appAccessList,
......
......@@ -103,10 +103,10 @@ export const AppSingle: React.FC = () => {
</div>
<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"
className="mb-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-amber-900 bg-amber-300 hover:bg-amber-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
onClick={() => setDisableAppModal(true)}
>
<XCircleIcon className="-ml-0.5 mr-2 h-4 w-4 text-yellow-900" aria-hidden="true" />
<XCircleIcon className="-ml-0.5 mr-2 h-4 w-4 text-amber-900" aria-hidden="true" />
Disable App
</button>
<button
......
......@@ -66,26 +66,17 @@ export const Apps: React.FC = () => {
const status = e.cell.row.original.status as AppStatusEnum;
return (
<div className="flex items-center">
{status === AppStatusEnum.External ? (
<div
className={`flex-shrink-0 h-4 w-4 rounded-full bg-transparent border-2 border-${getConstForStatus(
status,
'colorClass',
)}`}
/>
) : (
<div className={`flex-shrink-0 h-4 w-4 rounded-full bg-${getConstForStatus(status, 'colorClass')}`} />
)}
<div className={`flex-shrink-0 h-4 w-4 rounded-full ${getConstForStatus(status, 'colorClass')}`} />
{status === AppStatusEnum.Installing ? (
<div
className={`ml-2 cursor-pointer text-sm text-${getConstForStatus(status, 'colorClass')}`}
className={`ml-2 cursor-pointer text-sm text-gray-900`}
onClick={() => showToast('Installing an app can take up to 10 minutes.', ToastType.Success)}
>
{status}
</div>
) : (
<div className={`ml-2 text-sm text-${getConstForStatus(status, 'colorClass')}`}>{status}</div>
<div className={`ml-2 text-sm text-gray-900`}>{status}</div>
)}
</div>
);
......
......@@ -3,7 +3,7 @@ import _ from 'lodash';
import Editor from 'react-simple-code-editor';
// import { Menu, Transition } from '@headlessui/react';
// import { ChevronDownIcon } from '@heroicons/react/solid';
import yaml from 'js-yaml';
import { parse } from 'yaml';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-yaml';
......@@ -23,7 +23,7 @@ export const AdvancedTab = () => {
const isConfigurationValid = () => {
try {
yaml.load(code);
parse(code);
return true;
} catch (e: any) {
return false;
......
......@@ -28,25 +28,25 @@ search:
const tableConsts = [
{
status: AppStatusEnum.Installed,
colorClass: 'green-600',
colorClass: 'bg-primary-600',
buttonTitle: 'Configure',
buttonIcon: <CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />,
},
{
status: AppStatusEnum.NotInstalled,
colorClass: 'gray-600',
colorClass: 'bg-gray-200',
buttonTitle: 'Install',
buttonIcon: <PlusCircleIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />,
},
{
status: AppStatusEnum.Installing,
colorClass: 'primary-600',
colorClass: 'bg-primary-300',
buttonTitle: null,
buttonIcon: null,
},
{
status: AppStatusEnum.External,
colorClass: 'gray-400',
colorClass: 'bg-gray-600',
buttonTitle: null,
buttonIcon: null,
},
......
......@@ -15,7 +15,11 @@ import { AppStatusEnum } from 'src/services/apps/types';
import { DashboardCard, DashboardUtility, UpdateAlert, VersionInfo } from './components';
import { DASHBOARD_QUICK_ACCESS, HIDDEN_APPS, UTILITY_APPS } from './consts';
export const Dashboard: React.FC = () => {
type DashboardProps = {
isAdmin: boolean;
};
export const Dashboard = ({ isAdmin }: DashboardProps) => {
const host = window.location.hostname;
const splitedDomain = host.split('.');
splitedDomain.shift();
......@@ -53,11 +57,13 @@ export const Dashboard: React.FC = () => {
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8">
<div className="mt-6 pb-5 border-b border-gray-200 flex items-center justify-between">
<h1 className="text-xl sm:text-3xl leading-6 font-bold text-gray-900">{greet()}</h1>
<div className="system-status text-xs font-medium text-gray-500 flex flex-col gap-2">
<div className="flex items-center gap-1">
<VersionInfo sysInfo={sysInfo.sysInfo} />
{isAdmin && (
<div className="system-status text-xs font-medium text-gray-500 flex flex-col gap-2">
<div className="flex items-center gap-1">
<VersionInfo sysInfo={sysInfo.sysInfo} />
</div>
</div>
</div>
)}
</div>
</div>
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
......@@ -67,7 +73,7 @@ export const Dashboard: React.FC = () => {
.filter((app) => app.status !== AppStatusEnum.NotInstalled)
// .filter((app) => !app.external)
.map((app) => {
const version = appVersions[app.slug as keyof typeof appVersions];
const version = isAdmin ? appVersions[app.slug as keyof typeof appVersions] : '';
return <DashboardCard app={app} key={app.name} version={version} />;
})}
</div>
......
......@@ -32,7 +32,7 @@ export const DashboardCard = ({ app, version }: DashboardCardProps) => {
const isItMarkdown = appDescription.content.search('DOCTYPE');
if (isItMarkdown !== -1) {
fetch('/custom/markdown/default.md')
fetch('/markdown/default.md')
.then((res) => res.text())
.then((md) => {
return setContent(md);
......@@ -48,7 +48,7 @@ export const DashboardCard = ({ app, version }: DashboardCardProps) => {
) : (
<>
<span>Go to App</span>
<img className="h-6" src="/assets/stackspin_white_logo_icon.svg" alt="Stackspin" />
<img className="h-6" src="/icons/stackspin_white_logo_icon.svg" alt="Stackspin" />
</>
);
......@@ -84,7 +84,7 @@ export const DashboardCard = ({ app, version }: DashboardCardProps) => {
className="h-16 w-16 rounded-md overflow-hidden mr-4 flex-shrink-0"
src={app.assetSrc}
onError={(e) => {
e.currentTarget.src = 'custom/assets/default.svg';
e.currentTarget.src = 'icons/default.svg';
}}
alt={app.name}
/>
......
......@@ -10,7 +10,7 @@ export const UpdateAlert = (sysInfo: SysInfoState) => {
const updateAlert = (status: string) =>
status === 'imminent' ? (
<div className="update-alert w-full md:h-5 h-12 md:py-3 px-5 bg-yellow-100 flex items-center justify-center text-xs font-medium text-gray-500 gap-2">
<div className="update-alert w-full md:h-5 h-12 md:py-3 px-5 bg-amber-100 flex items-center justify-center text-xs font-medium text-gray-500 gap-2">
<LightningBoltIcon className="h-4 w-4 flex-none text-primary-800" />
<span className="py-3">Attention: your Stackspin instance will be updated in the next 24 hours. </span>
<a
......
export { Login } from './login';
export { Dashboard } from './dashboard';
export { Apps, AppSingle } from './apps';
export { ResourcesDashboard } from './resources';
export { Users } from './users';
......@@ -37,7 +37,7 @@ export function Login() {
<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="lg:block" src="assets/logo.svg" alt="Stackspin" />
<img className="lg:block" src="icons/logo.svg" alt="Stackspin" />
<h2 className="mt-6 text-center text-xl font-bold text-gray-900 sr-only">Sign in</h2>
</div>
<button
......
/* eslint-disable react-hooks/exhaustive-deps */
/**
* This page shows system information, in particular resource usage (cpu,
* memory, disk).
*
* This page is only available for admin users.
*/
import React, { useEffect } from 'react';
import { ResourceCard } from './components/ResourceCard';
import { useResources } from 'src/services/resources';
import { useApps } from 'src/services/apps';
const metrics = [
{
id: 'memory',
title: 'Memory',
},
{
id: 'cpu',
title: 'CPU',
},
{
id: 'disk',
title: 'Disk',
},
];
export const ResourcesDashboard: React.FC = () => {
const { resources, loadResources } = useResources();
const { apps, loadApps } = useApps();
window.console.log(resources);
useEffect(() => {
loadResources();
loadApps();
}, []);
const grafanaUrl = apps
.filter((app) => app.slug === 'monitoring')
.map((app) => app.url)
.join();
return (
<div className="relative">
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
<div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
<h1 className="text-3xl leading-6 font-bold text-gray-900">System resources</h1>
</div>
{resources && (
<>
<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="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
<div className=" mt-5 grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-3 mb-10">
{metrics.map((metric) => (
<ResourceCard key={metric.id} metric={metric} resources={resources} />
))}
</div>
</div>
</div>
</div>
</div>
<p>
For further details and system resource history, see&nbsp;
<a className="text-primary-700 hover:underline" href={grafanaUrl}>
Grafana
</a>
.
</p>
</>
)}
{!resources && (
<p>
Monitoring of system resources is not available. Perhaps the monitoring app is not installed or not
functioning properly.
</p>
)}
</div>
</div>
);
};