ajout app
This commit is contained in:
160
Voltaserve/ui/src/pages/account/account-invitations-page.tsx
Normal file
160
Voltaserve/ui/src/pages/account/account-invitations-page.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Portal,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import InvitationAPI, { SortBy, SortOrder } from '@/client/api/invitation'
|
||||
import UserAPI from '@/client/idp/user'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import prettyDate from '@/helpers/pretty-date'
|
||||
import userToString from '@/helpers/user-to-string'
|
||||
import { incomingInvitationPaginationStorage } from '@/infra/pagination'
|
||||
import {
|
||||
IconMoreVert,
|
||||
SectionSpinner,
|
||||
PagePagination,
|
||||
usePagePagination,
|
||||
} from '@/lib'
|
||||
|
||||
const AccountInvitationsPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const toast = useToast()
|
||||
const { data: user, error: userError } = UserAPI.useGet()
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: incomingInvitationPaginationStorage(),
|
||||
})
|
||||
const {
|
||||
data: list,
|
||||
error: invitationsError,
|
||||
mutate,
|
||||
} = InvitationAPI.useGetIncoming(
|
||||
{ page, size, sortBy: SortBy.DateCreated, sortOrder: SortOrder.Desc },
|
||||
swrConfig(),
|
||||
)
|
||||
|
||||
const handleAccept = useCallback(
|
||||
async (invitationId: string) => {
|
||||
await InvitationAPI.accept(invitationId)
|
||||
mutate()
|
||||
toast({
|
||||
title: 'Invitation accepted',
|
||||
status: 'success',
|
||||
isClosable: true,
|
||||
})
|
||||
},
|
||||
[mutate, toast],
|
||||
)
|
||||
|
||||
const handleDecline = useCallback(
|
||||
async (invitationId: string) => {
|
||||
await InvitationAPI.decline(invitationId)
|
||||
mutate()
|
||||
toast({
|
||||
title: 'Invitation declined',
|
||||
status: 'info',
|
||||
isClosable: true,
|
||||
})
|
||||
},
|
||||
[mutate, toast],
|
||||
)
|
||||
|
||||
if (userError || invitationsError) {
|
||||
return null
|
||||
}
|
||||
if (!user || !list) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{user.fullName}</title>
|
||||
</Helmet>
|
||||
{list.data.length === 0 && (
|
||||
<div
|
||||
className={cx('flex', 'items-center', 'justify-center', 'h-[300px]')}
|
||||
>
|
||||
<span>There are no invitations.</span>
|
||||
</div>
|
||||
)}
|
||||
{list.data.length > 0 && (
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'pb-3.5')}>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>From</Th>
|
||||
<Th>Organization</Th>
|
||||
<Th>Date</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{list.data.length > 0 &&
|
||||
list.data.map((i) => (
|
||||
<Tr key={i.id}>
|
||||
<Td>{userToString(i.owner)}</Td>
|
||||
<Td>{i.organization.name}</Td>
|
||||
<Td>{prettyDate(i.createTime)}</Td>
|
||||
<Td className={cx('text-right')}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<IconMoreVert />}
|
||||
variant="ghost"
|
||||
aria-label=""
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => handleAccept(i.id)}>
|
||||
Accept
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={cx('text-red-500')}
|
||||
onClick={() => handleDecline(i.id)}
|
||||
>
|
||||
Decline
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{list && (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end' }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountInvitationsPage
|
118
Voltaserve/ui/src/pages/account/account-layout.tsx
Normal file
118
Voltaserve/ui/src/pages/account/account-layout.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Heading,
|
||||
IconButton,
|
||||
Tab,
|
||||
TabList,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import NotificationAPI from '@/client/api/notification'
|
||||
import UserAPI from '@/client/idp/user'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import AccountEditPicture from '@/components/account/edit-picture'
|
||||
import { IconEdit } from '@/lib'
|
||||
|
||||
const AccountLayout = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [isImageModalOpen, setIsImageModalOpen] = useState(false)
|
||||
const { data: user } = UserAPI.useGet(swrConfig())
|
||||
const { data: notfications } = NotificationAPI.useGetAll(swrConfig())
|
||||
const invitationCount = useMemo(
|
||||
() => notfications?.filter((e) => e.type === 'new_invitation').length,
|
||||
[notfications],
|
||||
)
|
||||
const [tabIndex, setTabIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const segments = location.pathname.split('/')
|
||||
const segment = segments[segments.length - 1]
|
||||
if (segment === 'settings') {
|
||||
setTabIndex(0)
|
||||
} else if (segment === 'invitation') {
|
||||
setTabIndex(1)
|
||||
}
|
||||
}, [location])
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('flex', 'flex-row', 'gap-2.5')}>
|
||||
<div
|
||||
className={cx('flex', 'flex-col', 'gap-2', 'items-center', 'w-[250px]')}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'gap-2', 'items-center')}>
|
||||
<div className={cx('relative', 'shrink-0')}>
|
||||
<Avatar
|
||||
name={user.fullName}
|
||||
src={user.picture}
|
||||
size="2xl"
|
||||
className={cx('w-[165px]', 'h-[165px]')}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<IconEdit />}
|
||||
variant="solid-gray"
|
||||
right="5px"
|
||||
bottom="10px"
|
||||
position="absolute"
|
||||
zIndex={1000}
|
||||
aria-label=""
|
||||
onClick={() => setIsImageModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<Heading className={cx('text-center', 'text-heading')}>
|
||||
{user.fullName}
|
||||
</Heading>
|
||||
</div>
|
||||
<div className={cx('w-full', 'gap-1')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
type="submit"
|
||||
className={cx('w-full')}
|
||||
onClick={() => navigate('/sign-out')}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('w-full', 'pb-1.5')}>
|
||||
<Tabs
|
||||
variant="solid-rounded"
|
||||
colorScheme="gray"
|
||||
index={tabIndex}
|
||||
className={cx('pb-2.5')}
|
||||
>
|
||||
<TabList>
|
||||
<Tab onClick={() => navigate('/account/settings')}>Settings</Tab>
|
||||
<Tab onClick={() => navigate('/account/invitation')}>
|
||||
<div
|
||||
className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}
|
||||
>
|
||||
<span>Invitations</span>
|
||||
{invitationCount && invitationCount > 0 ? (
|
||||
<Tag className={cx('rounded-full')}>{invitationCount}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
<AccountEditPicture
|
||||
open={isImageModalOpen}
|
||||
user={user}
|
||||
onClose={() => setIsImageModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountLayout
|
195
Voltaserve/ui/src/pages/account/account-settings-page.tsx
Normal file
195
Voltaserve/ui/src/pages/account/account-settings-page.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Divider,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
Progress,
|
||||
Switch,
|
||||
Tooltip,
|
||||
useColorMode,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import StorageAPI from '@/client/api/storage'
|
||||
import UserAPI from '@/client/idp/user'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import AccountChangePassword from '@/components/account/account-change-password'
|
||||
import AccountDelete from '@/components/account/account-delete'
|
||||
import AccountEditEmail from '@/components/account/account-edit-email'
|
||||
import AccountEditFullName from '@/components/account/account-edit-full-name'
|
||||
import prettyBytes from '@/helpers/pretty-bytes'
|
||||
import { IconEdit, IconDelete, SectionSpinner, IconWarning } from '@/lib'
|
||||
|
||||
const EditButton = (props: IconButtonProps) => (
|
||||
<IconButton
|
||||
icon={<IconEdit />}
|
||||
className={cx('h-[40px]', 'w-[40px]')}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const Spacer = () => <div className={cx('grow')} />
|
||||
|
||||
const AccountSettingsPage = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode()
|
||||
const { data: user, error: userError } = UserAPI.useGet()
|
||||
const { data: storageUsage, error: storageUsageError } =
|
||||
StorageAPI.useGetAccountUsage(swrConfig())
|
||||
const [isFullNameModalOpen, setIsFullNameModalOpen] = useState(false)
|
||||
const [isEmailModalOpen, setIsEmailModalOpen] = useState(false)
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const sectionClassName = cx('flex', 'flex-col', 'gap-1', 'py-1.5')
|
||||
const rowClassName = cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-1',
|
||||
`h-[40px]`,
|
||||
)
|
||||
|
||||
if (userError) {
|
||||
return null
|
||||
}
|
||||
if (!user) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{user.fullName}</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-0')}>
|
||||
<div className={sectionClassName}>
|
||||
<span className={cx('font-bold')}>Storage Usage</span>
|
||||
{storageUsageError && <span>Failed to load storage usage.</span>}
|
||||
{storageUsage && !storageUsageError && (
|
||||
<>
|
||||
<span>
|
||||
{prettyBytes(storageUsage.bytes)} of{' '}
|
||||
{prettyBytes(storageUsage.maxBytes)} used
|
||||
</span>
|
||||
<Progress value={storageUsage.percentage} hasStripe />
|
||||
</>
|
||||
)}
|
||||
{!storageUsage && !storageUsageError && (
|
||||
<>
|
||||
<span>Calculating…</span>
|
||||
<Progress value={0} hasStripe />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={sectionClassName}>
|
||||
<span className={cx('font-bold')}>Basics</span>
|
||||
<div className={cx(rowClassName)}>
|
||||
<span>Full name</span>
|
||||
<Spacer />
|
||||
<span>{user.fullName}</span>
|
||||
<EditButton
|
||||
aria-label=""
|
||||
onClick={() => {
|
||||
setIsFullNameModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={sectionClassName}>
|
||||
<span className={cx('font-bold')}>Credentials</span>
|
||||
<div className={cx(rowClassName)}>
|
||||
<span>Email</span>
|
||||
<Spacer />
|
||||
{user.pendingEmail && (
|
||||
<div
|
||||
className={cx('flex', 'flex-row', 'gap-0.5', 'items-center')}
|
||||
>
|
||||
<Tooltip label="Please check your inbox to confirm your email.">
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'cursor-default',
|
||||
)}
|
||||
>
|
||||
<IconWarning className={cx('text-yellow-400')} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<span>{user.pendingEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
{!user.pendingEmail && (
|
||||
<span>{user.pendingEmail || user.email}</span>
|
||||
)}
|
||||
<EditButton
|
||||
aria-label=""
|
||||
onClick={() => {
|
||||
setIsEmailModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(rowClassName)}>
|
||||
<span>Password</span>
|
||||
<Spacer />
|
||||
<EditButton
|
||||
aria-label=""
|
||||
onClick={() => {
|
||||
setIsPasswordModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={sectionClassName}>
|
||||
<span className={cx('font-bold')}>Theme</span>
|
||||
<div className={cx(rowClassName)}>
|
||||
<span>Dark mode</span>
|
||||
<Spacer />
|
||||
<Switch
|
||||
isChecked={colorMode === 'dark'}
|
||||
onChange={() => toggleColorMode()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={sectionClassName}>
|
||||
<span className={cx('font-bold')}>Advanced</span>
|
||||
<div className={cx(rowClassName)}>
|
||||
<span>Delete account</span>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<IconDelete />}
|
||||
variant="solid"
|
||||
colorScheme="red"
|
||||
aria-label=""
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AccountEditFullName
|
||||
open={isFullNameModalOpen}
|
||||
user={user}
|
||||
onClose={() => setIsFullNameModalOpen(false)}
|
||||
/>
|
||||
<AccountEditEmail
|
||||
open={isEmailModalOpen}
|
||||
user={user}
|
||||
onClose={() => setIsEmailModalOpen(false)}
|
||||
/>
|
||||
<AccountChangePassword
|
||||
open={isPasswordModalOpen}
|
||||
user={user}
|
||||
onClose={() => setIsPasswordModalOpen(false)}
|
||||
/>
|
||||
<AccountDelete
|
||||
open={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSettingsPage
|
75
Voltaserve/ui/src/pages/confirm-email-page.tsx
Normal file
75
Voltaserve/ui/src/pages/confirm-email-page.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link as ChakraLink, Heading } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import AccountAPI from '@/client/idp/account'
|
||||
import Logo from '@/components/common/logo'
|
||||
import LayoutFull from '@/components/layout/layout-full'
|
||||
import { Spinner } from '@/lib'
|
||||
|
||||
const ConfirmEmailPage = () => {
|
||||
const params = useParams()
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
const [isFailed, setIsFailed] = useState(false)
|
||||
const [token, setToken] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
setToken(params.token as string)
|
||||
}, [params.token])
|
||||
|
||||
useEffect(() => {
|
||||
async function doRequest() {
|
||||
try {
|
||||
await AccountAPI.confirmEmail({ token: token })
|
||||
setIsCompleted(true)
|
||||
} catch {
|
||||
setIsFailed(true)
|
||||
} finally {
|
||||
setIsCompleted(true)
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
doRequest()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<LayoutFull>
|
||||
<Helmet>
|
||||
<title>Confirm Email</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-3')}>
|
||||
<div className={cx('w-[64px]')}>
|
||||
<Logo isGlossy={true} />
|
||||
</div>
|
||||
{!isCompleted && !isFailed ? (
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-1.5')}>
|
||||
<Heading className={cx('text-heading')}>
|
||||
Confirming your Email…
|
||||
</Heading>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
{isCompleted && !isFailed ? (
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-1.5')}>
|
||||
<Heading className={cx('text-heading')}>Email confirmed</Heading>
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-0.5')}>
|
||||
<span>Click the link below to sign in.</span>
|
||||
<ChakraLink as={Link} to="/sign-in">
|
||||
Sign In
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isFailed && (
|
||||
<Heading className={cx('text-heading')}>
|
||||
An error occurred while processing your request.
|
||||
</Heading>
|
||||
)}
|
||||
</div>
|
||||
</LayoutFull>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmEmailPage
|
101
Voltaserve/ui/src/pages/file-viewer-page.tsx
Normal file
101
Voltaserve/ui/src/pages/file-viewer-page.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Button } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import FileAPI, { File } from '@/client/api/file'
|
||||
import DrawerContent from '@/components/viewer/drawer/drawer-content'
|
||||
import ViewerAudio from '@/components/viewer/viewer-audio'
|
||||
import ViewerImage from '@/components/viewer/viewer-image'
|
||||
import ViewerPDF from '@/components/viewer/viewer-pdf'
|
||||
import ViewerVideo from '@/components/viewer/viewer-video'
|
||||
import downloadFile from '@/helpers/download-file'
|
||||
import { isAudio, isImage, isPDF, isVideo } from '@/helpers/file-extension'
|
||||
import { IconDownload, Drawer, Spinner } from '@/lib'
|
||||
|
||||
const FileViewerPage = () => {
|
||||
const { id } = useParams()
|
||||
const { data: file } = FileAPI.useGetById(id)
|
||||
|
||||
const renderViewer = useCallback((file: File) => {
|
||||
if (
|
||||
(file.original && isPDF(file.original.extension)) ||
|
||||
(file.preview && isPDF(file.preview.extension))
|
||||
) {
|
||||
return <ViewerPDF file={file} />
|
||||
} else if (file.original && isImage(file.original.extension)) {
|
||||
return <ViewerImage file={file} />
|
||||
} else if (file.original && isVideo(file.original.extension)) {
|
||||
return <ViewerVideo file={file} />
|
||||
} else if (file.original && isAudio(file.original.extension)) {
|
||||
return <ViewerAudio file={file} />
|
||||
} else {
|
||||
return (
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5')}>
|
||||
<span className={cx('text-[16px]')}>Cannot preview this file.</span>
|
||||
<Button
|
||||
leftIcon={<IconDownload />}
|
||||
colorScheme="blue"
|
||||
onClick={() => downloadFile(file)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{file ? (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{file.name}</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-row', 'gap-0', 'h-full')}>
|
||||
<Drawer storage={{ prefix: 'voltaserve', namespace: 'viewer' }}>
|
||||
<DrawerContent file={file} />
|
||||
</Drawer>
|
||||
<div
|
||||
className={cx('flex', 'flex-col', 'gap-0', 'grow', 'h-[100vh]')}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'w-full',
|
||||
'h-[80px]',
|
||||
)}
|
||||
>
|
||||
<span className={cx('font-medium', 'text-[16px]')}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'w-full',
|
||||
'h-full',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{renderViewer(file)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cx('flex', 'items-center', 'justify-center', 'h-[100vh]')}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileViewerPage
|
149
Voltaserve/ui/src/pages/forgot-password-page.tsx
Normal file
149
Voltaserve/ui/src/pages/forgot-password-page.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Heading,
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
Field,
|
||||
FieldAttributes,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikHelpers,
|
||||
} from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import AccountAPI from '@/client/idp/account'
|
||||
import Logo from '@/components/common/logo'
|
||||
import LayoutFull from '@/components/layout/layout-full'
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
}
|
||||
|
||||
const ForgotPasswordPage = () => {
|
||||
const formSchema = Yup.object().shape({
|
||||
email: Yup.string()
|
||||
.email('Email is not valid')
|
||||
.required('Email is required'),
|
||||
})
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
{ email }: FormValues,
|
||||
{ setSubmitting }: FormikHelpers<FormValues>,
|
||||
) => {
|
||||
try {
|
||||
await AccountAPI.sendResetPasswordEmail({
|
||||
email,
|
||||
})
|
||||
setIsCompleted(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutFull>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Forgot Password</title>
|
||||
</Helmet>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-2.5',
|
||||
'w-full',
|
||||
)}
|
||||
>
|
||||
<div className={cx('w-[64px]')}>
|
||||
<Logo isGlossy={true} />
|
||||
</div>
|
||||
<Heading className={cx('text-heading')}>Forgot Password</Heading>
|
||||
{isCompleted ? (
|
||||
<span className={cx('text-center')}>
|
||||
If your email belongs to an account, you will receive the recovery
|
||||
instructions in your inbox shortly.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={cx('text-center')}>
|
||||
Please provide your account Email where we can send you the
|
||||
password recovery instructions.
|
||||
</span>
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
}}
|
||||
validationSchema={formSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className={cx('w-full')}>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-1.5',
|
||||
)}
|
||||
>
|
||||
<Field name="email">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.email && touched.email ? true : false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>{errors.email}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
className={cx('w-full')}
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Send Password Recovery Instructions
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<div
|
||||
className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}
|
||||
>
|
||||
<span>Password recovered?</span>
|
||||
<ChakraLink as={Link} to="/sign-in">
|
||||
Sign In
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</LayoutFull>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPasswordPage
|
43
Voltaserve/ui/src/pages/group/group-layout.tsx
Normal file
43
Voltaserve/ui/src/pages/group/group-layout.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Heading, Tab, TabList, Tabs } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import GroupAPI from '@/client/api/group'
|
||||
import { swrConfig } from '@/client/options'
|
||||
|
||||
const GroupLayout = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const { data: group } = GroupAPI.useGetById(id, swrConfig())
|
||||
const [tabIndex, setTabIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const segments = location.pathname.split('/')
|
||||
const segment = segments[segments.length - 1]
|
||||
if (segment === 'member') {
|
||||
setTabIndex(0)
|
||||
} else if (segment === 'settings') {
|
||||
setTabIndex(1)
|
||||
}
|
||||
}, [location])
|
||||
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<Heading className={cx('text-heading')}>{group.name}</Heading>
|
||||
<Tabs variant="solid-rounded" colorScheme="gray" index={tabIndex}>
|
||||
<TabList>
|
||||
<Tab onClick={() => navigate(`/group/${id}/member`)}>Members</Tab>
|
||||
<Tab onClick={() => navigate(`/group/${id}/settings`)}>Settings</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupLayout
|
159
Voltaserve/ui/src/pages/group/group-list-page.tsx
Normal file
159
Voltaserve/ui/src/pages/group/group-list-page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Link,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
Heading,
|
||||
Link as ChakraLink,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Avatar,
|
||||
Badge,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import GroupAPI, { SortOrder } from '@/client/api/group'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import { CreateGroupButton } from '@/components/top-bar/top-bar-buttons'
|
||||
import prettyDate from '@/helpers/pretty-date'
|
||||
import { decodeQuery } from '@/helpers/query'
|
||||
import { groupPaginationStorage } from '@/infra/pagination'
|
||||
import { SectionSpinner, PagePagination, usePagePagination } from '@/lib'
|
||||
|
||||
const GroupListPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const query = decodeQuery(searchParams.get('q') as string)
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: groupPaginationStorage(),
|
||||
})
|
||||
const {
|
||||
data: list,
|
||||
error,
|
||||
mutate,
|
||||
} = GroupAPI.useList(
|
||||
{ query, page, size, sortOrder: SortOrder.Desc },
|
||||
swrConfig(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mutate()
|
||||
}, [query, page, size, mutate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Groups</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'pb-3.5')}>
|
||||
<Heading className={cx('pl-2', 'text-heading')}>Groups</Heading>
|
||||
{error && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<span>Failed to load groups.</span>
|
||||
</div>
|
||||
)}
|
||||
{!list && !error && <SectionSpinner />}
|
||||
{list && list.data.length === 0 && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5')}>
|
||||
<span>There are no groups.</span>
|
||||
<CreateGroupButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{list && list.data.length > 0 && (
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Organization</Th>
|
||||
<Th>Permission</Th>
|
||||
<Th>Date</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{list.data.map((g) => (
|
||||
<Tr key={g.id}>
|
||||
<Td>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-1.5',
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={g.name}
|
||||
size="sm"
|
||||
className={cx('w-[40px]', 'h-[40px]')}
|
||||
/>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
to={`/group/${g.id}/member`}
|
||||
className={cx('no-underline')}
|
||||
>
|
||||
{g.name}
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
to={`/organization/${g.organization.id}/member`}
|
||||
className={cx('no-underline')}
|
||||
>
|
||||
{g.organization.name}
|
||||
</ChakraLink>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge>{g.permission}</Badge>
|
||||
</Td>
|
||||
<Td>{prettyDate(g.createTime)}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
{list && (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end' }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupListPage
|
209
Voltaserve/ui/src/pages/group/group-members-page.tsx
Normal file
209
Voltaserve/ui/src/pages/group/group-members-page.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Button,
|
||||
Avatar,
|
||||
Portal,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import GroupAPI from '@/client/api/group'
|
||||
import { geEditorPermission } from '@/client/api/permission'
|
||||
import UserAPI, { SortBy, SortOrder } from '@/client/api/user'
|
||||
import { User as IdPUser } from '@/client/idp/user'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import GroupAddMember from '@/components/group/group-add-member'
|
||||
import GroupRemoveMember from '@/components/group/group-remove-member'
|
||||
import { decodeQuery } from '@/helpers/query'
|
||||
import { groupMemberPaginationStorage } from '@/infra/pagination'
|
||||
import {
|
||||
IconLogout,
|
||||
IconPersonAdd,
|
||||
SectionSpinner,
|
||||
PagePagination,
|
||||
usePagePagination,
|
||||
IconMoreVert,
|
||||
} from '@/lib'
|
||||
|
||||
const GroupMembersPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { id } = useParams()
|
||||
const { data: group, error: groupError } = GroupAPI.useGetById(
|
||||
id,
|
||||
swrConfig(),
|
||||
)
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: groupMemberPaginationStorage(),
|
||||
})
|
||||
const [searchParams] = useSearchParams()
|
||||
const query = decodeQuery(searchParams.get('q') as string)
|
||||
const {
|
||||
data: list,
|
||||
error: membersError,
|
||||
mutate,
|
||||
} = UserAPI.useList(
|
||||
{
|
||||
query,
|
||||
groupId: id,
|
||||
page,
|
||||
size,
|
||||
sortBy: SortBy.FullName,
|
||||
sortOrder: SortOrder.Asc,
|
||||
},
|
||||
swrConfig(),
|
||||
)
|
||||
const [userToRemove, setUserToRemove] = useState<IdPUser>()
|
||||
const [isAddMembersModalOpen, setIsAddMembersModalOpen] = useState(false)
|
||||
const [isRemoveMemberModalOpen, setIsRemoveMemberModalOpen] =
|
||||
useState<boolean>(false)
|
||||
|
||||
if (groupError || membersError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!group || !list) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{group.name}</title>
|
||||
</Helmet>
|
||||
{list.data.length > 0 && (
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'pb-3.5')}>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Full name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{list.data.map((u) => (
|
||||
<Tr key={u.id}>
|
||||
<Td>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-1.5',
|
||||
'items-center',
|
||||
)}
|
||||
>
|
||||
<Avatar name={u.fullName} src={u.picture} />
|
||||
<span>{u.fullName}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{u.email}</Td>
|
||||
<Td className={cx('text-right')}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<IconMoreVert />}
|
||||
variant="ghost"
|
||||
aria-label=""
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<IconLogout />}
|
||||
className={cx('text-red-500')}
|
||||
isDisabled={!geEditorPermission(group.permission)}
|
||||
onClick={() => {
|
||||
setUserToRemove(u)
|
||||
setIsRemoveMemberModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Remove From Group
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{list && (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end' }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
)}
|
||||
{userToRemove && (
|
||||
<GroupRemoveMember
|
||||
isOpen={isRemoveMemberModalOpen}
|
||||
user={userToRemove}
|
||||
group={group}
|
||||
onCompleted={() => mutate()}
|
||||
onClose={() => setIsRemoveMemberModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{list.data.length === 0 && (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{group.name}</title>
|
||||
</Helmet>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5', 'items-center')}>
|
||||
<span>This group has no members.</span>
|
||||
{geEditorPermission(group.permission) && (
|
||||
<Button
|
||||
leftIcon={<IconPersonAdd />}
|
||||
onClick={() => {
|
||||
setIsAddMembersModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Add Members
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GroupAddMember
|
||||
open={isAddMembersModalOpen}
|
||||
group={group}
|
||||
onClose={() => setIsAddMembersModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupMembersPage
|
111
Voltaserve/ui/src/pages/group/group-settings-page.tsx
Normal file
111
Voltaserve/ui/src/pages/group/group-settings-page.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Divider, IconButton } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import GroupAPI from '@/client/api/group'
|
||||
import { geEditorPermission, geOwnerPermission } from '@/client/api/permission'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import GroupAddMember from '@/components/group/group-add-member'
|
||||
import GroupDelete from '@/components/group/group-delete'
|
||||
import GroupEditName from '@/components/group/group-edit-name'
|
||||
import { IconEdit, IconDelete, IconPersonAdd, SectionSpinner } from '@/lib'
|
||||
|
||||
const Spacer = () => <div className={cx('grow')} />
|
||||
|
||||
const GroupSettingsPage = () => {
|
||||
const { id } = useParams()
|
||||
const { data: group, error } = GroupAPI.useGetById(id, swrConfig())
|
||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false)
|
||||
const [isAddMembersModalOpen, setIsAddMembersModalOpen] = useState(false)
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const hasEditPermission = useMemo(
|
||||
() => group && geEditorPermission(group.permission),
|
||||
[group],
|
||||
)
|
||||
const hasOwnerPermission = useMemo(
|
||||
() => group && geOwnerPermission(group.permission),
|
||||
[group],
|
||||
)
|
||||
const sectionClassName = cx('flex', 'flex-col', 'gap-1', 'py-1.5')
|
||||
const rowClassName = cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-1',
|
||||
`h-[40px]`,
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return null
|
||||
}
|
||||
if (!group) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{group.name}</title>
|
||||
</Helmet>
|
||||
<div className={sectionClassName}>
|
||||
<div className={rowClassName}>
|
||||
<span>Name</span>
|
||||
<Spacer />
|
||||
<span>{group.name}</span>
|
||||
<IconButton
|
||||
icon={<IconEdit />}
|
||||
isDisabled={!hasEditPermission}
|
||||
aria-label=""
|
||||
onClick={() => {
|
||||
setIsNameModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={rowClassName}>
|
||||
<span>Add members</span>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<IconPersonAdd />}
|
||||
isDisabled={!hasOwnerPermission}
|
||||
aria-label=""
|
||||
onClick={() => {
|
||||
setIsAddMembersModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={rowClassName}>
|
||||
<span>Delete permanently</span>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<IconDelete />}
|
||||
variant="solid"
|
||||
colorScheme="red"
|
||||
isDisabled={!hasOwnerPermission}
|
||||
aria-label=""
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<GroupEditName
|
||||
open={isNameModalOpen}
|
||||
group={group}
|
||||
onClose={() => setIsNameModalOpen(false)}
|
||||
/>
|
||||
<GroupAddMember
|
||||
open={isAddMembersModalOpen}
|
||||
group={group}
|
||||
onClose={() => setIsAddMembersModalOpen(false)}
|
||||
/>
|
||||
<GroupDelete
|
||||
open={deleteModalOpen}
|
||||
group={group}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupSettingsPage
|
144
Voltaserve/ui/src/pages/new-group-page.tsx
Normal file
144
Voltaserve/ui/src/pages/new-group-page.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
} from '@chakra-ui/react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import {
|
||||
Field,
|
||||
FieldAttributes,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikHelpers,
|
||||
} from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import GroupAPI from '@/client/api/group'
|
||||
import OrganizationSelector from '@/components/common/organization-selector'
|
||||
|
||||
type FormValues = {
|
||||
name: string
|
||||
organizationId: string
|
||||
}
|
||||
|
||||
const NewGroupPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { org } = useParams()
|
||||
const { mutate } = useSWRConfig()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const formSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Name is required').max(255),
|
||||
organizationId: Yup.string().required('Organization is required'),
|
||||
})
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
{ name, organizationId }: FormValues,
|
||||
{ setSubmitting }: FormikHelpers<FormValues>,
|
||||
) => {
|
||||
setSubmitting(true)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await GroupAPI.create({
|
||||
name,
|
||||
organizationId,
|
||||
})
|
||||
mutate(`/groups/${result.id}`, result)
|
||||
mutate(`/groups`)
|
||||
setSubmitting(false)
|
||||
navigate(`/group/${result.id}/member`)
|
||||
} catch {
|
||||
setIsLoading(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[navigate, mutate],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>New Group</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<Heading className={cx('text-heading')}>New Group</Heading>
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{ name: '', organizationId: org || '' }}
|
||||
validationSchema={formSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, setFieldValue }) => (
|
||||
<Form>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5')}>
|
||||
<Field name="name">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
maxW="400px"
|
||||
isInvalid={errors.name && touched.name ? true : false}
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input {...field} disabled={isSubmitting} autoFocus />
|
||||
<FormErrorMessage>{errors.name}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="organizationId">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
maxW="400px"
|
||||
isInvalid={
|
||||
errors.organizationId && touched.organizationId
|
||||
? true
|
||||
: false
|
||||
}
|
||||
>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<OrganizationSelector
|
||||
onConfirm={(value) =>
|
||||
setFieldValue(field.name, value.id)
|
||||
}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.organizationId}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div
|
||||
className={cx('flex', 'flex-row', 'items-center', 'gap-1')}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
isDisabled={isSubmitting || isLoading}
|
||||
isLoading={isSubmitting || isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button as={Link} to="/group" variant="solid">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewGroupPage
|
115
Voltaserve/ui/src/pages/new-organization-page.tsx
Normal file
115
Voltaserve/ui/src/pages/new-organization-page.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Heading } from '@chakra-ui/react'
|
||||
import { Button, FormControl, FormErrorMessage, Input } from '@chakra-ui/react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import {
|
||||
Field,
|
||||
FieldAttributes,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikHelpers,
|
||||
} from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import OrganizationAPI from '@/client/api/organization'
|
||||
|
||||
type FormValues = {
|
||||
name: string
|
||||
}
|
||||
|
||||
const NewOrganizationPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { mutate } = useSWRConfig()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const formSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Name is required').max(255),
|
||||
})
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
{ name }: FormValues,
|
||||
{ setSubmitting }: FormikHelpers<FormValues>,
|
||||
) => {
|
||||
setSubmitting(true)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await OrganizationAPI.create({
|
||||
name,
|
||||
})
|
||||
mutate(`/organizations/${result.id}`, result)
|
||||
mutate(`/organizations`)
|
||||
setSubmitting(false)
|
||||
navigate(`/organization/${result.id}/member`)
|
||||
} catch {
|
||||
setIsLoading(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[navigate, mutate],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>New Organization</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<Heading className={cx('text-heading')}>New Organization</Heading>
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{ name: '' }}
|
||||
validationSchema={formSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5')}>
|
||||
<Field name="name">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
maxW="400px"
|
||||
isInvalid={errors.name && touched.name ? true : false}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Name"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
<FormErrorMessage>{errors.name}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div
|
||||
className={cx('flex', 'flex-row', 'items-center', 'gap-1')}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
isDisabled={isSubmitting || isLoading}
|
||||
isLoading={isSubmitting || isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button as={Link} to="/organization" variant="solid">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewOrganizationPage
|
174
Voltaserve/ui/src/pages/new-workspace-page.tsx
Normal file
174
Voltaserve/ui/src/pages/new-workspace-page.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
} from '@chakra-ui/react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import {
|
||||
Field,
|
||||
FieldAttributes,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikHelpers,
|
||||
} from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import WorkspaceAPI from '@/client/api/workspace'
|
||||
import OrganizationSelector from '@/components/common/organization-selector'
|
||||
import StorageInput from '@/components/common/storage-input'
|
||||
import { gigabyteToByte } from '@/helpers/convert-storage'
|
||||
|
||||
type FormValues = {
|
||||
name: string
|
||||
organizationId: string
|
||||
storageCapacity: number
|
||||
}
|
||||
|
||||
const NewWorkspacePage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { mutate } = useSWRConfig()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const formSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Name is required').max(255),
|
||||
organizationId: Yup.string().required('Organization is required'),
|
||||
storageCapacity: Yup.number()
|
||||
.required('Storage capacity is required')
|
||||
.positive()
|
||||
.integer()
|
||||
.min(1, 'Invalid storage usage value'),
|
||||
})
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
{ name, organizationId, storageCapacity }: FormValues,
|
||||
{ setSubmitting }: FormikHelpers<FormValues>,
|
||||
) => {
|
||||
setSubmitting(true)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await WorkspaceAPI.create({
|
||||
name,
|
||||
organizationId,
|
||||
storageCapacity,
|
||||
})
|
||||
mutate(`/workspaces/${result.id}`, result)
|
||||
mutate(`/workspaces`)
|
||||
setSubmitting(false)
|
||||
navigate(`/workspace/${result.id}/file/${result.rootId}`)
|
||||
} catch (e) {
|
||||
setIsLoading(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[navigate, mutate],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>New Workspace</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<Heading className={cx('text-heading')}>New Workspace</Heading>
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
name: '',
|
||||
organizationId: '',
|
||||
storageCapacity: gigabyteToByte(100),
|
||||
}}
|
||||
validationSchema={formSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, setFieldValue }) => (
|
||||
<Form>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5')}>
|
||||
<Field name="name">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
maxW="400px"
|
||||
isInvalid={errors.name && touched.name ? true : false}
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input {...field} disabled={isSubmitting} autoFocus />
|
||||
<FormErrorMessage>{errors.name}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="organizationId">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
maxW="400px"
|
||||
isInvalid={
|
||||
errors.organizationId && touched.organizationId
|
||||
? true
|
||||
: false
|
||||
}
|
||||
>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<OrganizationSelector
|
||||
onConfirm={(value) =>
|
||||
setFieldValue(field.name, value.id)
|
||||
}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.organizationId}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="storageCapacity">
|
||||
{(props: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
maxW="400px"
|
||||
isInvalid={
|
||||
errors.storageCapacity && touched.storageCapacity
|
||||
? true
|
||||
: false
|
||||
}
|
||||
>
|
||||
<FormLabel>Storage capacity</FormLabel>
|
||||
<StorageInput {...props} />
|
||||
<FormErrorMessage>
|
||||
{errors.storageCapacity}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div
|
||||
className={cx('flex', 'flex-row', 'items-center', 'gap-1')}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
isDisabled={isSubmitting || isLoading}
|
||||
isLoading={isSubmitting || isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button as={Link} to="/workspace" variant="solid">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewWorkspacePage
|
@ -0,0 +1,204 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Portal,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import InvitationAPI, { SortBy, SortOrder } from '@/client/api/invitation'
|
||||
import OrganizationAPI from '@/client/api/organization'
|
||||
import { geEditorPermission } from '@/client/api/permission'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import OrganizationInviteMembers from '@/components/organization/organization-invite-members'
|
||||
import OrganizationStatus from '@/components/organization/organization-status'
|
||||
import prettyDate from '@/helpers/pretty-date'
|
||||
import { outgoingInvitationPaginationStorage } from '@/infra/pagination'
|
||||
import {
|
||||
IconMoreVert,
|
||||
IconSend,
|
||||
IconDelete,
|
||||
IconPersonAdd,
|
||||
SectionSpinner,
|
||||
PagePagination,
|
||||
usePagePagination,
|
||||
} from '@/lib'
|
||||
|
||||
const OrganizationInvitationsPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { id } = useParams()
|
||||
const toast = useToast()
|
||||
const { data: org, error: orgError } = OrganizationAPI.useGetById(
|
||||
id,
|
||||
swrConfig(),
|
||||
)
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: outgoingInvitationPaginationStorage(),
|
||||
})
|
||||
const {
|
||||
data: list,
|
||||
error: invitationsError,
|
||||
mutate,
|
||||
} = InvitationAPI.useGetOutgoing(
|
||||
{
|
||||
organizationId: id,
|
||||
page,
|
||||
size,
|
||||
sortBy: SortBy.DateCreated,
|
||||
sortOrder: SortOrder.Desc,
|
||||
},
|
||||
swrConfig(),
|
||||
)
|
||||
const [isInviteMembersModalOpen, setIsInviteMembersModalOpen] =
|
||||
useState(false)
|
||||
|
||||
const handleResend = useCallback(
|
||||
async (invitationId: string) => {
|
||||
await InvitationAPI.resend(invitationId)
|
||||
toast({
|
||||
title: 'Invitation resent',
|
||||
status: 'success',
|
||||
isClosable: true,
|
||||
})
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (invitationId: string) => {
|
||||
await InvitationAPI.delete(invitationId)
|
||||
mutate()
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
if (invitationsError || orgError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!list || !org) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{org.name}</title>
|
||||
</Helmet>
|
||||
{list && list.data.length === 0 ? (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5', 'items-center')}>
|
||||
<span>This organization has no invitations.</span>
|
||||
{geEditorPermission(org.permission) && (
|
||||
<Button
|
||||
leftIcon={<IconPersonAdd />}
|
||||
onClick={() => {
|
||||
setIsInviteMembersModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Invite Members
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<OrganizationInviteMembers
|
||||
open={isInviteMembersModalOpen}
|
||||
id={org.id}
|
||||
onClose={() => setIsInviteMembersModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{list && list.data.length > 0 ? (
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'py-3.5')}>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Email</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Date</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{list.data.map((i) => (
|
||||
<Tr key={i.id}>
|
||||
<Td>{i.email}</Td>
|
||||
<Td>
|
||||
<OrganizationStatus value={i.status} />
|
||||
</Td>
|
||||
<Td>{prettyDate(i.createTime)}</Td>
|
||||
<Td className={cx('text-right')}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<IconMoreVert />}
|
||||
variant="ghost"
|
||||
aria-label=""
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
{i.status === 'pending' && (
|
||||
<MenuItem
|
||||
icon={<IconSend />}
|
||||
onClick={() => handleResend(i.id)}
|
||||
>
|
||||
Resend
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<IconDelete />}
|
||||
className={cx('text-red-500')}
|
||||
onClick={() => handleDelete(i.id)}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{list && (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end' }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationInvitationsPage
|
56
Voltaserve/ui/src/pages/organization/organization-layout.tsx
Normal file
56
Voltaserve/ui/src/pages/organization/organization-layout.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Heading, Tab, TabList, Tabs } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import OrganizationAPI from '@/client/api/organization'
|
||||
import { geOwnerPermission } from '@/client/api/permission'
|
||||
import { swrConfig } from '@/client/options'
|
||||
|
||||
const OrganizationLayout = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const { data: org } = OrganizationAPI.useGetById(id, swrConfig())
|
||||
const [tabIndex, setTabIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const segments = location.pathname.split('/')
|
||||
const segment = segments[segments.length - 1]
|
||||
if (segment === 'member') {
|
||||
setTabIndex(0)
|
||||
} else if (segment === 'invitation') {
|
||||
setTabIndex(1)
|
||||
} else if (segment === 'settings') {
|
||||
setTabIndex(2)
|
||||
}
|
||||
}, [location])
|
||||
|
||||
if (!org) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5')}>
|
||||
<Heading className={cx('text-heading')}>{org.name}</Heading>
|
||||
<Tabs variant="solid-rounded" colorScheme="gray" index={tabIndex}>
|
||||
<TabList>
|
||||
<Tab onClick={() => navigate(`/organization/${id}/member`)}>
|
||||
Members
|
||||
</Tab>
|
||||
<Tab
|
||||
onClick={() => navigate(`/organization/${id}/invitation`)}
|
||||
display={geOwnerPermission(org.permission) ? 'auto' : 'none'}
|
||||
>
|
||||
Invitations
|
||||
</Tab>
|
||||
<Tab onClick={() => navigate(`/organization/${id}/settings`)}>
|
||||
Settings
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationLayout
|
149
Voltaserve/ui/src/pages/organization/organization-list-page.tsx
Normal file
149
Voltaserve/ui/src/pages/organization/organization-list-page.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Link,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
Heading,
|
||||
Link as ChakraLink,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Avatar,
|
||||
Badge,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import OrganizationAPI, { SortOrder } from '@/client/api/organization'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import { CreateOrganizationButton } from '@/components/top-bar/top-bar-buttons'
|
||||
import prettyDate from '@/helpers/pretty-date'
|
||||
import { decodeQuery } from '@/helpers/query'
|
||||
import { organizationPaginationStorage } from '@/infra/pagination'
|
||||
import { SectionSpinner, PagePagination, usePagePagination } from '@/lib'
|
||||
|
||||
const OrganizationListPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const query = decodeQuery(searchParams.get('q') as string)
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: organizationPaginationStorage(),
|
||||
})
|
||||
const {
|
||||
data: list,
|
||||
error,
|
||||
mutate,
|
||||
} = OrganizationAPI.useList(
|
||||
{ query, page, size, sortOrder: SortOrder.Desc },
|
||||
swrConfig(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mutate()
|
||||
}, [query, page, size, mutate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Organizations</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'pb-3.5')}>
|
||||
<Heading className={cx('pl-2', 'text-heading')}>Organizations</Heading>
|
||||
{!list && error && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<span>Failed to load organizations.</span>
|
||||
</div>
|
||||
)}
|
||||
{!list && !error && <SectionSpinner />}
|
||||
{list && list.data.length === 0 && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5', 'items-center')}>
|
||||
<span>There are no organizations.</span>
|
||||
<CreateOrganizationButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{list && list.data.length > 0 && (
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Permission</Th>
|
||||
<Th>Date</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{list.data.map((o) => (
|
||||
<Tr key={o.id}>
|
||||
<Td>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-1.5',
|
||||
'items-center',
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={o.name}
|
||||
size="sm"
|
||||
className={cx('w-[40px]', 'h-[40px]')}
|
||||
/>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
to={`/organization/${o.id}/member`}
|
||||
className={cx('no-underline')}
|
||||
>
|
||||
<span>{o.name}</span>
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge>{o.permission}</Badge>
|
||||
</Td>
|
||||
<Td>{prettyDate(o.createTime)}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
{list && (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end' }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationListPage
|
@ -0,0 +1,211 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Button,
|
||||
Avatar,
|
||||
Portal,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import OrganizationAPI from '@/client/api/organization'
|
||||
import { geEditorPermission } from '@/client/api/permission'
|
||||
import UserAPI, { SortBy, SortOrder, User } from '@/client/api/user'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import OrganizationInviteMembers from '@/components/organization/organization-invite-members'
|
||||
import OrganizationRemoveMember from '@/components/organization/organization-remove-member'
|
||||
import { decodeQuery } from '@/helpers/query'
|
||||
import { organizationMemberPaginationStorage } from '@/infra/pagination'
|
||||
import {
|
||||
IconMoreVert,
|
||||
IconLogout,
|
||||
IconPersonAdd,
|
||||
SectionSpinner,
|
||||
PagePagination,
|
||||
usePagePagination,
|
||||
} from '@/lib'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hook'
|
||||
import {
|
||||
inviteModalDidClose,
|
||||
inviteModalDidOpen,
|
||||
} from '@/store/ui/organizations'
|
||||
|
||||
const OrganizationMembersPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { id } = useParams()
|
||||
const { data: org, error: orgError } = OrganizationAPI.useGetById(
|
||||
id,
|
||||
swrConfig(),
|
||||
)
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: organizationMemberPaginationStorage(),
|
||||
})
|
||||
const [searchParams] = useSearchParams()
|
||||
const query = decodeQuery(searchParams.get('q') as string)
|
||||
const {
|
||||
data: list,
|
||||
error: membersError,
|
||||
mutate,
|
||||
} = UserAPI.useList(
|
||||
{
|
||||
query,
|
||||
organizationId: id,
|
||||
page,
|
||||
size,
|
||||
sortBy: SortBy.FullName,
|
||||
sortOrder: SortOrder.Asc,
|
||||
},
|
||||
swrConfig(),
|
||||
)
|
||||
const isInviteMembersModalOpen = useAppSelector(
|
||||
(state) => state.ui.organizations.isInviteModalOpen,
|
||||
)
|
||||
const [userToRemove, setUserToRemove] = useState<User>()
|
||||
const [isRemoveMemberModalOpen, setIsRemoveMemberModalOpen] =
|
||||
useState<boolean>(false)
|
||||
|
||||
if (membersError || orgError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!list || !org) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{org.name}</title>
|
||||
</Helmet>
|
||||
{list.data.length > 0 && (
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'pb-3.5')}>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Full name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{list.data.map((u) => (
|
||||
<Tr key={u.id}>
|
||||
<Td>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-1.5',
|
||||
'items-center',
|
||||
)}
|
||||
>
|
||||
<Avatar name={u.fullName} src={u.picture} />
|
||||
<span>{u.fullName}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{u.email}</Td>
|
||||
<Td className={cx('text-right')}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<IconMoreVert />}
|
||||
variant="ghost"
|
||||
aria-label=""
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<IconLogout />}
|
||||
className={cx('text-red-500')}
|
||||
isDisabled={!geEditorPermission(org.permission)}
|
||||
onClick={() => {
|
||||
setUserToRemove(u)
|
||||
setIsRemoveMemberModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Remove From Organization
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{list && (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end' }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
)}
|
||||
{userToRemove && (
|
||||
<OrganizationRemoveMember
|
||||
isOpen={isRemoveMemberModalOpen}
|
||||
user={userToRemove}
|
||||
organization={org}
|
||||
onCompleted={() => mutate()}
|
||||
onClose={() => setIsRemoveMemberModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{list.data.length === 0 && (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5', 'items-center')}>
|
||||
<span>This organization has no members.</span>
|
||||
{geEditorPermission(org.permission) && (
|
||||
<Button
|
||||
leftIcon={<IconPersonAdd />}
|
||||
onClick={() => dispatch(inviteModalDidOpen())}
|
||||
>
|
||||
Invite Members
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<OrganizationInviteMembers
|
||||
open={isInviteMembersModalOpen}
|
||||
id={org.id}
|
||||
onClose={() => dispatch(inviteModalDidClose())}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationMembersPage
|
@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Divider, IconButton } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import OrganizationAPI from '@/client/api/organization'
|
||||
import { geEditorPermission, geOwnerPermission } from '@/client/api/permission'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import OrganizationDelete from '@/components/organization/organization-delete'
|
||||
import OrganizationEditName from '@/components/organization/organization-edit-name'
|
||||
import OrganizationInviteMembers from '@/components/organization/organization-invite-members'
|
||||
import OrganizationLeave from '@/components/organization/organization-leave'
|
||||
import {
|
||||
IconEdit,
|
||||
IconLogout,
|
||||
IconDelete,
|
||||
IconPersonAdd,
|
||||
SectionSpinner,
|
||||
} from '@/lib'
|
||||
|
||||
const Spacer = () => <div className={cx('grow')} />
|
||||
|
||||
const OrganizationSettingsPage = () => {
|
||||
const { id } = useParams()
|
||||
const { data: org, error } = OrganizationAPI.useGetById(id, swrConfig())
|
||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false)
|
||||
const [isInviteMembersModalOpen, setIsInviteMembersModalOpen] =
|
||||
useState(false)
|
||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const sectionClassName = cx('flex', 'flex-col', 'gap-1', 'py-1.5')
|
||||
const rowClassName = cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-1',
|
||||
`h-[40px]`,
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{org.name}</title>
|
||||
</Helmet>
|
||||
<div className={sectionClassName}>
|
||||
<div className={rowClassName}>
|
||||
<span>Name</span>
|
||||
<Spacer />
|
||||
<span>{org.name}</span>
|
||||
<IconButton
|
||||
icon={<IconEdit />}
|
||||
isDisabled={!geEditorPermission(org.permission)}
|
||||
aria-label=""
|
||||
onClick={() => {
|
||||
setIsNameModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={rowClassName}>
|
||||
<span>Invite members</span>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<IconPersonAdd />}
|
||||
isDisabled={!geOwnerPermission(org.permission)}
|
||||
aria-label=""
|
||||
onClick={() => {
|
||||
setIsInviteMembersModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={rowClassName}>
|
||||
<span>Leave</span>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<IconLogout />}
|
||||
variant="solid"
|
||||
colorScheme="red"
|
||||
aria-label=""
|
||||
onClick={() => setIsLeaveModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={rowClassName}>
|
||||
<span>Delete permanently</span>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<IconDelete />}
|
||||
variant="solid"
|
||||
colorScheme="red"
|
||||
isDisabled={!geEditorPermission(org.permission)}
|
||||
aria-label=""
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<OrganizationEditName
|
||||
open={isNameModalOpen}
|
||||
organization={org}
|
||||
onClose={() => setIsNameModalOpen(false)}
|
||||
/>
|
||||
<OrganizationInviteMembers
|
||||
open={isInviteMembersModalOpen}
|
||||
id={org.id}
|
||||
onClose={() => setIsInviteMembersModalOpen(false)}
|
||||
/>
|
||||
<OrganizationLeave
|
||||
open={isLeaveModalOpen}
|
||||
id={org.id}
|
||||
onClose={() => setIsLeaveModalOpen(false)}
|
||||
/>
|
||||
<OrganizationDelete
|
||||
open={isDeleteModalOpen}
|
||||
organization={org}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationSettingsPage
|
188
Voltaserve/ui/src/pages/reset-password-page.tsx
Normal file
188
Voltaserve/ui/src/pages/reset-password-page.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Heading,
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
Field,
|
||||
FieldAttributes,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikHelpers,
|
||||
} from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import AccountAPI from '@/client/idp/account'
|
||||
import Logo from '@/components/common/logo'
|
||||
import LayoutFull from '@/components/layout/layout-full'
|
||||
|
||||
type FormValues = {
|
||||
newPassword: string
|
||||
newPasswordConfirmation: string
|
||||
}
|
||||
|
||||
const ResetPasswordPage = () => {
|
||||
const params = useParams()
|
||||
const token = params.token as string
|
||||
const formSchema = Yup.object().shape({
|
||||
newPassword: Yup.string()
|
||||
.required('Password is required')
|
||||
.matches(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/,
|
||||
'Must contain at least 8 characters, one Uppercase, one Lowercase, one number and one special character',
|
||||
),
|
||||
newPasswordConfirmation: Yup.string()
|
||||
.oneOf([Yup.ref('newPassword'), undefined], 'Passwords do not match')
|
||||
.required('Confirm your password'),
|
||||
})
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
{ newPassword }: FormValues,
|
||||
{ setSubmitting }: FormikHelpers<FormValues>,
|
||||
) => {
|
||||
try {
|
||||
await AccountAPI.resetPassword({
|
||||
newPassword,
|
||||
token: token,
|
||||
})
|
||||
setIsCompleted(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[token],
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutFull>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Reset Password</title>
|
||||
</Helmet>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-2.5',
|
||||
'w-full',
|
||||
)}
|
||||
>
|
||||
<div className={cx('w-[64px]')}>
|
||||
<Logo isGlossy={true} />
|
||||
</div>
|
||||
<Heading className={cx('text-heading')}>Reset Password</Heading>
|
||||
{isCompleted ? (
|
||||
<div className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}>
|
||||
<span className={cx('text-center')}>
|
||||
Password successfully changed.
|
||||
</span>
|
||||
<ChakraLink as={Link} to="/sign-in">
|
||||
Sign In
|
||||
</ChakraLink>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{
|
||||
newPassword: '',
|
||||
newPasswordConfirmation: '',
|
||||
}}
|
||||
validationSchema={formSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className={cx('w-full')}>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-1.5',
|
||||
)}
|
||||
>
|
||||
<Field name="newPassword">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.newPassword && touched.newPassword
|
||||
? true
|
||||
: false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="newPassword"
|
||||
placeholder="New password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.newPassword}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="newPasswordConfirmation">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.newPasswordConfirmation &&
|
||||
touched.newPasswordConfirmation
|
||||
? true
|
||||
: false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="newPasswordConfirmation"
|
||||
placeholder="Confirm new password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.newPasswordConfirmation}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
className={cx('w-full')}
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<div
|
||||
className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}
|
||||
>
|
||||
<span>Password already reset?</span>
|
||||
<ChakraLink as={Link} to="/sign-in">
|
||||
Sign In
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</LayoutFull>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPasswordPage
|
62
Voltaserve/ui/src/pages/root-page.tsx
Normal file
62
Voltaserve/ui/src/pages/root-page.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useColorMode } from '@chakra-ui/react'
|
||||
import { cx } from '@emotion/css'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
const RootPage = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { colorMode } = useColorMode()
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/') {
|
||||
navigate('/workspace')
|
||||
}
|
||||
}, [location.pathname, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const element = document.querySelector("link[rel='icon']")
|
||||
if (element) {
|
||||
window
|
||||
.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', (event: MediaQueryListEvent) => {
|
||||
if (event.matches) {
|
||||
element.setAttribute('href', '/favicon-dark.svg')
|
||||
} else {
|
||||
element.setAttribute('href', '/favicon.svg')
|
||||
}
|
||||
})
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
element.setAttribute('href', '/favicon-dark.svg')
|
||||
} else {
|
||||
element.setAttribute('href', '/favicon.svg')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const body = document.getElementsByTagName('body')[0]
|
||||
if (colorMode === 'dark') {
|
||||
body.classList.add('dark')
|
||||
} else {
|
||||
body.classList.remove('dark')
|
||||
}
|
||||
}, [colorMode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Voltaserve</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
|
||||
</Helmet>
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RootPage
|
194
Voltaserve/ui/src/pages/sign-in-page.tsx
Normal file
194
Voltaserve/ui/src/pages/sign-in-page.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Heading,
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
Field,
|
||||
FieldAttributes,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikHelpers,
|
||||
} from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import GroupAPI from '@/client/api/group'
|
||||
import OrganizationAPI from '@/client/api/organization'
|
||||
import WorkspaceAPI from '@/client/api/workspace'
|
||||
import TokenAPI from '@/client/idp/token'
|
||||
import Logo from '@/components/common/logo'
|
||||
import LayoutFull from '@/components/layout/layout-full'
|
||||
import { gigabyteToByte } from '@/helpers/convert-storage'
|
||||
import { saveToken } from '@/infra/token'
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const SignInPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const formSchema = Yup.object().shape({
|
||||
email: Yup.string()
|
||||
.email('Email is not valid')
|
||||
.required('Email is required'),
|
||||
password: Yup.string().required('Password is required'),
|
||||
})
|
||||
|
||||
const handleSignIn = useCallback(
|
||||
async (
|
||||
{ email: username, password }: FormValues,
|
||||
{ setSubmitting }: FormikHelpers<FormValues>,
|
||||
) => {
|
||||
try {
|
||||
const token = await TokenAPI.exchange({
|
||||
username,
|
||||
password,
|
||||
grant_type: 'password',
|
||||
})
|
||||
saveToken(token)
|
||||
const orgList = await OrganizationAPI.list()
|
||||
if (orgList.totalElements === 0) {
|
||||
const { id: organizationId } = await OrganizationAPI.create({
|
||||
name: 'My Organization',
|
||||
})
|
||||
await GroupAPI.create({
|
||||
name: 'My Group',
|
||||
organizationId,
|
||||
})
|
||||
const { id: workspaceId, rootId } = await WorkspaceAPI.create({
|
||||
name: 'My Workspace',
|
||||
organizationId,
|
||||
storageCapacity: gigabyteToByte(100),
|
||||
})
|
||||
navigate(`/workspace/${workspaceId}/file/${rootId}`)
|
||||
} else {
|
||||
const workspaceList = await WorkspaceAPI.list()
|
||||
if (workspaceList.totalElements === 1) {
|
||||
navigate(
|
||||
`/workspace/${workspaceList.data[0].id}/file/${workspaceList.data[0].rootId}`,
|
||||
)
|
||||
} else {
|
||||
navigate('/workspace')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutFull>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Sign In to Voltaserve</title>
|
||||
</Helmet>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-2.5',
|
||||
'w-full',
|
||||
)}
|
||||
>
|
||||
<div className={cx('w-[64px]')}>
|
||||
<Logo isGlossy={true} />
|
||||
</div>
|
||||
<Heading className={cx('text-heading')}>
|
||||
Sign In to Voltaserve
|
||||
</Heading>
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={formSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={handleSignIn}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className={cx('w-full')}>
|
||||
<div
|
||||
className={cx('flex', 'flex-col', 'items-center', 'gap-1.5')}
|
||||
>
|
||||
<Field name="email">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={errors.email && touched.email ? true : false}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>{errors.email}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="password">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.password && touched.password ? true : false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>{errors.password}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
className={cx('w-full')}
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<div
|
||||
className={cx('flex', 'flex-col', 'items-center', 'max-w-[60ch]')}
|
||||
>
|
||||
<div className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}>
|
||||
<span>{"Don't have an account yet?"}</span>
|
||||
<ChakraLink as={Link} to="/sign-up">
|
||||
Sign Up
|
||||
</ChakraLink>
|
||||
</div>
|
||||
<div className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}>
|
||||
<span>Cannot sign in?</span>
|
||||
<ChakraLink as={Link} to="/forgot-password">
|
||||
Reset Password
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</LayoutFull>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignInPage
|
29
Voltaserve/ui/src/pages/sign-out-page.tsx
Normal file
29
Voltaserve/ui/src/pages/sign-out-page.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Heading } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import LayoutFull from '@/components/layout/layout-full'
|
||||
import { clearToken } from '@/infra/token'
|
||||
|
||||
function SignOutPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
clearToken()
|
||||
navigate('/sign-in')
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<LayoutFull>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Signing Out…</title>
|
||||
</Helmet>
|
||||
<Heading className={cx('text-heading')}>Signing out…</Heading>
|
||||
</>
|
||||
</LayoutFull>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignOutPage
|
233
Voltaserve/ui/src/pages/sign-up-page.tsx
Normal file
233
Voltaserve/ui/src/pages/sign-up-page.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Heading,
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
Field,
|
||||
FieldAttributes,
|
||||
FieldProps,
|
||||
Form,
|
||||
Formik,
|
||||
FormikHelpers,
|
||||
} from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import AccountAPI from '@/client/idp/account'
|
||||
import Logo from '@/components/common/logo'
|
||||
import LayoutFull from '@/components/layout/layout-full'
|
||||
|
||||
type FormValues = {
|
||||
fullName: string
|
||||
email: string
|
||||
password: string
|
||||
passwordConfirmation: string
|
||||
}
|
||||
|
||||
const SignUpPage = () => {
|
||||
const [isConfirmationVisible, setIsConfirmationVisible] = useState(false)
|
||||
const formSchema = Yup.object().shape({
|
||||
fullName: Yup.string().required('Name is required'),
|
||||
email: Yup.string()
|
||||
.email('Email is not valid')
|
||||
.required('Email is required'),
|
||||
password: Yup.string().required('Password is required'),
|
||||
passwordConfirmation: Yup.string()
|
||||
.oneOf([Yup.ref('password'), undefined], 'Passwords must match')
|
||||
.required('Confirm your password'),
|
||||
})
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
{ fullName, email, password }: FormValues,
|
||||
{ setSubmitting }: FormikHelpers<FormValues>,
|
||||
) => {
|
||||
try {
|
||||
await AccountAPI.create({
|
||||
fullName,
|
||||
email,
|
||||
password,
|
||||
})
|
||||
setIsConfirmationVisible(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutFull>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Sign Up to Voltaserve</title>
|
||||
</Helmet>
|
||||
{isConfirmationVisible && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-2.5',
|
||||
'w-full',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-1.5')}>
|
||||
<div className={cx('w-[64px]')}>
|
||||
<Logo isGlossy={true} />
|
||||
</div>
|
||||
<Heading className={cx('text-heading')}>
|
||||
Thanks! We just sent you a confirmation email
|
||||
</Heading>
|
||||
<span className={cx('text-center')}>
|
||||
Just open your inbox, find the email, and click on the
|
||||
confirmation link.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isConfirmationVisible && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-2.5',
|
||||
'w-full',
|
||||
)}
|
||||
>
|
||||
<div className={cx('w-[64px]')}>
|
||||
<Logo isGlossy={true} />
|
||||
</div>
|
||||
<Heading className={cx('text-heading')}>
|
||||
Sign Up to Voltaserve
|
||||
</Heading>
|
||||
<Formik
|
||||
initialValues={{
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
}}
|
||||
validationSchema={formSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className={cx('w-full')}>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-1.5',
|
||||
)}
|
||||
>
|
||||
<Field name="fullName">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.fullName && touched.fullName ? true : false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="fullName"
|
||||
placeholder="Full name"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>{errors.fullName}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="email">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.email && touched.email ? true : false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>{errors.email}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="password">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.password && touched.password ? true : false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>{errors.password}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="passwordConfirmation">
|
||||
{({ field }: FieldAttributes<FieldProps>) => (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
errors.passwordConfirmation &&
|
||||
touched.passwordConfirmation
|
||||
? true
|
||||
: false
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
id="passwordConfirmation"
|
||||
placeholder="Confirm password"
|
||||
type="password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.passwordConfirmation}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
className={cx('w-full')}
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<div className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}>
|
||||
<span>Already a member?</span>
|
||||
<ChakraLink as={Link} to="/sign-in">
|
||||
Sign In
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</LayoutFull>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUpPage
|
75
Voltaserve/ui/src/pages/update-email-page.tsx
Normal file
75
Voltaserve/ui/src/pages/update-email-page.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link as ChakraLink, Heading } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import UserAPI from '@/client/idp/user'
|
||||
import Logo from '@/components/common/logo'
|
||||
import LayoutFull from '@/components/layout/layout-full'
|
||||
import { Spinner } from '@/lib'
|
||||
|
||||
const UpdateEmailPage = () => {
|
||||
const params = useParams()
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
const [isFailed, setIsFailed] = useState(false)
|
||||
const [token, setToken] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
setToken(params.token as string)
|
||||
}, [params.token])
|
||||
|
||||
useEffect(() => {
|
||||
async function doRequest() {
|
||||
try {
|
||||
await UserAPI.updateEmailConfirmation({ token: token })
|
||||
setIsCompleted(true)
|
||||
} catch {
|
||||
setIsFailed(true)
|
||||
} finally {
|
||||
setIsCompleted(true)
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
doRequest()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<LayoutFull>
|
||||
<Helmet>
|
||||
<title>Confirm Email</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-3')}>
|
||||
<div className={cx('w-[64px]')}>
|
||||
<Logo isGlossy={true} />
|
||||
</div>
|
||||
{!isCompleted && !isFailed ? (
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-1.5')}>
|
||||
<Heading className={cx('text-heading')}>
|
||||
Confirming your Email…
|
||||
</Heading>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
{isCompleted && !isFailed ? (
|
||||
<div className={cx('flex', 'flex-col', 'items-center', 'gap-1.5')}>
|
||||
<Heading className={cx('text-heading')}>Email confirmed</Heading>
|
||||
<div className={cx('flex', 'flex-row', 'items-center', 'gap-0.5')}>
|
||||
<span>Click the link below to go back to your account.</span>
|
||||
<ChakraLink as={Link} to="/account/settings">
|
||||
Back to account
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isFailed && (
|
||||
<Heading className={cx('text-heading')}>
|
||||
An error occurred while processing your request.
|
||||
</Heading>
|
||||
)}
|
||||
</div>
|
||||
</LayoutFull>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateEmailPage
|
165
Voltaserve/ui/src/pages/workspace/workspace-files-page.tsx
Normal file
165
Voltaserve/ui/src/pages/workspace/workspace-files-page.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import FileAPI from '@/client/api/file'
|
||||
import WorkspaceAPI from '@/client/api/workspace'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import Path from '@/components/common/path'
|
||||
import FileCopy from '@/components/file/file-copy'
|
||||
import FileCreate from '@/components/file/file-create'
|
||||
import FileMove from '@/components/file/file-move'
|
||||
import FileRename from '@/components/file/file-rename'
|
||||
import FileToolbar from '@/components/file/file-toolbar'
|
||||
import FileDelete from '@/components/file/fle-idelete'
|
||||
import FileList from '@/components/file/list'
|
||||
import FileSharing from '@/components/file/sharing'
|
||||
import { decodeQuery } from '@/helpers/query'
|
||||
import { filePaginationSteps, filesPaginationStorage } from '@/infra/pagination'
|
||||
import {
|
||||
PagePagination,
|
||||
Spinner,
|
||||
usePageMonitor,
|
||||
usePagePagination,
|
||||
variables,
|
||||
} from '@/lib'
|
||||
import { listUpdated } from '@/store/entities/files'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hook'
|
||||
import { selectionUpdated } from '@/store/ui/files'
|
||||
|
||||
const WorkspaceFilesPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { id, fileId } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const query = decodeQuery(searchParams.get('q') as string)
|
||||
const dispatch = useAppDispatch()
|
||||
const sortBy = useAppSelector((state) => state.ui.files.sortBy)
|
||||
const sortOrder = useAppSelector((state) => state.ui.files.sortOrder)
|
||||
const iconScale = useAppSelector((state) => state.ui.files.iconScale)
|
||||
const { data: workspace } = WorkspaceAPI.useGetById(id, swrConfig())
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: filesPaginationStorage(),
|
||||
steps: filePaginationSteps(),
|
||||
})
|
||||
const {
|
||||
data: list,
|
||||
error,
|
||||
isLoading,
|
||||
} = FileAPI.useList(
|
||||
fileId!,
|
||||
{
|
||||
size,
|
||||
page,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
query: query ? { text: query } : undefined,
|
||||
},
|
||||
swrConfig(),
|
||||
)
|
||||
const { hasPageSwitcher, hasSizeSelector } = usePageMonitor({
|
||||
totalElements: list?.totalElements || 0,
|
||||
totalPages: list?.totalPages || 1,
|
||||
steps,
|
||||
})
|
||||
const hasPagination = hasPageSwitcher || hasSizeSelector
|
||||
|
||||
useEffect(() => {
|
||||
if (list) {
|
||||
dispatch(listUpdated(list))
|
||||
}
|
||||
}, [list, dispatch])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>{workspace && <title>{workspace.name}</title>}</Helmet>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'w-full',
|
||||
'gap-2.5',
|
||||
'grow',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{workspace && fileId ? (
|
||||
<Path
|
||||
rootId={workspace.rootId}
|
||||
fileId={fileId}
|
||||
maxCharacters={30}
|
||||
onClick={(fileId) => {
|
||||
dispatch(selectionUpdated([]))
|
||||
navigate(`/workspace/${workspace.id}/file/${fileId}`)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<FileToolbar list={list} />
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-1.5',
|
||||
'grow',
|
||||
'overflow-y-auto',
|
||||
'overflow-x-hidden',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'w-full',
|
||||
'overflow-y-auto',
|
||||
'overflow-x-hidden',
|
||||
'border-t',
|
||||
'border-t-gray-300',
|
||||
'dark:border-t-gray-600',
|
||||
{
|
||||
'border-b': hasPagination,
|
||||
'border-b-gray-300': hasPagination,
|
||||
'dark:border-b-gray-600': hasPagination,
|
||||
},
|
||||
'pt-1.5',
|
||||
'flex-grow',
|
||||
)}
|
||||
onClick={() => dispatch(selectionUpdated([]))}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-full',
|
||||
)}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
{list && !error ? <FileList list={list} scale={iconScale} /> : null}
|
||||
</div>
|
||||
{list ? (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end', paddingBottom: variables.spacing }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{list ? <FileSharing list={list} /> : null}
|
||||
<FileMove />
|
||||
<FileCopy />
|
||||
<FileCreate />
|
||||
<FileDelete />
|
||||
<FileRename />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceFilesPage
|
51
Voltaserve/ui/src/pages/workspace/workspace-layout.tsx
Normal file
51
Voltaserve/ui/src/pages/workspace/workspace-layout.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Heading, Tab, TabList, Tabs } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import WorkspaceAPI from '@/client/api/workspace'
|
||||
import { swrConfig } from '@/client/options'
|
||||
|
||||
const WorkspaceLayout = () => {
|
||||
const location = useLocation()
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: workspace } = WorkspaceAPI.useGetById(id, swrConfig())
|
||||
const [tabIndex, setTabIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const segments = location.pathname.split('/')
|
||||
const segment = segments[segments.length - 1]
|
||||
if (segment === 'settings') {
|
||||
setTabIndex(1)
|
||||
} else {
|
||||
setTabIndex(0)
|
||||
}
|
||||
}, [location])
|
||||
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'h-full')}>
|
||||
<Heading className={cx('text-heading')}>{workspace.name}</Heading>
|
||||
<Tabs variant="solid-rounded" colorScheme="gray" index={tabIndex}>
|
||||
<TabList>
|
||||
<Tab
|
||||
onClick={() =>
|
||||
navigate(`/workspace/${id}/file/${workspace.rootId}`)
|
||||
}
|
||||
>
|
||||
Files
|
||||
</Tab>
|
||||
<Tab onClick={() => navigate(`/workspace/${id}/settings`)}>
|
||||
Settings
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceLayout
|
159
Voltaserve/ui/src/pages/workspace/workspace-list-page.tsx
Normal file
159
Voltaserve/ui/src/pages/workspace/workspace-list-page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Link,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
Heading,
|
||||
Link as ChakraLink,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Avatar,
|
||||
Badge,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import WorkspaceAPI, { SortOrder } from '@/client/api/workspace'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import { CreateWorkspaceButton } from '@/components/top-bar/top-bar-buttons'
|
||||
import prettyDate from '@/helpers/pretty-date'
|
||||
import { decodeQuery } from '@/helpers/query'
|
||||
import { workspacePaginationStorage } from '@/infra/pagination'
|
||||
import { SectionSpinner, PagePagination, usePagePagination } from '@/lib'
|
||||
|
||||
const WorkspaceListPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const query = decodeQuery(searchParams.get('q') as string)
|
||||
const { page, size, steps, setPage, setSize } = usePagePagination({
|
||||
navigate,
|
||||
location,
|
||||
storage: workspacePaginationStorage(),
|
||||
})
|
||||
const {
|
||||
data: list,
|
||||
error,
|
||||
mutate,
|
||||
} = WorkspaceAPI.useList(
|
||||
{ query, page, size, sortOrder: SortOrder.Desc },
|
||||
swrConfig(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mutate()
|
||||
}, [query, page, size, mutate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Workspaces</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-3.5', 'pb-3.5')}>
|
||||
<Heading className={cx('pl-2', 'text-heading')}>Workspaces</Heading>
|
||||
{!list && error && (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<span>Failed to load workspaces.</span>
|
||||
</div>
|
||||
)}
|
||||
{!list && !error && <SectionSpinner />}
|
||||
{list && list.data.length === 0 && !error ? (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-[300px]',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5', 'items-center')}>
|
||||
<span>There are no workspaces.</span>
|
||||
<CreateWorkspaceButton />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{list && list.data.length > 0 && (
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Organization</Th>
|
||||
<Th>Permission</Th>
|
||||
<Th>Date</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{list.data.map((w) => (
|
||||
<Tr key={w.id}>
|
||||
<Td>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-1.5',
|
||||
'items-center',
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={w.name}
|
||||
size="sm"
|
||||
className={cx('w-[40px]', 'h-[40px]')}
|
||||
/>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
to={`/workspace/${w.id}/file/${w.rootId}`}
|
||||
className={cx('no-underline')}
|
||||
>
|
||||
<span>{w.name}</span>
|
||||
</ChakraLink>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
to={`/organization/${w.organization.id}/member`}
|
||||
className={cx('no-underline')}
|
||||
>
|
||||
{w.organization.name}
|
||||
</ChakraLink>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge>{w.permission}</Badge>
|
||||
</Td>
|
||||
<Td>{prettyDate(w.createTime)}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
{list && (
|
||||
<PagePagination
|
||||
style={{ alignSelf: 'end' }}
|
||||
totalElements={list.totalElements}
|
||||
totalPages={list.totalPages}
|
||||
page={page}
|
||||
size={size}
|
||||
steps={steps}
|
||||
setPage={setPage}
|
||||
setSize={setSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceListPage
|
147
Voltaserve/ui/src/pages/workspace/workspace-settings-page.tsx
Normal file
147
Voltaserve/ui/src/pages/workspace/workspace-settings-page.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import {
|
||||
Divider,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
Progress,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { geEditorPermission } from '@/client/api/permission'
|
||||
import StorageAPI from '@/client/api/storage'
|
||||
import WorkspaceAPI from '@/client/api/workspace'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import WorkspaceDelete from '@/components/workspace/workspace-delete'
|
||||
import WorkspaceEditName from '@/components/workspace/workspace-edit-name'
|
||||
import WorkspaceEditStorageCapacity from '@/components/workspace/workspace-edit-storage-capacity'
|
||||
import prettyBytes from '@/helpers/pretty-bytes'
|
||||
import { IconEdit, IconDelete, SectionSpinner } from '@/lib'
|
||||
|
||||
const EditButton = (props: IconButtonProps) => (
|
||||
<IconButton icon={<IconEdit />} {...props} />
|
||||
)
|
||||
|
||||
const Spacer = () => <div className={cx('grow')} />
|
||||
|
||||
const WorkspaceSettingsPage = () => {
|
||||
const { id } = useParams()
|
||||
const { data: workspace, error: workspaceError } = WorkspaceAPI.useGetById(
|
||||
id,
|
||||
swrConfig(),
|
||||
)
|
||||
const { data: storageUsage, error: storageUsageError } =
|
||||
StorageAPI.useGetWorkspaceUsage(id, swrConfig())
|
||||
const hasEditPermission = useMemo(
|
||||
() => workspace && geEditorPermission(workspace.permission),
|
||||
[workspace],
|
||||
)
|
||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false)
|
||||
const [isStorageCapacityModalOpen, setIsStorageCapacityModalOpen] =
|
||||
useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const sectionClassName = cx('flex', 'flex-col', 'gap-1', 'py-1.5')
|
||||
const rowClassName = cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-1',
|
||||
`h-[40px]`,
|
||||
)
|
||||
|
||||
if (workspaceError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
return <SectionSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{workspace.name}</title>
|
||||
</Helmet>
|
||||
<div className={cx('flex', 'flex-col', 'gap-0')}>
|
||||
<div className={sectionClassName}>
|
||||
<span className={cx('font-bold')}>Storage</span>
|
||||
{storageUsageError && <span>Failed to load storage usage.</span>}
|
||||
{storageUsage && !storageUsageError && (
|
||||
<>
|
||||
<span>
|
||||
{prettyBytes(storageUsage.bytes)} of{' '}
|
||||
{prettyBytes(storageUsage.maxBytes)} used
|
||||
</span>
|
||||
<Progress value={storageUsage.percentage} hasStripe />
|
||||
</>
|
||||
)}
|
||||
{!storageUsage && !storageUsageError && (
|
||||
<>
|
||||
<span>Calculating…</span>
|
||||
<Progress value={0} hasStripe />
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<div className={rowClassName}>
|
||||
<span>Storage capacity</span>
|
||||
<Spacer />
|
||||
<span>{prettyBytes(workspace.storageCapacity)}</span>
|
||||
<EditButton
|
||||
aria-label=""
|
||||
isDisabled={!hasEditPermission}
|
||||
onClick={() => {
|
||||
setIsStorageCapacityModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider className={cx('mb-1.5')} />
|
||||
<span className={cx('font-bold')}>Basics</span>
|
||||
<div className={rowClassName}>
|
||||
<span>Name</span>
|
||||
<Spacer />
|
||||
<span>{workspace.name}</span>
|
||||
<EditButton
|
||||
aria-label=""
|
||||
isDisabled={!hasEditPermission}
|
||||
onClick={() => {
|
||||
setIsNameModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={sectionClassName}>
|
||||
<span className={cx('font-bold')}>Advanced</span>
|
||||
<div className={rowClassName}>
|
||||
<span>Delete permanently</span>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<IconDelete />}
|
||||
variant="solid"
|
||||
colorScheme="red"
|
||||
isDisabled={!hasEditPermission}
|
||||
aria-label=""
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceEditName
|
||||
open={isNameModalOpen}
|
||||
workspace={workspace}
|
||||
onClose={() => setIsNameModalOpen(false)}
|
||||
/>
|
||||
<WorkspaceEditStorageCapacity
|
||||
open={isStorageCapacityModalOpen}
|
||||
workspace={workspace}
|
||||
onClose={() => setIsStorageCapacityModalOpen(false)}
|
||||
/>
|
||||
<WorkspaceDelete
|
||||
open={isDeleteModalOpen}
|
||||
workspace={workspace}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceSettingsPage
|
Reference in New Issue
Block a user