diff --git a/emulators/auth_export/accounts.json b/emulators/auth_export/accounts.json index 8f30ff5b..ab381e8e 100644 --- a/emulators/auth_export/accounts.json +++ b/emulators/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1651129533821","displayName":"Admin User","photoUrl":"","customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","email":"admin@example.com"}],"validSince":"1651117599","email":"admin@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-04-29T00:47:58.946Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651023059443","displayName":"Editor User","providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"}],"validSince":"1651117599","email":"editor@example.com","emailVerified":true,"disabled":false}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1651297974462","displayName":"Admin User","photoUrl":"","customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","displayName":"Admin User","email":"admin@example.com"}],"validSince":"1651195467","email":"admin@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-04-30T08:44:58.158Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651223181908","displayName":"Editor User","providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"}],"validSince":"1651195467","email":"editor@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-04-30T08:44:53.855Z"}]} \ No newline at end of file diff --git a/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index b233d47d..5f3f7b4d 100644 Binary files a/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/emulators/firestore_export/all_namespaces/all_kinds/output-0 b/emulators/firestore_export/all_namespaces/all_kinds/output-0 index 5b815ff2..18df228e 100644 Binary files a/emulators/firestore_export/all_namespaces/all_kinds/output-0 and b/emulators/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/emulators/firestore_export/firestore_export.overall_export_metadata b/emulators/firestore_export/firestore_export.overall_export_metadata index 93146c62..a87e2efa 100644 Binary files a/emulators/firestore_export/firestore_export.overall_export_metadata and b/emulators/firestore_export/firestore_export.overall_export_metadata differ diff --git a/eslint-local-rules.js b/eslint-local-rules.js index 9919621d..b3a7b8f6 100644 --- a/eslint-local-rules.js +++ b/eslint-local-rules.js @@ -1,5 +1,7 @@ const JOTAI_USE_ATOM_HOOKS = [ "useAtom", + "useSetAtom", + "useAtomValue", "useUpdateAtom", "useAtomValue", "useResetAtom", diff --git a/package.json b/package.json index 15f811bb..df92a71b 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,9 @@ "dompurify": "^2.3.6", "firebase": "^9.6.11", "firebaseui": "^6.0.1", - "jotai": "^1.6.4", + "jotai": "^1.6.5", "lodash-es": "^4.17.21", + "match-sorter": "^6.3.1", "notistack": "^2.0.4", "react": "^18.0.0", "react-color-palette": "^6.2.0", diff --git a/src/App.tsx b/src/App.tsx index 60ab41b6..8b62793d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,8 @@ import { useAtom } from "jotai"; import Loading from "@src/components/Loading"; import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase"; +import ConfirmDialog from "@src/components/ConfirmDialog"; +import RowyRunModal from "@src/components/RowyRunModal"; import NotFound from "@src/pages/NotFound"; import RequireAuth from "@src/layouts/RequireAuth"; import Navigation from "@src/layouts/Navigation"; @@ -32,6 +34,8 @@ const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettings" /* // prettier-ignore const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */)); // prettier-ignore +const UserManagementPage = lazy(() => import("@src/pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */)); +// prettier-ignore // const RowyRunTestPage = lazy(() => import("@src/pages/RowyRunTest" /* webpackChunkName: "RowyRunTestPage" */)); export default function App() { @@ -40,6 +44,8 @@ export default function App() { return ( }> + + {currentUser === undefined ? ( @@ -76,10 +82,14 @@ export default function App() { /> } /> } /> + } /> {/* } /> */} - } /> + + + + {/* } /> */} )} diff --git a/src/atoms/project.ts b/src/atoms/project.ts index 68277ed3..57dd393e 100644 --- a/src/atoms/project.ts +++ b/src/atoms/project.ts @@ -1,9 +1,10 @@ -import { atom } from "jotai"; +import { atom, PrimitiveAtom } from "jotai"; import { sortBy } from "lodash-es"; import { ThemeOptions } from "@mui/material"; import { userRolesAtom } from "./auth"; -import { UpdateFunction } from "./types"; +import { UpdateDocFunction, UpdateCollectionFunction } from "./types"; +import { UserSettings } from "./user"; export const projectIdAtom = atom(""); @@ -27,7 +28,7 @@ export type PublicSettings = Partial<{ export const publicSettingsAtom = atom({}); /** Stores a function that updates public settings */ export const updatePublicSettingsAtom = - atom | null>(null); + atom | null>(null); /** Project settings are visible to authenticated users */ export type ProjectSettings = Partial<{ @@ -48,7 +49,7 @@ export type ProjectSettings = Partial<{ export const projectSettingsAtom = atom({}); /** Stores a function that updates project settings */ export const updateProjectSettingsAtom = - atom | null>(null); + atom | null>(null); /** Table settings stored in project settings */ export type TableSettings = { @@ -98,3 +99,9 @@ export const rolesAtom = atom((get) => ) ) ); + +/** User management page: all users */ +export const allUsersAtom = atom([]); +/** Stores a function that updates a user document */ +export const updateUserAtom = + atom | null>(null); diff --git a/src/atoms/rowyRun.ts b/src/atoms/rowyRun.ts index e024fcc6..4e51305a 100644 --- a/src/atoms/rowyRun.ts +++ b/src/atoms/rowyRun.ts @@ -38,11 +38,15 @@ export interface IRowyRunRequestProps { json?: boolean; /** Optionally pass an abort signal to abort the request */ signal?: AbortSignal; + /** Optionally pass a callback that’s called if Rowy Run not set up */ + handleNotSetUp?: () => void; } /** * An atom that returns a function to call Rowy Run endpoints using the URL - * defined in project settings and retrieving a JWT token + * defined in project settings and retrieving a JWT token. + * + * Returns `false` if user not signed in or Rowy Run not set up. * * @example Basic usage: * ``` @@ -65,10 +69,12 @@ export const rowyRunAtom = atom((get) => { body, signal, json = true, - }: IRowyRunRequestProps): Promise => { + handleNotSetUp, + }: IRowyRunRequestProps): Promise => { if (!currentUser) { console.log("Rowy Run: Not signed in"); - return; + if (handleNotSetUp) handleNotSetUp(); + return false; } const authToken = await getIdTokenResult(currentUser!, forceRefresh); @@ -79,7 +85,8 @@ export const rowyRunAtom = atom((get) => { : rowyRunUrl; if (!serviceUrl) { console.log("Rowy Run: Not set up"); - return; + if (handleNotSetUp) handleNotSetUp(); + return false; } const { method, path } = route; diff --git a/src/atoms/types.d.ts b/src/atoms/types.d.ts index 8d85b83e..04694f3c 100644 --- a/src/atoms/types.d.ts +++ b/src/atoms/types.d.ts @@ -1 +1,6 @@ -export type UpdateFunction = (update: Partial) => Promise; +export type UpdateDocFunction = (update: Partial) => Promise; + +export type UpdateCollectionFunction = ( + path: string, + update: Partial +) => Promise; diff --git a/src/atoms/ui.ts b/src/atoms/ui.ts index b1a02488..8ed44cbb 100644 --- a/src/atoms/ui.ts +++ b/src/atoms/ui.ts @@ -1,6 +1,77 @@ +import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; +import { DialogProps, ButtonProps } from "@mui/material"; + /** Nav open state stored in local storage. */ export const navOpenAtom = atomWithStorage("__ROWY__NAV_OPEN", false); /** Nav pinned state stored in local storage. */ export const navPinnedAtom = atomWithStorage("__ROWY__NAV_PINNED", false); + +export type ConfirmDialogProps = { + open: boolean; + + title?: string; + /** Pass a string to display basic styled text */ + body?: React.ReactNode; + + /** Callback called when user clicks confirm */ + handleConfirm?: () => void; + /** Optionally override confirm button text */ + confirm?: string | JSX.Element; + /** Optionally require user to type this string to enable the confirm button */ + confirmationCommand?: string; + /** Optionally set confirm button color */ + confirmColor?: ButtonProps["color"]; + + /** Callback called when user clicks cancel */ + handleCancel?: () => void; + /** Optionally override cancel button text */ + cancel?: string; + /** Optionally hide cancel button */ + hideCancel?: boolean; + + /** Optionally set dialog max width */ + maxWidth?: DialogProps["maxWidth"]; +}; +/** + * Open a confirm dialog + * + * @example Basic usage: + * ``` + * const confirm = useSetAtom(confirmDialogAtom, globalScope); + * confirm({ + * open: true, + * handleConfirm: () => ... + * }); + * ``` + */ +export const confirmDialogAtom = atom({ open: false }); + +/** + * Open global Rowy Run modal if feature not available + * {@link openRowyRunModalAtom | Use `openRowyRunModalAtom` to open} + */ +export const rowyRunModalAtom = atom({ open: false, feature: "", version: "" }); +/** + * Helper atom to open Rowy Run Modal + * + * @example Basic usage: + * ``` + * const openRowyRun = useSetAtom(openRowyRunModalAtom, globalScope); + * openRowyRun({ + * feature: ... + * version: ... + * }); + * ``` + */ +export const openRowyRunModalAtom = atom( + null, + (_, set, update?: Partial>) => { + set(rowyRunModalAtom, { + open: true, + feature: update?.feature || "", + version: update?.version || "", + }); + } +); diff --git a/src/atoms/user.ts b/src/atoms/user.ts index 65db3bb7..e6db5dd8 100644 --- a/src/atoms/user.ts +++ b/src/atoms/user.ts @@ -6,10 +6,11 @@ import { ThemeOptions } from "@mui/material"; import themes from "@src/theme"; import { publicSettingsAtom } from "./project"; import { TableFilter } from "./table"; -import { UpdateFunction } from "./types"; +import { UpdateDocFunction } from "./types"; /** User info and settings */ export type UserSettings = Partial<{ + _rowy_id: string; /** Synced from user auth info */ user: { email: string; @@ -34,9 +35,8 @@ export type UserSettings = Partial<{ /** User info and settings */ export const userSettingsAtom = atom({}); /** Stores a function that updates user settings */ -export const updateUserSettingsAtom = atom | null>( - null -); +export const updateUserSettingsAtom = + atom | null>(null); /** * Stores which theme is currently active, based on user or OS setting. diff --git a/src/components/AccessDenied.tsx b/src/components/AccessDenied.tsx new file mode 100644 index 00000000..00de18d0 --- /dev/null +++ b/src/components/AccessDenied.tsx @@ -0,0 +1,93 @@ +import { useAtom } from "jotai"; + +import { + Typography, + Stack, + Avatar, + Alert, + Divider, + Link as MuiLink, + Button, +} from "@mui/material"; +import SecurityIcon from "@mui/icons-material/SecurityOutlined"; + +import EmptyState from "@src/components/EmptyState"; + +import { + globalScope, + currentUserAtom, + userRolesAtom, +} from "@src/atoms/globalScope"; +import { WIKI_LINKS } from "@src/constants/externalLinks"; +import { ROUTES } from "@src/constants/routes"; + +export default function AccessDenied() { + const [currentUser] = useAtom(currentUserAtom, globalScope); + const [userRoles] = useAtom(userRolesAtom, globalScope); + + if (!currentUser) window.location.reload(); + + return ( + +
+ + +
+ {currentUser?.displayName} + {currentUser?.email} +
+
+ + {(!userRoles || userRoles.length === 0) && ( + + Your account has no roles set + + )} +
+ + + You do not have access to this project. Please contact the project + owner. + + + + + + OR + + + + If you are the project owner, please follow{" "} + + these instructions + {" "} + to set up this project’s security rules. + + + } + sx={{ + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + bgcolor: "background.default", + zIndex: 9999, + }} + /> + ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..65099a2b --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { useAtom } from "jotai"; + +import { + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, + Button, +} from "@mui/material"; + +import { SlideTransitionMui } from "@src/components/Modal/SlideTransition"; +import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope"; + +/** + * Display a confirm dialog using `confirmDialogAtom` in `globalState` + * {@link confirmDialogAtom | See usage example} + */ +export default function ConfirmDialog() { + const [ + { + open, + + title = "Are you sure?", + body, + + handleConfirm, + confirm = "Confirm", + confirmationCommand, + confirmColor, + + handleCancel, + cancel = "Cancel", + hideCancel, + + maxWidth = "xs", + }, + setState, + ] = useAtom(confirmDialogAtom, globalScope); + const handleClose = () => setState({ open: false }); + + const [dryText, setDryText] = useState(""); + + return ( + { + if (reason === "backdropClick" || reason === "escapeKeyDown") return; + else handleClose(); + }} + maxWidth={maxWidth} + TransitionComponent={SlideTransitionMui} + style={{ cursor: "default" }} + > + {title} + + + {typeof body === "string" ? ( + {body} + ) : ( + body + )} + {confirmationCommand && ( + setDryText(e.target.value)} + autoFocus + label={`Type ${confirmationCommand} below to continue:`} + placeholder={confirmationCommand} + fullWidth + id="dryText" + sx={{ mt: 3 }} + /> + )} + + + + {!hideCancel && ( + + )} + + + + ); +} diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx index 02ce85b1..e800aa3e 100644 --- a/src/components/ErrorFallback.tsx +++ b/src/components/ErrorFallback.tsx @@ -5,6 +5,7 @@ import ReloadIcon from "@mui/icons-material/Refresh"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState"; +import AccessDenied from "@src/components/AccessDenied"; import meta from "@root/package.json"; export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {} @@ -36,12 +37,17 @@ export default function ErrorFallback({ /> ); + if ((error as any).code === "permission-denied") return ; + return ( - {error.message} + + {(error as any).code && {(error as any).code}: } + {error.message} + + + {!userRoles.includes("ADMIN") && ( + + Contact the project owner to set up Rowy Run + + )} + + } + /> + ); +} diff --git a/src/components/Settings/UserManagement/InviteUser.tsx b/src/components/Settings/UserManagement/InviteUser.tsx new file mode 100644 index 00000000..0f662054 --- /dev/null +++ b/src/components/Settings/UserManagement/InviteUser.tsx @@ -0,0 +1,158 @@ +import { useState } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { Link } from "react-router-dom"; +import { useSnackbar } from "notistack"; + +import { + Button, + DialogContentText, + Link as MuiLink, + TextField, + Typography, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/PersonAddOutlined"; + +import MultiSelect from "@rowy/multiselect"; +import Modal from "@src/components/Modal"; + +import { + globalScope, + rolesAtom, + projectSettingsAtom, + rowyRunAtom, + openRowyRunModalAtom, +} from "@src/atoms/globalScope"; +import routes from "@src/constants/routes"; +import { runRoutes } from "@src/constants/runRoutes"; + +export default function InviteUser() { + const [projectRoles] = useAtom(rolesAtom, globalScope); + const [projectSettings] = useAtom(projectSettingsAtom, globalScope); + const [rowyRun] = useAtom(rowyRunAtom, globalScope); + const openRowyRunModal = useSetAtom(openRowyRunModalAtom, globalScope); + const { enqueueSnackbar } = useSnackbar(); + + const [open, setOpen] = useState(false); + const [status, setStatus] = useState<"LOADING" | string>(""); + + const [email, setEmail] = useState(""); + const [roles, setRoles] = useState([]); + + const handleInvite = async () => { + try { + setStatus("LOADING"); + const res = await rowyRun({ + route: runRoutes.inviteUser, + body: { email, roles }, + }); + if (!res.success) throw new Error(res.message); + setStatus(""); + setOpen(false); + enqueueSnackbar(`Sent invite to: ${email}`); + } catch (e: any) { + console.error(e); + setStatus("Failed to invite user: " + e.message); + } + }; + + return ( + <> + + + {open && ( + setOpen(false)} + maxWidth="xs" + body={ + <> + + Invite a user to join your project via email. + + + They can sign up with any of the sign-in options{" "} + + you have enabled + + , as long as they use the same email address. + + + setEmail(e.target.value)} + fullWidth + placeholder="name@example.com" + sx={{ mt: 3 }} + /> + + { + if (Array.isArray(roles)) { + if (roles.length >= 1) return roles.join(", "); + return ( + + Set roles… + + ); + } + return null; + }, + }, + sx: { mt: 3 }, + }} + /> + + } + footer={ + status !== "LOADING" && + typeof status === "string" && ( + + {status} + + ) + } + actions={{ + primary: { + children: "Invite", + disabled: !email || roles.length === 0, + loading: status === "LOADING", + type: "submit", + onClick: handleInvite, + }, + }} + /> + )} + + ); +} diff --git a/src/components/Settings/UserManagement/UserItem.tsx b/src/components/Settings/UserManagement/UserItem.tsx new file mode 100644 index 00000000..38177f01 --- /dev/null +++ b/src/components/Settings/UserManagement/UserItem.tsx @@ -0,0 +1,215 @@ +import { useState } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { useSnackbar } from "notistack"; + +import { + ListItem, + ListItemAvatar, + Avatar, + ListItemText, + Tooltip, + IconButton, + Typography, +} from "@mui/material"; +import CopyIcon from "@src/assets/icons/Copy"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; + +import MultiSelect from "@rowy/multiselect"; + +import { + globalScope, + rolesAtom, + projectSettingsAtom, + rowyRunAtom, + openRowyRunModalAtom, + UserSettings, + updateUserAtom, + confirmDialogAtom, +} from "@src/atoms/globalScope"; +import { runRoutes } from "@src/constants/runRoutes"; +import { USERS } from "@src/config/dbPaths"; + +export default function UserItem({ + _rowy_id, + user, + roles: rolesProp, +}: UserSettings) { + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const openRowyRunModal = useSetAtom(openRowyRunModalAtom, globalScope); + + const [projectRoles] = useAtom(rolesAtom, globalScope); + const [projectSettings] = useAtom(projectSettingsAtom, globalScope); + const [rowyRun] = useAtom(rowyRunAtom, globalScope); + const [updateUser] = useAtom(updateUserAtom, globalScope); + + const [value, setValue] = useState(Array.isArray(rolesProp) ? rolesProp : []); + const allRoles = new Set(["ADMIN", ...(projectRoles ?? []), ...value]); + + const handleSave = async () => { + try { + if (!user) throw new Error("User is not defined"); + if (JSON.stringify(value) === JSON.stringify(rolesProp)) return; + + const loadingSnackbarId = enqueueSnackbar("Setting roles…"); + + const res = await rowyRun?.({ + route: runRoutes.setUserRoles, + body: { email: user!.email, roles: value }, + }); + if (res.success) { + if (!updateUser) throw new Error("Could not update user document"); + await updateUser(_rowy_id!, { roles: value }); + closeSnackbar(loadingSnackbarId); + enqueueSnackbar(`Set roles for ${user!.email}: ${value.join(", ")}`); + } + } catch (e: any) { + console.error(e); + enqueueSnackbar(`Failed to set roles for ${user!.email}: ${e.message}`); + } + }; + + const listItemChildren = ( + <> + + + {user?.displayName ? user.displayName[0] : undefined} + + + *": { userSelect: "all" }, + }} + primaryTypographyProps={{ variant: "body1" }} + /> + + ); + + const handleDelete = async () => { + if (!projectSettings.rowyRunUrl) { + openRowyRunModal({ feature: "User Management" }); + return; + } + + confirm({ + open: true, + title: "Delete user?", + body: ( + <> + + You will delete the user in Firebase Authentication and the + corresponding user document in {USERS}. + + ), + confirm: "Delete", + confirmColor: "error", + handleConfirm: async () => { + if (!user) return; + const loadingSnackbarId = enqueueSnackbar("Deleting user…"); + const response = await rowyRun({ + route: runRoutes.deleteUser, + body: { email: user.email }, + }); + closeSnackbar(loadingSnackbarId); + if (response) enqueueSnackbar(`Deleted user: ${user.email}`); + }, + }); + }; + + return ( + + { + if (Array.isArray(value)) { + if (value.length === 1) return value[0]; + if (value.length > 1) return `${value.length} roles`; + return ( + + No roles + + ); + } + return null; + }, + }, + + fullWidth: false, + sx: { + mr: 0.5, + + "&& .MuiInputLabel-root": { + opacity: 0, + mt: -3, + }, + + "&& .MuiFilledInput-root": { + bgcolor: "transparent", + boxShadow: 0, + "&::before": { content: "none" }, + + "&:hover, &.Mui-focused": { bgcolor: "action.hover" }, + }, + "&& .MuiSelect-select.MuiFilledInput-input": { + typography: "button", + pl: 1, + pr: 3.25, + }, + "&& .MuiSelect-icon": { + right: 2, + }, + }, + }} + onClose={handleSave} + /> + + + { + if (!_rowy_id) return; + await navigator.clipboard.writeText(_rowy_id); + enqueueSnackbar(`Copied UID for ${user?.email}: ${_rowy_id}`); + }} + > + + + + + + + + + + } + sx={{ + pr: 1, + + "& .MuiListItemSecondaryAction-root": { + position: "static", + transform: "none", + marginLeft: "auto", + + display: "flex", + alignItems: "center", + }, + }} + /> + ); +} diff --git a/src/components/Settings/UserManagement/UserSkeleton.tsx b/src/components/Settings/UserManagement/UserSkeleton.tsx new file mode 100644 index 00000000..5ea70e8b --- /dev/null +++ b/src/components/Settings/UserManagement/UserSkeleton.tsx @@ -0,0 +1,40 @@ +import { + Skeleton, + ListItem, + ListItemAvatar, + Avatar, + ListItemText, + Stack, +} from "@mui/material"; + +export default function UserSkeleton() { + return ( + + + + + + + } + secondary={} + /> + + } + secondaryAction={ + + + + + + } + /> + ); +} diff --git a/src/hooks/useBasicSearch.ts b/src/hooks/useBasicSearch.ts new file mode 100644 index 00000000..cb61aebc --- /dev/null +++ b/src/hooks/useBasicSearch.ts @@ -0,0 +1,18 @@ +import { useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { matchSorter } from "match-sorter"; + +export function useBasicSearch( + list: T[], + keys: string[], + debounce: number = 400 +) { + const [query, setQuery] = useState(""); + const handleQuery = useDebouncedCallback(setQuery, debounce); + + const results = query ? matchSorter(list, query, { keys }) : list; + + return [results, query, handleQuery] as const; +} + +export default useBasicSearch; diff --git a/src/hooks/useFirestoreCollectionWithAtom.ts b/src/hooks/useFirestoreCollectionWithAtom.ts new file mode 100644 index 00000000..a0e3db4e --- /dev/null +++ b/src/hooks/useFirestoreCollectionWithAtom.ts @@ -0,0 +1,153 @@ +import { useEffect } from "react"; +import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; +import { Scope } from "jotai/core/atom"; +import { RESET } from "jotai/utils"; +import { + query, + collection, + where, + orderBy, + DocumentData, + onSnapshot, + FirestoreError, + setDoc, + doc, + CollectionReference, +} from "firebase/firestore"; +import { useErrorHandler } from "react-error-boundary"; + +import { globalScope } from "@src/atoms/globalScope"; +import { UpdateCollectionFunction } from "@src/atoms/types"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; + +/** Options for {@link useFirestoreCollectionWithAtom} */ +interface IUseFirestoreCollectionWithAtomOptions { + /** Additional path segments appended to the path. If any are undefined, the listener isn’t created at all. */ + pathSegments?: Array; + /** Attach filters to the query */ + filters?: Parameters[]; + /** Attach orders to the query */ + orders?: Parameters[]; + /** Called when an error occurs. Make sure to wrap in useCallback! If not provided, errors trigger the nearest ErrorBoundary. */ + onError?: (error: FirestoreError) => void; + /** Optionally disable Suspense */ + disableSuspense?: boolean; + /** Set this atom’s value to a function that updates the document. Uses same scope as `dataScope`. */ + updateDataAtom?: PrimitiveAtom | null>; +} + +/** + * Attaches a listener for a Firestore collection and unsubscribes on unmount. + * Gets the Firestore instance initiated in globalScope. + * Updates an atom and Suspends that atom until the first snapshot is received. + * + * @param dataAtom - Atom to store data in + * @param dataScope - Atom scope + * @param path - Collection path. If falsy, the listener isn’t created at all. + * @param options - {@link IUseFirestoreCollectionWithAtomOptions} + */ +export function useFirestoreCollectionWithAtom( + dataAtom: PrimitiveAtom, + dataScope: Scope | undefined, + path: string | undefined, + options?: IUseFirestoreCollectionWithAtomOptions +) { + const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); + const setDataAtom = useSetAtom(dataAtom, dataScope); + const setUpdateDataAtom = useSetAtom( + options?.updateDataAtom || (dataAtom as any), + globalScope + ); + const handleError = useErrorHandler(); + + // Destructure options so they can be used as useEffect dependencies + const { + pathSegments, + filters, + orders, + onError, + disableSuspense, + updateDataAtom, + } = options || {}; + + useEffect(() => { + if (!path || (Array.isArray(pathSegments) && pathSegments.some((x) => !x))) + return; + + let suspended = false; + + // Suspend data atom until we get the first snapshot + if (!disableSuspense) { + setDataAtom(new Promise(() => {}) as unknown as T[]); + suspended = true; + } + + // Create a collection reference to use in `updateDataAtom` + const collectionRef = collection( + firebaseDb, + path, + ...((pathSegments as string[]) || []) + ) as CollectionReference; + // Create the query with filters and orders + const _query = query( + collectionRef, + ...(filters?.map((filter) => where(...filter)) || []), + ...(orders?.map((order) => orderBy(...order)) || []) + ); + + const unsubscribe = onSnapshot( + _query, + (querySnapshot) => { + try { + // Extract doc data from query + // and add `_rowy_id` and `_rowy_ref` fields + const docs = querySnapshot.docs.map((doc) => ({ + ...doc.data(), + _rowy_id: doc.id, + _rowy_ref: doc.ref, + })); + setDataAtom(docs); + } catch (error) { + if (onError) onError(error as FirestoreError); + else handleError(error); + } + suspended = false; + }, + (error) => { + if (suspended) setDataAtom([]); + if (onError) onError(error); + else handleError(error); + } + ); + + // If `options?.updateDataAtom` was passed, + // set the atom’s value to a function that updates the document + if (updateDataAtom) { + setUpdateDataAtom( + () => (path: string, update: T) => + setDoc(doc(collectionRef, path), update, { merge: true }) + ); + } + + return () => { + unsubscribe(); + // If `options?.updateDataAtom` was passed, + // reset the atom’s value to prevent writes + if (updateDataAtom) setUpdateDataAtom(RESET); + }; + }, [ + firebaseDb, + path, + pathSegments, + filters, + orders, + onError, + setDataAtom, + disableSuspense, + handleError, + updateDataAtom, + setUpdateDataAtom, + ]); +} + +export default useFirestoreCollectionWithAtom; diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index 2322f423..0631e439 100644 --- a/src/hooks/useFirestoreDocWithAtom.ts +++ b/src/hooks/useFirestoreDocWithAtom.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; -import { useAtom, PrimitiveAtom } from "jotai"; +import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; import { Scope } from "jotai/core/atom"; -import { useUpdateAtom, RESET } from "jotai/utils"; +import { RESET } from "jotai/utils"; import { doc, DocumentData, @@ -13,7 +13,7 @@ import { import { useErrorHandler } from "react-error-boundary"; import { globalScope } from "@src/atoms/globalScope"; -import { UpdateFunction } from "@src/atoms/types"; +import { UpdateDocFunction } from "@src/atoms/types"; import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; /** Options for {@link useFirestoreDocWithAtom} */ @@ -27,14 +27,13 @@ interface IUseFirestoreDocWithAtomOptions { /** Optionally create the document if it doesn’t exist with the following data */ createIfNonExistent?: T; /** Set this atom’s value to a function that updates the document. Uses same scope as `dataScope`. */ - updateDataAtom?: PrimitiveAtom | null>; + updateDataAtom?: PrimitiveAtom | null>; } /** - * Attaches a listener for Firestore documents and unsubscribes on unmount. + * Attaches a listener for a Firestore document and unsubscribes on unmount. * Gets the Firestore instance initiated in globalScope. - * Updates an atom and optionally Suspends that atom until the first snapshot - * is received. + * Updates an atom and Suspends that atom until the first snapshot is received. * * @param dataAtom - Atom to store data in * @param dataScope - Atom scope @@ -42,14 +41,14 @@ interface IUseFirestoreDocWithAtomOptions { * @param options - {@link IUseFirestoreDocWithAtomOptions} */ export function useFirestoreDocWithAtom( - dataAtom: PrimitiveAtom, + dataAtom: PrimitiveAtom, dataScope: Scope | undefined, path: string | undefined, options?: IUseFirestoreDocWithAtomOptions ) { const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); - const setDataAtom = useUpdateAtom(dataAtom, dataScope); - const setUpdateDataAtom = useUpdateAtom( + const setDataAtom = useSetAtom(dataAtom, dataScope); + const setUpdateDataAtom = useSetAtom( options?.updateDataAtom || (dataAtom as any), globalScope ); @@ -72,7 +71,7 @@ export function useFirestoreDocWithAtom( // Suspend data atom until we get the first snapshot if (!disableSuspense) { - setDataAtom(new Promise(() => {})); + setDataAtom(new Promise(() => {}) as unknown as T); suspended = true; } @@ -84,13 +83,13 @@ export function useFirestoreDocWithAtom( const unsubscribe = onSnapshot( ref, - (doc) => { + (docSnapshot) => { try { - if (!doc.exists() && !!createIfNonExistent) { - setDoc(doc.ref, createIfNonExistent); + if (!docSnapshot.exists() && !!createIfNonExistent) { + setDoc(docSnapshot.ref, createIfNonExistent); setDataAtom(createIfNonExistent); } else { - setDataAtom(doc.data() || {}); + setDataAtom(docSnapshot.data() || ({} as T)); } } catch (error) { if (onError) onError(error as FirestoreError); @@ -99,7 +98,7 @@ export function useFirestoreDocWithAtom( suspended = false; }, (error) => { - if (suspended) setDataAtom({}); + if (suspended) setDataAtom({} as T); if (onError) onError(error); else handleError(error); } diff --git a/src/pages/JotaiTest.tsx b/src/pages/JotaiTest.tsx index f94cce52..96ada5e6 100644 --- a/src/pages/JotaiTest.tsx +++ b/src/pages/JotaiTest.tsx @@ -41,8 +41,6 @@ function JotaiTest() { const [projectSettings] = useAtom(projectSettingsAtom, globalScope); const [userSettings] = useAtom(userSettingsAtom, globalScope); const [rowyRun] = useAtom(rowyRunAtom, globalScope); - // console.log("publicSettings", publicSettings); - // console.log("userSettings", userSettings); const [count, setCount] = useState(0); const { enqueueSnackbar } = useSnackbar(); diff --git a/src/pages/Settings/UserManagement.tsx b/src/pages/Settings/UserManagement.tsx new file mode 100644 index 00000000..1bd921fe --- /dev/null +++ b/src/pages/Settings/UserManagement.tsx @@ -0,0 +1,113 @@ +import { Suspense } from "react"; +import { useAtom } from "jotai"; +import { TransitionGroup } from "react-transition-group"; + +import { + Container, + Stack, + Typography, + Paper, + List, + Fade, + Collapse, +} from "@mui/material"; + +import FloatingSearch from "@src/components/FloatingSearch"; +import SlideTransition from "@src/components/Modal/SlideTransition"; +import UserItem from "@src/components/Settings/UserManagement/UserItem"; +import UserSkeleton from "@src/components/Settings/UserManagement/UserSkeleton"; +import InviteUser from "@src/components/Settings/UserManagement/InviteUser"; + +import { globalScope, allUsersAtom } from "@src/atoms/globalScope"; +import UserManagementSourceFirebase from "@src/sources/UserManagementSourceFirebase"; +import useBasicSearch from "@src/hooks/useBasicSearch"; + +const SEARCH_KEYS = ["id", "user.displayName", "user.email"]; + +function UserManagementPage() { + const [users] = useAtom(allUsersAtom, globalScope); + const [results, query, handleQuery] = useBasicSearch(users, SEARCH_KEYS); + + return ( + + + + handleQuery(e.target.value)} + /> + + + + + {query ? `${results.length} of ${users.length}` : users.length} user + {results.length !== 1 && "s"} + + + + + + + + + + + {results.map((user) => ( + + + + ))} + + + + + + ); +} + +export default function SuspendedUserManagementPage() { + return ( + + + + + + + Loading users… + + + + + + + + + + + + + } + > + + + ); +} diff --git a/src/sources/ProjectSourceFirebase.tsx b/src/sources/ProjectSourceFirebase.tsx index da62c5f7..f3098ab9 100644 --- a/src/sources/ProjectSourceFirebase.tsx +++ b/src/sources/ProjectSourceFirebase.tsx @@ -1,6 +1,6 @@ -import { useEffect, useCallback } from "react"; -import { atom, useAtom } from "jotai"; -import { useUpdateAtom, useAtomCallback } from "jotai/utils"; +import { memo, useEffect, useCallback } from "react"; +import { atom, useAtom, useSetAtom } from "jotai"; +import { useAtomCallback } from "jotai/utils"; import { FirebaseOptions, initializeApp } from "firebase/app"; import { getAuth, connectAuthEmulator, getIdTokenResult } from "firebase/auth"; @@ -87,10 +87,10 @@ export const firebaseDbAtom = atom((get) => { * * Sets project ID, project settings, public settings, current user, user roles, and user settings. */ -export default function ProjectSourceFirebase() { +export const ProjectSourceFirebase = memo(function ProjectSourceFirebase() { // Set projectId from Firebase project const [firebaseConfig] = useAtom(firebaseConfigAtom, globalScope); - const setProjectId = useUpdateAtom(projectIdAtom, globalScope); + const setProjectId = useSetAtom(projectIdAtom, globalScope); useEffect(() => { setProjectId(firebaseConfig.projectId || ""); }, [firebaseConfig.projectId, setProjectId]); @@ -98,9 +98,9 @@ export default function ProjectSourceFirebase() { // Get current user and store in atoms const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope); const [currentUser, setCurrentUser] = useAtom(currentUserAtom, globalScope); - const setUserRoles = useUpdateAtom(userRolesAtom, globalScope); + const setUserRoles = useSetAtom(userRolesAtom, globalScope); // Must use `useAtomCallback`, otherwise `useAtom(updateUserSettingsAtom)` - // will cause infinite render + // will cause infinite re-render const updateUserSettings = useAtomCallback( useCallback((get) => get(updateUserSettingsAtom), []), globalScope @@ -137,7 +137,8 @@ export default function ProjectSourceFirebase() { updateDataAtom: updatePublicSettingsAtom, }); - // Store public settings in atom when a user is signed in + // Store project settings in atom when a user is signed in. + // If they have no access, display AccessDenied screen via ErrorBoundary. useFirestoreDocWithAtom( projectSettingsAtom, globalScope, @@ -162,4 +163,6 @@ export default function ProjectSourceFirebase() { }); return null; -} +}); + +export default ProjectSourceFirebase; diff --git a/src/sources/RowyProject.tsx b/src/sources/RowyProject.tsx index e4c0e776..e513373d 100644 --- a/src/sources/RowyProject.tsx +++ b/src/sources/RowyProject.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useUpdateAtom } from "jotai/utils"; +import { useSetAtom } from "jotai"; import { firebaseConfigAtom } from "@src/sources/ProjectSourceFirebase"; import { globalScope } from "@src/atoms/globalScope"; @@ -14,7 +14,7 @@ const envConfig = { export default function RowyProject({ children }: React.PropsWithChildren<{}>) { const [hasConfig, setHasConfig] = useState(false); - const setConfigAtom = useUpdateAtom(firebaseConfigAtom, globalScope); + const setConfigAtom = useSetAtom(firebaseConfigAtom, globalScope); if (!hasConfig) { return ( diff --git a/src/sources/UserManagementSourceFirebase.tsx b/src/sources/UserManagementSourceFirebase.tsx new file mode 100644 index 00000000..beb244dc --- /dev/null +++ b/src/sources/UserManagementSourceFirebase.tsx @@ -0,0 +1,21 @@ +import { memo } from "react"; + +import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom"; +import { + globalScope, + allUsersAtom, + updateUserAtom, +} from "@src/atoms/globalScope"; +import { USERS } from "@src/config/dbPaths"; + +const UserManagementSourceFirebase = memo( + function UserManagementSourceFirebase() { + useFirestoreCollectionWithAtom(allUsersAtom, globalScope, USERS, { + updateDataAtom: updateUserAtom, + }); + + return null; + } +); + +export default UserManagementSourceFirebase; diff --git a/src/test/App.test.tsx b/src/test/App.test.tsx index 8244f62d..8df59ee4 100644 --- a/src/test/App.test.tsx +++ b/src/test/App.test.tsx @@ -19,3 +19,6 @@ test("signs in", async () => { expect(await screen.findByText(/Nav/i)).toBeInTheDocument(); expect(await screen.findByText(/{"emulator":true}/i)).toBeInTheDocument(); }); + +// TODO: +// test("signs in without roles in auth") diff --git a/yarn.lock b/yarn.lock index e78674af..9c0645b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7586,10 +7586,10 @@ jju@~1.4.0: resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo= -jotai@^1.6.4: - version "1.6.4" - resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.4.tgz#4d9904362c53c4293d32e21fb358d3de34b82912" - integrity sha512-XC0ExLhdE6FEBdIjKTe6kMlHaAUV/QiwN7vZond76gNr/WdcdonJOEW79+5t8u38sR41bJXi26B1dRi7cCRz9A== +jotai@^1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.5.tgz#989efe63dc65beea23b3a5eab56f689d78e38070" + integrity sha512-B+DGV5ALIkIeyA1Bi9yBpdGmcWkmDS/p8C1XFYx9jFBs+lkeOdu0WAozAPCG4Qq1EcQ68vF+07HmPYFN5kf9OQ== "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" @@ -8005,6 +8005,14 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" +match-sorter@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda" + integrity sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw== + dependencies: + "@babel/runtime" "^7.12.5" + remove-accents "0.4.2" + material-design-lite@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/material-design-lite/-/material-design-lite-1.3.0.tgz#d004ce3fee99a1eeb74a78b8a325134a5f1171d3" @@ -9736,6 +9744,11 @@ relateurl@^0.2.7: resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= +remove-accents@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" + integrity sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U= + renderkid@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a"