mirror of
https://github.com/rowyio/rowy.git
synced 2026-05-18 05:05:28 +02:00
add user management page, RowyRunModal, ConfirmDialog
This commit is contained in:
@@ -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"}]}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
const JOTAI_USE_ATOM_HOOKS = [
|
||||
"useAtom",
|
||||
"useSetAtom",
|
||||
"useAtomValue",
|
||||
"useUpdateAtom",
|
||||
"useAtomValue",
|
||||
"useResetAtom",
|
||||
|
||||
@@ -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",
|
||||
|
||||
12
src/App.tsx
12
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 (
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
7
src/atoms/types.d.ts
vendored
7
src/atoms/types.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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 || "",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
93
src/components/AccessDenied.tsx
Normal file
93
src/components/AccessDenied.tsx
Normal 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 project’s security rules.
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
sx={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bgcolor: "background.default",
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
src/components/ConfirmDialog.tsx
Normal file
107
src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
136
src/components/FloatingSearch.tsx
Normal file
136
src/components/FloatingSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export const SlideTransition: React.ForwardRefExoticComponent<
|
||||
{(state) =>
|
||||
cloneElement(children as any, {
|
||||
style: { ...defaultStyle, ...transitionStyles[state] },
|
||||
tabIndex: -1,
|
||||
ref,
|
||||
})
|
||||
}
|
||||
|
||||
112
src/components/RowyRunModal.tsx
Normal file
112
src/components/RowyRunModal.tsx
Normal 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 Run
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
158
src/components/Settings/UserManagement/InviteUser.tsx
Normal file
158
src/components/Settings/UserManagement/InviteUser.tsx
Normal 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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
215
src/components/Settings/UserManagement/UserItem.tsx
Normal file
215
src/components/Settings/UserManagement/UserItem.tsx
Normal 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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/components/Settings/UserManagement/UserSkeleton.tsx
Normal file
40
src/components/Settings/UserManagement/UserSkeleton.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
src/hooks/useBasicSearch.ts
Normal file
18
src/hooks/useBasicSearch.ts
Normal 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;
|
||||
153
src/hooks/useFirestoreCollectionWithAtom.ts
Normal file
153
src/hooks/useFirestoreCollectionWithAtom.ts
Normal 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 isn’t 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 atom’s 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 isn’t 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 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;
|
||||
@@ -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 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<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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
113
src/pages/Settings/UserManagement.tsx
Normal file
113
src/pages/Settings/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
21
src/sources/UserManagementSourceFirebase.tsx
Normal file
21
src/sources/UserManagementSourceFirebase.tsx
Normal 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;
|
||||
@@ -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")
|
||||
|
||||
21
yarn.lock
21
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"
|
||||
|
||||
Reference in New Issue
Block a user