diff options
Diffstat (limited to 'src/app/(main)/admin/users')
| -rw-r--r-- | src/app/(main)/admin/users/UserAddButton.tsx | 32 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/UserAddForm.tsx | 71 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/UserDeleteButton.tsx | 35 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/UserDeleteForm.tsx | 41 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/UsersDataTable.tsx | 14 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/UsersPage.tsx | 24 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/UsersTable.tsx | 84 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/[userId]/UserEditForm.tsx | 73 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/[userId]/UserHeader.tsx | 9 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/[userId]/UserPage.tsx | 19 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/[userId]/UserProvider.tsx | 20 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/[userId]/UserSettings.tsx | 25 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/[userId]/UserWebsites.tsx | 15 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/[userId]/page.tsx | 12 | ||||
| -rw-r--r-- | src/app/(main)/admin/users/page.tsx | 9 |
15 files changed, 483 insertions, 0 deletions
diff --git a/src/app/(main)/admin/users/UserAddButton.tsx b/src/app/(main)/admin/users/UserAddButton.tsx new file mode 100644 index 0000000..0525082 --- /dev/null +++ b/src/app/(main)/admin/users/UserAddButton.tsx @@ -0,0 +1,32 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { UserAddForm } from './UserAddForm'; + +export function UserAddButton({ onSave }: { onSave?: () => void }) { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = () => { + toast(formatMessage(messages.saved)); + touch('users'); + onSave?.(); + }; + + return ( + <DialogTrigger> + <Button variant="primary" data-test="button-create-user"> + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.createUser)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.createUser)} style={{ width: 400 }}> + {({ close }) => <UserAddForm onSave={handleSave} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/admin/users/UserAddForm.tsx b/src/app/(main)/admin/users/UserAddForm.tsx new file mode 100644 index 0000000..6c36551 --- /dev/null +++ b/src/app/(main)/admin/users/UserAddForm.tsx @@ -0,0 +1,71 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + PasswordField, + Select, + TextField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function UserAddForm({ onSave, onClose }) { + const { mutateAsync, error, isPending } = useUpdateQuery(`/users`); + const { formatMessage, labels, getErrorMessage } = useMessages(); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + onSave(data); + onClose(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)}> + <FormField + label={formatMessage(labels.username)} + name="username" + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoComplete="new-username" data-test="input-username" /> + </FormField> + <FormField + label={formatMessage(labels.password)} + name="password" + rules={{ required: formatMessage(labels.required) }} + > + <PasswordField autoComplete="new-password" data-test="input-password" /> + </FormField> + <FormField + label={formatMessage(labels.role)} + name="role" + rules={{ required: formatMessage(labels.required) }} + > + <Select> + <ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly"> + {formatMessage(labels.viewOnly)} + </ListItem> + <ListItem id={ROLES.user} data-test="dropdown-item-user"> + {formatMessage(labels.user)} + </ListItem> + <ListItem id={ROLES.admin} data-test="dropdown-item-admin"> + {formatMessage(labels.admin)} + </ListItem> + </Select> + </FormField> + <FormButtons> + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/admin/users/UserDeleteButton.tsx b/src/app/(main)/admin/users/UserDeleteButton.tsx new file mode 100644 index 0000000..ee8f2c1 --- /dev/null +++ b/src/app/(main)/admin/users/UserDeleteButton.tsx @@ -0,0 +1,35 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useLoginQuery, useMessages } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { UserDeleteForm } from './UserDeleteForm'; + +export function UserDeleteButton({ + userId, + username, + onDelete, +}: { + userId: string; + username: string; + onDelete?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { user } = useLoginQuery(); + + return ( + <DialogTrigger> + <Button isDisabled={userId === user?.id} data-test="button-delete"> + <Icon size="sm"> + <Trash /> + </Icon> + <Text>{formatMessage(labels.delete)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.deleteUser)} style={{ width: 400 }}> + {({ close }) => ( + <UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} /> + )} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/admin/users/UserDeleteForm.tsx b/src/app/(main)/admin/users/UserDeleteForm.tsx new file mode 100644 index 0000000..8f6fd50 --- /dev/null +++ b/src/app/(main)/admin/users/UserDeleteForm.tsx @@ -0,0 +1,41 @@ +import { AlertDialog, Row } from '@umami/react-zen'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; + +export function UserDeleteForm({ + userId, + username, + onSave, + onClose, +}: { + userId: string; + username: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { messages, labels, formatMessage } = useMessages(); + const { mutateAsync } = useDeleteQuery(`/users/${userId}`); + const { touch } = useModified(); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch('users'); + touch(`users:${userId}`); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <AlertDialog + title={formatMessage(labels.delete)} + onConfirm={handleConfirm} + onCancel={onClose} + confirmLabel={formatMessage(labels.delete)} + isDanger + > + <Row gap="1">{formatMessage(messages.confirmDelete, { target: username })}</Row> + </AlertDialog> + ); +} diff --git a/src/app/(main)/admin/users/UsersDataTable.tsx b/src/app/(main)/admin/users/UsersDataTable.tsx new file mode 100644 index 0000000..8467bd2 --- /dev/null +++ b/src/app/(main)/admin/users/UsersDataTable.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useUsersQuery } from '@/components/hooks'; +import { UsersTable } from './UsersTable'; + +export function UsersDataTable({ showActions }: { showActions?: boolean; children?: ReactNode }) { + const queryResult = useUsersQuery(); + + return ( + <DataGrid query={queryResult} allowSearch={true}> + {({ data }) => <UsersTable data={data} showActions={showActions} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/admin/users/UsersPage.tsx b/src/app/(main)/admin/users/UsersPage.tsx new file mode 100644 index 0000000..7e1b0f4 --- /dev/null +++ b/src/app/(main)/admin/users/UsersPage.tsx @@ -0,0 +1,24 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { UserAddButton } from './UserAddButton'; +import { UsersDataTable } from './UsersDataTable'; + +export function UsersPage() { + const { formatMessage, labels } = useMessages(); + + const handleSave = () => {}; + + return ( + <Column gap="6" margin="2"> + <PageHeader title={formatMessage(labels.users)}> + <UserAddButton onSave={handleSave} /> + </PageHeader> + <Panel> + <UsersDataTable /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/admin/users/UsersTable.tsx b/src/app/(main)/admin/users/UsersTable.tsx new file mode 100644 index 0000000..9c10f3e --- /dev/null +++ b/src/app/(main)/admin/users/UsersTable.tsx @@ -0,0 +1,84 @@ +import { DataColumn, DataTable, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { useState } from 'react'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages } from '@/components/hooks'; +import { Edit, Trash } from '@/components/icons'; +import { MenuButton } from '@/components/input/MenuButton'; +import { ROLES } from '@/lib/constants'; +import { UserDeleteForm } from './UserDeleteForm'; + +export function UsersTable({ + data = [], + showActions = true, +}: { + data: any[]; + showActions?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const [deleteUser, setDeleteUser] = useState(null); + + return ( + <> + <DataTable data={data}> + <DataColumn id="username" label={formatMessage(labels.username)} width="2fr"> + {(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>} + </DataColumn> + <DataColumn id="role" label={formatMessage(labels.role)}> + {(row: any) => + formatMessage( + labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown, + ) + } + </DataColumn> + <DataColumn id="websites" label={formatMessage(labels.websites)}> + {(row: any) => row._count.websites} + </DataColumn> + <DataColumn id="created" label={formatMessage(labels.created)}> + {(row: any) => <DateDistance date={new Date(row.createdAt)} />} + </DataColumn> + {showActions && ( + <DataColumn id="action" align="end" width="100px"> + {(row: any) => { + const { id } = row; + + return ( + <MenuButton> + <MenuItem href={`/admin/users/${id}`} data-test="link-button-edit"> + <Row alignItems="center" gap> + <Icon> + <Edit /> + </Icon> + <Text>{formatMessage(labels.edit)}</Text> + </Row> + </MenuItem> + <MenuItem + id="delete" + onAction={() => setDeleteUser(row)} + data-test="link-button-delete" + > + <Row alignItems="center" gap> + <Icon> + <Trash /> + </Icon> + <Text>{formatMessage(labels.delete)}</Text> + </Row> + </MenuItem> + </MenuButton> + ); + }} + </DataColumn> + )} + </DataTable> + <Modal isOpen={!!deleteUser}> + <UserDeleteForm + userId={deleteUser?.id} + username={deleteUser?.username} + onClose={() => { + setDeleteUser(null); + }} + /> + </Modal> + </> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx new file mode 100644 index 0000000..28bf030 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx @@ -0,0 +1,73 @@ +import { + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + PasswordField, + Select, + TextField, +} from '@umami/react-zen'; +import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) { + const { formatMessage, labels, messages, getMessage } = useMessages(); + const user = useUser(); + const { user: login } = useLoginQuery(); + + const { mutateAsync, error, toast, touch } = useUpdateQuery(`/users/${userId}`); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('users'); + touch(`user:${user.id}`); + onSave?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}> + <FormField name="username" label={formatMessage(labels.username)}> + <TextField data-test="input-username" /> + </FormField> + <FormField + name="password" + label={formatMessage(labels.password)} + rules={{ + minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) }, + }} + > + <PasswordField autoComplete="new-password" data-test="input-password" /> + </FormField> + + {user.id !== login.id && ( + <FormField + name="role" + label={formatMessage(labels.role)} + rules={{ required: formatMessage(labels.required) }} + > + <Select defaultValue={user.role}> + <ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly"> + {formatMessage(labels.viewOnly)} + </ListItem> + <ListItem id={ROLES.user} data-test="dropdown-item-user"> + {formatMessage(labels.user)} + </ListItem> + <ListItem id={ROLES.admin} data-test="dropdown-item-admin"> + {formatMessage(labels.admin)} + </ListItem> + </Select> + </FormField> + )} + <FormButtons> + <FormSubmitButton data-test="button-submit" variant="primary"> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserHeader.tsx b/src/app/(main)/admin/users/[userId]/UserHeader.tsx new file mode 100644 index 0000000..1f82897 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserHeader.tsx @@ -0,0 +1,9 @@ +import { PageHeader } from '@/components/common/PageHeader'; +import { useUser } from '@/components/hooks'; +import { User } from '@/components/icons'; + +export function UserHeader() { + const user = useUser(); + + return <PageHeader title={user?.username} icon={<User />} />; +} diff --git a/src/app/(main)/admin/users/[userId]/UserPage.tsx b/src/app/(main)/admin/users/[userId]/UserPage.tsx new file mode 100644 index 0000000..5e0f8d1 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserPage.tsx @@ -0,0 +1,19 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { UserHeader } from '@/app/(main)/admin/users/[userId]/UserHeader'; +import { Panel } from '@/components/common/Panel'; +import { UserProvider } from './UserProvider'; +import { UserSettings } from './UserSettings'; + +export function UserPage({ userId }: { userId: string }) { + return ( + <UserProvider userId={userId}> + <Column gap="6"> + <UserHeader /> + <Panel> + <UserSettings userId={userId} /> + </Panel> + </Column> + </UserProvider> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserProvider.tsx b/src/app/(main)/admin/users/[userId]/UserProvider.tsx new file mode 100644 index 0000000..ea01915 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserProvider.tsx @@ -0,0 +1,20 @@ +import { Loading } from '@umami/react-zen'; +import { createContext, type ReactNode } from 'react'; +import { useUserQuery } from '@/components/hooks/queries/useUserQuery'; +import type { User } from '@/generated/prisma/client'; + +export const UserContext = createContext<User>(null); + +export function UserProvider({ userId, children }: { userId: string; children: ReactNode }) { + const { data: user, isFetching, isLoading } = useUserQuery(userId); + + if (isFetching && isLoading) { + return <Loading placement="absolute" />; + } + + if (!user) { + return null; + } + + return <UserContext.Provider value={user}>{children}</UserContext.Provider>; +} diff --git a/src/app/(main)/admin/users/[userId]/UserSettings.tsx b/src/app/(main)/admin/users/[userId]/UserSettings.tsx new file mode 100644 index 0000000..3f17f3e --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserSettings.tsx @@ -0,0 +1,25 @@ +import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { UserEditForm } from './UserEditForm'; +import { UserWebsites } from './UserWebsites'; + +export function UserSettings({ userId }: { userId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <Column gap="6"> + <Tabs> + <TabList> + <Tab id="details">{formatMessage(labels.details)}</Tab> + <Tab id="websites">{formatMessage(labels.websites)}</Tab> + </TabList> + <TabPanel id="details" style={{ width: 500 }}> + <UserEditForm userId={userId} /> + </TabPanel> + <TabPanel id="websites"> + <UserWebsites userId={userId} /> + </TabPanel> + </Tabs> + </Column> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserWebsites.tsx b/src/app/(main)/admin/users/[userId]/UserWebsites.tsx new file mode 100644 index 0000000..eeb173e --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserWebsites.tsx @@ -0,0 +1,15 @@ +import { WebsitesTable } from '@/app/(main)/websites/WebsitesTable'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useUserWebsitesQuery } from '@/components/hooks'; + +export function UserWebsites({ userId }) { + const queryResult = useUserWebsitesQuery({ userId }); + + return ( + <DataGrid query={queryResult}> + {({ data }) => ( + <WebsitesTable data={data} showActions={true} allowEdit={true} allowView={true} /> + )} + </DataGrid> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/page.tsx b/src/app/(main)/admin/users/[userId]/page.tsx new file mode 100644 index 0000000..16c9f36 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { UserPage } from './UserPage'; + +export default async function ({ params }: { params: Promise<{ userId: string }> }) { + const { userId } = await params; + + return <UserPage userId={userId} />; +} + +export const metadata: Metadata = { + title: 'User', +}; diff --git a/src/app/(main)/admin/users/page.tsx b/src/app/(main)/admin/users/page.tsx new file mode 100644 index 0000000..96e69eb --- /dev/null +++ b/src/app/(main)/admin/users/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next'; +import { UsersPage } from './UsersPage'; + +export default function () { + return <UsersPage />; +} +export const metadata: Metadata = { + title: 'Users', +}; |