ajout app

This commit is contained in:
2024-04-17 20:22:30 +02:00
parent cc017cfc5e
commit f9d05a2fd3
8025 changed files with 729805 additions and 0 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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

View 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

View 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

View File

@ -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

View File

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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