add user management page, RowyRunModal, ConfirmDialog

This commit is contained in:
Sidney Alcantara
2022-05-02 16:01:34 +10:00
parent 0b51acce71
commit 70fb0c664c
31 changed files with 1342 additions and 50 deletions

View File

@@ -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}]}
{"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"}]}

View File

@@ -1,5 +1,7 @@
const JOTAI_USE_ATOM_HOOKS = [
"useAtom",
"useSetAtom",
"useAtomValue",
"useUpdateAtom",
"useAtomValue",
"useResetAtom",

View File

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

View File

@@ -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 (
<Suspense fallback={<Loading fullScreen />}>
<ProjectSourceFirebase />
<ConfirmDialog />
<RowyRunModal/>
{currentUser === undefined ? (
<Loading fullScreen message="Authenticating" />
@@ -76,10 +82,14 @@ export default function App() {
/>
<Route path={ROUTES.userSettings} element={<UserSettingsPage />} />
<Route path={ROUTES.projectSettings} element={<ProjectSettingsPage />} />
<Route path={ROUTES.userManagement} element={<UserManagementPage />} />
{/* <Route path={ROUTES.rowyRunTest} element={<RowyRunTestPage />} /> */}
</Route>
<Route path="/jotaiTest" element={<JotaiTestPage />} />
</Route>
{/* <Route path="/jotaiTest" element={<JotaiTestPage />} /> */}
</Routes>
)}
</Suspense>

View File

@@ -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<string>("");
@@ -27,7 +28,7 @@ export type PublicSettings = Partial<{
export const publicSettingsAtom = atom<PublicSettings>({});
/** Stores a function that updates public settings */
export const updatePublicSettingsAtom =
atom<UpdateFunction<PublicSettings> | null>(null);
atom<UpdateDocFunction<PublicSettings> | 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<ProjectSettings>({});
/** Stores a function that updates project settings */
export const updateProjectSettingsAtom =
atom<UpdateFunction<ProjectSettings> | null>(null);
atom<UpdateDocFunction<ProjectSettings> | 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<UserSettings[]>([]);
/** Stores a function that updates a user document */
export const updateUserAtom =
atom<UpdateCollectionFunction<UserSettings> | null>(null);

View File

@@ -38,11 +38,15 @@ export interface IRowyRunRequestProps {
json?: boolean;
/** Optionally pass an abort signal to abort the request */
signal?: AbortSignal;
/** Optionally pass a callback thats 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<Response | any | void> => {
handleNotSetUp,
}: IRowyRunRequestProps): Promise<Response | any | false> => {
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;

View File

@@ -1 +1,6 @@
export type UpdateFunction<T> = (update: Partial<T>) => Promise<any>;
export type UpdateDocFunction<T> = (update: Partial<T>) => Promise<void>;
export type UpdateCollectionFunction<T> = (
path: string,
update: Partial<T>
) => Promise<void>;

View File

@@ -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<ConfirmDialogProps>({ 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<Record<"feature" | "version", string>>) => {
set(rowyRunModalAtom, {
open: true,
feature: update?.feature || "",
version: update?.version || "",
});
}
);

View File

@@ -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<UserSettings>({});
/** Stores a function that updates user settings */
export const updateUserSettingsAtom = atom<UpdateFunction<UserSettings> | null>(
null
);
export const updateUserSettingsAtom =
atom<UpdateDocFunction<UserSettings> | null>(null);
/**
* Stores which theme is currently active, based on user or OS setting.

View File

@@ -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 (
<EmptyState
fullScreen
Icon={SecurityIcon}
message="Access denied"
description={
<>
<div style={{ textAlign: "left", width: "100%" }}>
<Stack
direction="row"
spacing={1.25}
alignItems="flex-start"
sx={{ mt: 2 }}
>
<Avatar src={currentUser?.photoURL || ""} />
<div>
<Typography>{currentUser?.displayName}</Typography>
<Typography variant="button">{currentUser?.email}</Typography>
</div>
</Stack>
{(!userRoles || userRoles.length === 0) && (
<Alert severity="warning" sx={{ mt: 2 }}>
Your account has no roles set
</Alert>
)}
</div>
<Typography>
You do not have access to this project. Please contact the project
owner.
</Typography>
<Button href={ROUTES.signOut}>Sign out</Button>
<Divider flexItem sx={{ typography: "overline" }}>
OR
</Divider>
<Typography>
If you are the project owner, please follow{" "}
<MuiLink
href={WIKI_LINKS.setupRoles}
target="_blank"
rel="noopener noreferrer"
>
these instructions
</MuiLink>{" "}
to set up this projects security rules.
</Typography>
</>
}
sx={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: "background.default",
zIndex: 9999,
}}
/>
);
}

View File

@@ -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 (
<Dialog
open={open}
onClose={(_, reason) => {
if (reason === "backdropClick" || reason === "escapeKeyDown") return;
else handleClose();
}}
maxWidth={maxWidth}
TransitionComponent={SlideTransitionMui}
style={{ cursor: "default" }}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
{typeof body === "string" ? (
<DialogContentText>{body}</DialogContentText>
) : (
body
)}
{confirmationCommand && (
<TextField
value={dryText}
onChange={(e) => setDryText(e.target.value)}
autoFocus
label={`Type ${confirmationCommand} below to continue:`}
placeholder={confirmationCommand}
fullWidth
id="dryText"
sx={{ mt: 3 }}
/>
)}
</DialogContent>
<DialogActions>
{!hideCancel && (
<Button
onClick={() => {
if (handleCancel) handleCancel();
handleClose();
}}
>
{cancel}
</Button>
)}
<Button
onClick={() => {
if (handleConfirm) handleConfirm();
handleClose();
}}
color={confirmColor || "primary"}
variant="contained"
autoFocus
disabled={
confirmationCommand ? dryText !== confirmationCommand : false
}
>
{confirm}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -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 <AccessDenied />;
return (
<EmptyState
message="Something went wrong"
description={
<>
<span>{error.message}</span>
<span>
{(error as any).code && <b>{(error as any).code}: </b>}
{error.message}
</span>
<Button
href={
meta.repository.url.replace(".git", "") + "/issues/new/choose"

View File

@@ -0,0 +1,136 @@
import {
useScrollTrigger,
Paper,
TextField,
FilledTextFieldProps,
InputAdornment,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import SlideTransition from "@src/components/Modal/SlideTransition";
import { APP_BAR_HEIGHT } from "@src/layouts/Navigation";
export interface IFloatingSearchProps extends Partial<FilledTextFieldProps> {
label: string;
paperSx?: FilledTextFieldProps["sx"];
}
export default function FloatingSearch({
label,
paperSx,
...props
}: IFloatingSearchProps) {
const dockedTransition = useScrollTrigger({
disableHysteresis: true,
threshold: 4,
});
const docked = useScrollTrigger({
disableHysteresis: true,
threshold: APP_BAR_HEIGHT,
});
return (
<SlideTransition in timeout={50}>
<Paper
elevation={dockedTransition ? 4 : 1}
sx={{
position: "sticky",
top: (theme) => theme.spacing(0.5),
zIndex: "appBar",
height: 48,
maxWidth: (theme) => theme.breakpoints.values.sm - 48,
width: "100%",
mx: "auto",
transition: (theme) =>
theme.transitions.create([
"box-shadow",
"transform",
"opacity",
"width",
]) + " !important",
transitionTimingFunction: (
theme
) => `${theme.transitions.easing.easeInOut},
cubic-bezier(0.1, 0.8, 0.1, 1),
cubic-bezier(0.1, 0.8, 0.1, 1) !important`,
...paperSx,
...(dockedTransition
? {
width: `calc(100vw - ${
(48 + 8) * 2
}px - env(safe-area-inset-left) - env(safe-area-inset-right))`,
}
: {}),
...(docked ? { boxShadow: "none" } : {}),
}}
>
<TextField
label={label}
placeholder={label}
hiddenLabel
fullWidth
type="search"
id="user-management-search"
size="medium"
autoFocus
InputProps={{
startAdornment: (
<InputAdornment
position="start"
sx={{ px: 0.5, pointerEvents: "none" }}
>
<SearchIcon />
</InputAdornment>
),
}}
sx={{
"& .MuiInputLabel-root": {
height: "0px",
m: 0,
p: 0,
pointerEvents: "none",
opacity: 0,
},
"& .MuiFilledInput-root": {
borderRadius: 2,
...(docked
? {}
: {
boxShadow: (theme) =>
`0 -1px 0 0 ${theme.palette.text.disabled} inset`,
"&:hover": {
boxShadow: (theme) =>
`0 -1px 0 0 ${theme.palette.text.primary} inset`,
},
"&.Mui-focused, &.Mui-focused:hover": {
boxShadow: (theme) =>
`0 -2px 0 0 ${theme.palette.primary.main} inset`,
},
}),
"&::after": {
width: (theme) =>
`calc(100% - ${
(theme.shape.borderRadius as number) * 2 * 2
}px)`,
left: (theme) => (theme.shape.borderRadius as number) * 2,
},
"&.Mui-disabled": {
bgcolor: "transparent",
boxShadow: "none",
"& .MuiInputAdornment-root": { color: "text.disabled" },
},
},
}}
{...props}
/>
</Paper>
</SlideTransition>
);
}

View File

@@ -59,6 +59,7 @@ export const SlideTransition: React.ForwardRefExoticComponent<
{(state) =>
cloneElement(children as any, {
style: { ...defaultStyle, ...transitionStyles[state] },
tabIndex: -1,
ref,
})
}

View File

@@ -0,0 +1,112 @@
import { Link } from "react-router-dom";
import { useAtom } from "jotai";
import {
Typography,
Button,
DialogContentText,
Link as MuiLink,
} from "@mui/material";
import Modal from "@src/components/Modal";
import Logo from "@src/assets/LogoRowyRun";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import {
globalScope,
userRolesAtom,
projectSettingsAtom,
rowyRunModalAtom,
} from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
import { WIKI_LINKS } from "@src/constants/externalLinks";
/**
* Display a modal asking the user to deploy or upgrade Rowy Run
* using `rowyRunModalAtom` in `globalState`
* {@link rowyRunModalAtom | See usage example}
*/
export default function RowyRunModal() {
const [userRoles] = useAtom(userRolesAtom, globalScope);
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const [rowyRunModal, setRowyRunModal] = useAtom(
rowyRunModalAtom,
globalScope
);
const handleClose = () => setRowyRunModal((s) => ({ ...s, open: false }));
const showUpdateModal = rowyRunModal.version && projectSettings?.rowyRunUrl;
return (
<Modal
open={rowyRunModal.open}
onClose={handleClose}
title={
<Logo
size={2}
style={{
margin: "16px auto",
display: "block",
position: "relative",
right: 44 / -2,
}}
/>
}
maxWidth="xs"
body={
<>
<Typography variant="h5" paragraph align="center">
{showUpdateModal ? "Update" : "Set up"} Rowy Run to use{" "}
{rowyRunModal.feature || "this feature"}
</Typography>
{showUpdateModal && (
<DialogContentText variant="body1" paragraph textAlign="center">
{rowyRunModal.feature || "This feature"} requires Rowy Run v
{rowyRunModal.version} or later.
</DialogContentText>
)}
<DialogContentText variant="body1" paragraph textAlign="center">
Rowy Run is a Cloud Run instance that provides backend
functionality, such as table action scripts, user management, and
easy Cloud Function deployment.{" "}
<MuiLink
href={WIKI_LINKS.rowyRun}
target="_blank"
rel="noopener noreferrer"
>
Learn more
<InlineOpenInNewIcon />
</MuiLink>
</DialogContentText>
<Button
component={Link}
to={ROUTES.projectSettings + "#rowyRun"}
variant="contained"
color="primary"
size="large"
onClick={handleClose}
style={{ display: "flex" }}
disabled={!userRoles.includes("ADMIN")}
>
Set up Rowy Run
</Button>
{!userRoles.includes("ADMIN") && (
<Typography
variant="body2"
textAlign="center"
color="error"
sx={{ mt: 1 }}
>
Contact the project owner to set up Rowy&nbsp;Run
</Typography>
)}
</>
}
/>
);
}

View File

@@ -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 (
<>
<Button
aria-label="Invite user"
onClick={
projectSettings.rowyRunUrl
? () => setOpen(true)
: () => openRowyRunModal({ feature: "Invite user" })
}
variant="text"
color="primary"
startIcon={<AddIcon />}
sx={{ "&&": { mb: -0.5 } }}
>
Invite user
</Button>
{open && (
<Modal
title="Invite user"
onClose={() => setOpen(false)}
maxWidth="xs"
body={
<>
<DialogContentText paragraph>
Invite a user to join your project via email.
</DialogContentText>
<DialogContentText>
They can sign up with any of the sign-in options{" "}
<MuiLink
component={Link}
to={routes.projectSettings + "#authentication"}
>
you have enabled
</MuiLink>
, as long as they use the same email address.
</DialogContentText>
<TextField
label="Email address"
id="invite-email"
value={email}
onChange={(e) => setEmail(e.target.value)}
fullWidth
placeholder="name@example.com"
sx={{ mt: 3 }}
/>
<MultiSelect
label="Roles"
value={roles}
options={Array.isArray(projectRoles) ? projectRoles : ["ADMIN"]}
onChange={setRoles}
freeText
TextFieldProps={{
id: "invite-roles",
SelectProps: {
renderValue: () => {
if (Array.isArray(roles)) {
if (roles.length >= 1) return roles.join(", ");
return (
<Typography variant="inherit" color="text.disabled">
Set roles
</Typography>
);
}
return null;
},
},
sx: { mt: 3 },
}}
/>
</>
}
footer={
status !== "LOADING" &&
typeof status === "string" && (
<Typography
variant="caption"
color="error"
textAlign="center"
sx={{ m: 1, mb: -1 }}
>
{status}
</Typography>
)
}
actions={{
primary: {
children: "Invite",
disabled: !email || roles.length === 0,
loading: status === "LOADING",
type: "submit",
onClick: handleInvite,
},
}}
/>
)}
</>
);
}

View File

@@ -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 = (
<>
<ListItemAvatar>
<Avatar src={user?.photoURL}>
{user?.displayName ? user.displayName[0] : undefined}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={user?.displayName}
secondary={user?.email}
sx={{
overflowX: "hidden",
"& > *": { userSelect: "all" },
}}
primaryTypographyProps={{ variant: "body1" }}
/>
</>
);
const handleDelete = async () => {
if (!projectSettings.rowyRunUrl) {
openRowyRunModal({ feature: "User Management" });
return;
}
confirm({
open: true,
title: "Delete user?",
body: (
<>
<ListItem children={listItemChildren} disablePadding sx={{ mb: 3 }} />
You will delete the user in Firebase Authentication and the
corresponding user document in <code>{USERS}</code>.
</>
),
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 (
<ListItem
children={listItemChildren}
secondaryAction={
<>
<MultiSelect
label="Roles"
value={value}
options={Array.from(allRoles)}
onChange={setValue}
freeText
TextFieldProps={{
SelectProps: {
renderValue: () => {
if (Array.isArray(value)) {
if (value.length === 1) return value[0];
if (value.length > 1) return `${value.length} roles`;
return (
<Typography variant="inherit" color="text.disabled">
No roles
</Typography>
);
}
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}
/>
<Tooltip title="Copy UID">
<IconButton
aria-label="Copy UID"
onClick={async () => {
if (!_rowy_id) return;
await navigator.clipboard.writeText(_rowy_id);
enqueueSnackbar(`Copied UID for ${user?.email}: ${_rowy_id}`);
}}
>
<CopyIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete…">
<IconButton
aria-label="Delete…"
color="error"
onClick={handleDelete}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</>
}
sx={{
pr: 1,
"& .MuiListItemSecondaryAction-root": {
position: "static",
transform: "none",
marginLeft: "auto",
display: "flex",
alignItems: "center",
},
}}
/>
);
}

View File

@@ -0,0 +1,40 @@
import {
Skeleton,
ListItem,
ListItemAvatar,
Avatar,
ListItemText,
Stack,
} from "@mui/material";
export default function UserSkeleton() {
return (
<ListItem
children={
<>
<ListItemAvatar>
<Skeleton variant="circular">
<Avatar />
</Skeleton>
</ListItemAvatar>
<ListItemText
primary={<Skeleton width={80} />}
secondary={<Skeleton width={120} />}
/>
</>
}
secondaryAction={
<Stack
spacing={2}
alignItems="center"
direction="row"
sx={{ pr: 1.25 }}
>
<Skeleton width={80} height={32} />
<Skeleton variant="circular" width={24} height={24} />
<Skeleton variant="circular" width={24} height={24} />
</Stack>
}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { matchSorter } from "match-sorter";
export function useBasicSearch<T>(
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;

View File

@@ -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<T> {
/** Additional path segments appended to the path. If any are undefined, the listener isnt created at all. */
pathSegments?: Array<string | undefined>;
/** Attach filters to the query */
filters?: Parameters<typeof where>[];
/** Attach orders to the query */
orders?: Parameters<typeof orderBy>[];
/** 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 atoms value to a function that updates the document. Uses same scope as `dataScope`. */
updateDataAtom?: PrimitiveAtom<UpdateCollectionFunction<T> | 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 isnt created at all.
* @param options - {@link IUseFirestoreCollectionWithAtomOptions}
*/
export function useFirestoreCollectionWithAtom<T = DocumentData>(
dataAtom: PrimitiveAtom<T[]>,
dataScope: Scope | undefined,
path: string | undefined,
options?: IUseFirestoreCollectionWithAtomOptions<T>
) {
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<T>;
// Create the query with filters and orders
const _query = query<T>(
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 atoms 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 atoms value to prevent writes
if (updateDataAtom) setUpdateDataAtom(RESET);
};
}, [
firebaseDb,
path,
pathSegments,
filters,
orders,
onError,
setDataAtom,
disableSuspense,
handleError,
updateDataAtom,
setUpdateDataAtom,
]);
}
export default useFirestoreCollectionWithAtom;

View File

@@ -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<T> {
/** Optionally create the document if it doesnt exist with the following data */
createIfNonExistent?: T;
/** Set this atoms value to a function that updates the document. Uses same scope as `dataScope`. */
updateDataAtom?: PrimitiveAtom<UpdateFunction<T> | null>;
updateDataAtom?: PrimitiveAtom<UpdateDocFunction<T> | 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<T> {
* @param options - {@link IUseFirestoreDocWithAtomOptions}
*/
export function useFirestoreDocWithAtom<T = DocumentData>(
dataAtom: PrimitiveAtom<DocumentData>,
dataAtom: PrimitiveAtom<T>,
dataScope: Scope | undefined,
path: string | undefined,
options?: IUseFirestoreDocWithAtomOptions<T>
) {
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<T = DocumentData>(
// 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<T = DocumentData>(
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<T = DocumentData>(
suspended = false;
},
(error) => {
if (suspended) setDataAtom({});
if (suspended) setDataAtom({} as T);
if (onError) onError(error);
else handleError(error);
}

View File

@@ -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();

View File

@@ -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 (
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<UserManagementSourceFirebase />
<FloatingSearch
label="Search users"
onChange={(e) => handleQuery(e.target.value)}
/>
<SlideTransition in timeout={100}>
<Stack
direction="row"
spacing={2}
justifyContent="space-between"
alignItems="flex-end"
sx={{ mt: 4, ml: 1, mb: 0.5, cursor: "default" }}
>
<Typography variant="subtitle1" component="h2">
{query ? `${results.length} of ${users.length}` : users.length} user
{results.length !== 1 && "s"}
</Typography>
<InviteUser />
</Stack>
</SlideTransition>
<SlideTransition in timeout={100 + 50}>
<Paper>
<List sx={{ py: { xs: 0, sm: 1.5 }, px: { xs: 0, sm: 1 } }}>
<TransitionGroup>
{results.map((user) => (
<Collapse key={user._rowy_id}>
<UserItem {...user} />
</Collapse>
))}
</TransitionGroup>
</List>
</Paper>
</SlideTransition>
</Container>
);
}
export default function SuspendedUserManagementPage() {
return (
<Suspense
fallback={
<Fade in timeout={1000} style={{ transitionDelay: "1s" }} unmountOnExit>
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<FloatingSearch label="Search users" disabled />
<Stack
direction="row"
spacing={2}
justifyContent="space-between"
alignItems="flex-end"
sx={{ mt: 4, ml: 1 }}
>
<Typography
variant="subtitle1"
component="p"
color="text.disabled"
sx={{ lineHeight: "32px" }}
>
Loading users
</Typography>
</Stack>
<Paper>
<List sx={{ py: { xs: 0, sm: 1.5 }, px: { xs: 0, sm: 1 } }}>
<UserSkeleton />
<UserSkeleton />
<UserSkeleton />
</List>
</Paper>
</Container>
</Fade>
}
>
<UserManagementPage />
</Suspense>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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