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 = [
|
const JOTAI_USE_ATOM_HOOKS = [
|
||||||
"useAtom",
|
"useAtom",
|
||||||
|
"useSetAtom",
|
||||||
|
"useAtomValue",
|
||||||
"useUpdateAtom",
|
"useUpdateAtom",
|
||||||
"useAtomValue",
|
"useAtomValue",
|
||||||
"useResetAtom",
|
"useResetAtom",
|
||||||
|
|||||||
@@ -20,8 +20,9 @@
|
|||||||
"dompurify": "^2.3.6",
|
"dompurify": "^2.3.6",
|
||||||
"firebase": "^9.6.11",
|
"firebase": "^9.6.11",
|
||||||
"firebaseui": "^6.0.1",
|
"firebaseui": "^6.0.1",
|
||||||
"jotai": "^1.6.4",
|
"jotai": "^1.6.5",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"match-sorter": "^6.3.1",
|
||||||
"notistack": "^2.0.4",
|
"notistack": "^2.0.4",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-color-palette": "^6.2.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 Loading from "@src/components/Loading";
|
||||||
import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase";
|
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 NotFound from "@src/pages/NotFound";
|
||||||
import RequireAuth from "@src/layouts/RequireAuth";
|
import RequireAuth from "@src/layouts/RequireAuth";
|
||||||
import Navigation from "@src/layouts/Navigation";
|
import Navigation from "@src/layouts/Navigation";
|
||||||
@@ -32,6 +34,8 @@ const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettings" /*
|
|||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */));
|
const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */));
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
const UserManagementPage = lazy(() => import("@src/pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */));
|
||||||
|
// prettier-ignore
|
||||||
// const RowyRunTestPage = lazy(() => import("@src/pages/RowyRunTest" /* webpackChunkName: "RowyRunTestPage" */));
|
// const RowyRunTestPage = lazy(() => import("@src/pages/RowyRunTest" /* webpackChunkName: "RowyRunTestPage" */));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -40,6 +44,8 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loading fullScreen />}>
|
<Suspense fallback={<Loading fullScreen />}>
|
||||||
<ProjectSourceFirebase />
|
<ProjectSourceFirebase />
|
||||||
|
<ConfirmDialog />
|
||||||
|
<RowyRunModal/>
|
||||||
|
|
||||||
{currentUser === undefined ? (
|
{currentUser === undefined ? (
|
||||||
<Loading fullScreen message="Authenticating" />
|
<Loading fullScreen message="Authenticating" />
|
||||||
@@ -76,10 +82,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path={ROUTES.userSettings} element={<UserSettingsPage />} />
|
<Route path={ROUTES.userSettings} element={<UserSettingsPage />} />
|
||||||
<Route path={ROUTES.projectSettings} element={<ProjectSettingsPage />} />
|
<Route path={ROUTES.projectSettings} element={<ProjectSettingsPage />} />
|
||||||
|
<Route path={ROUTES.userManagement} element={<UserManagementPage />} />
|
||||||
{/* <Route path={ROUTES.rowyRunTest} element={<RowyRunTestPage />} /> */}
|
{/* <Route path={ROUTES.rowyRunTest} element={<RowyRunTestPage />} /> */}
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/jotaiTest" element={<JotaiTestPage />} />
|
<Route path="/jotaiTest" element={<JotaiTestPage />} />
|
||||||
|
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* <Route path="/jotaiTest" element={<JotaiTestPage />} /> */}
|
||||||
</Routes>
|
</Routes>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { atom } from "jotai";
|
import { atom, PrimitiveAtom } from "jotai";
|
||||||
import { sortBy } from "lodash-es";
|
import { sortBy } from "lodash-es";
|
||||||
import { ThemeOptions } from "@mui/material";
|
import { ThemeOptions } from "@mui/material";
|
||||||
|
|
||||||
import { userRolesAtom } from "./auth";
|
import { userRolesAtom } from "./auth";
|
||||||
import { UpdateFunction } from "./types";
|
import { UpdateDocFunction, UpdateCollectionFunction } from "./types";
|
||||||
|
import { UserSettings } from "./user";
|
||||||
|
|
||||||
export const projectIdAtom = atom<string>("");
|
export const projectIdAtom = atom<string>("");
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export type PublicSettings = Partial<{
|
|||||||
export const publicSettingsAtom = atom<PublicSettings>({});
|
export const publicSettingsAtom = atom<PublicSettings>({});
|
||||||
/** Stores a function that updates public settings */
|
/** Stores a function that updates public settings */
|
||||||
export const updatePublicSettingsAtom =
|
export const updatePublicSettingsAtom =
|
||||||
atom<UpdateFunction<PublicSettings> | null>(null);
|
atom<UpdateDocFunction<PublicSettings> | null>(null);
|
||||||
|
|
||||||
/** Project settings are visible to authenticated users */
|
/** Project settings are visible to authenticated users */
|
||||||
export type ProjectSettings = Partial<{
|
export type ProjectSettings = Partial<{
|
||||||
@@ -48,7 +49,7 @@ export type ProjectSettings = Partial<{
|
|||||||
export const projectSettingsAtom = atom<ProjectSettings>({});
|
export const projectSettingsAtom = atom<ProjectSettings>({});
|
||||||
/** Stores a function that updates project settings */
|
/** Stores a function that updates project settings */
|
||||||
export const updateProjectSettingsAtom =
|
export const updateProjectSettingsAtom =
|
||||||
atom<UpdateFunction<ProjectSettings> | null>(null);
|
atom<UpdateDocFunction<ProjectSettings> | null>(null);
|
||||||
|
|
||||||
/** Table settings stored in project settings */
|
/** Table settings stored in project settings */
|
||||||
export type TableSettings = {
|
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;
|
json?: boolean;
|
||||||
/** Optionally pass an abort signal to abort the request */
|
/** Optionally pass an abort signal to abort the request */
|
||||||
signal?: AbortSignal;
|
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
|
* 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:
|
* @example Basic usage:
|
||||||
* ```
|
* ```
|
||||||
@@ -65,10 +69,12 @@ export const rowyRunAtom = atom((get) => {
|
|||||||
body,
|
body,
|
||||||
signal,
|
signal,
|
||||||
json = true,
|
json = true,
|
||||||
}: IRowyRunRequestProps): Promise<Response | any | void> => {
|
handleNotSetUp,
|
||||||
|
}: IRowyRunRequestProps): Promise<Response | any | false> => {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
console.log("Rowy Run: Not signed in");
|
console.log("Rowy Run: Not signed in");
|
||||||
return;
|
if (handleNotSetUp) handleNotSetUp();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
const authToken = await getIdTokenResult(currentUser!, forceRefresh);
|
const authToken = await getIdTokenResult(currentUser!, forceRefresh);
|
||||||
|
|
||||||
@@ -79,7 +85,8 @@ export const rowyRunAtom = atom((get) => {
|
|||||||
: rowyRunUrl;
|
: rowyRunUrl;
|
||||||
if (!serviceUrl) {
|
if (!serviceUrl) {
|
||||||
console.log("Rowy Run: Not set up");
|
console.log("Rowy Run: Not set up");
|
||||||
return;
|
if (handleNotSetUp) handleNotSetUp();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { method, path } = route;
|
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 { atomWithStorage } from "jotai/utils";
|
||||||
|
|
||||||
|
import { DialogProps, ButtonProps } from "@mui/material";
|
||||||
|
|
||||||
/** Nav open state stored in local storage. */
|
/** Nav open state stored in local storage. */
|
||||||
export const navOpenAtom = atomWithStorage("__ROWY__NAV_OPEN", false);
|
export const navOpenAtom = atomWithStorage("__ROWY__NAV_OPEN", false);
|
||||||
/** Nav pinned state stored in local storage. */
|
/** Nav pinned state stored in local storage. */
|
||||||
export const navPinnedAtom = atomWithStorage("__ROWY__NAV_PINNED", false);
|
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 themes from "@src/theme";
|
||||||
import { publicSettingsAtom } from "./project";
|
import { publicSettingsAtom } from "./project";
|
||||||
import { TableFilter } from "./table";
|
import { TableFilter } from "./table";
|
||||||
import { UpdateFunction } from "./types";
|
import { UpdateDocFunction } from "./types";
|
||||||
|
|
||||||
/** User info and settings */
|
/** User info and settings */
|
||||||
export type UserSettings = Partial<{
|
export type UserSettings = Partial<{
|
||||||
|
_rowy_id: string;
|
||||||
/** Synced from user auth info */
|
/** Synced from user auth info */
|
||||||
user: {
|
user: {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -34,9 +35,8 @@ export type UserSettings = Partial<{
|
|||||||
/** User info and settings */
|
/** User info and settings */
|
||||||
export const userSettingsAtom = atom<UserSettings>({});
|
export const userSettingsAtom = atom<UserSettings>({});
|
||||||
/** Stores a function that updates user settings */
|
/** Stores a function that updates user settings */
|
||||||
export const updateUserSettingsAtom = atom<UpdateFunction<UserSettings> | null>(
|
export const updateUserSettingsAtom =
|
||||||
null
|
atom<UpdateDocFunction<UserSettings> | null>(null);
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores which theme is currently active, based on user or OS setting.
|
* 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 InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||||
|
|
||||||
import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState";
|
import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState";
|
||||||
|
import AccessDenied from "@src/components/AccessDenied";
|
||||||
import meta from "@root/package.json";
|
import meta from "@root/package.json";
|
||||||
|
|
||||||
export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {}
|
export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {}
|
||||||
@@ -36,12 +37,17 @@ export default function ErrorFallback({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ((error as any).code === "permission-denied") return <AccessDenied />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
message="Something went wrong"
|
message="Something went wrong"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
<span>{error.message}</span>
|
<span>
|
||||||
|
{(error as any).code && <b>{(error as any).code}: </b>}
|
||||||
|
{error.message}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
href={
|
href={
|
||||||
meta.repository.url.replace(".git", "") + "/issues/new/choose"
|
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) =>
|
{(state) =>
|
||||||
cloneElement(children as any, {
|
cloneElement(children as any, {
|
||||||
style: { ...defaultStyle, ...transitionStyles[state] },
|
style: { ...defaultStyle, ...transitionStyles[state] },
|
||||||
|
tabIndex: -1,
|
||||||
ref,
|
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 { useEffect } from "react";
|
||||||
import { useAtom, PrimitiveAtom } from "jotai";
|
import { useAtom, PrimitiveAtom, useSetAtom } from "jotai";
|
||||||
import { Scope } from "jotai/core/atom";
|
import { Scope } from "jotai/core/atom";
|
||||||
import { useUpdateAtom, RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import {
|
import {
|
||||||
doc,
|
doc,
|
||||||
DocumentData,
|
DocumentData,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { useErrorHandler } from "react-error-boundary";
|
import { useErrorHandler } from "react-error-boundary";
|
||||||
|
|
||||||
import { globalScope } from "@src/atoms/globalScope";
|
import { globalScope } from "@src/atoms/globalScope";
|
||||||
import { UpdateFunction } from "@src/atoms/types";
|
import { UpdateDocFunction } from "@src/atoms/types";
|
||||||
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
|
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
|
||||||
|
|
||||||
/** Options for {@link useFirestoreDocWithAtom} */
|
/** Options for {@link useFirestoreDocWithAtom} */
|
||||||
@@ -27,14 +27,13 @@ interface IUseFirestoreDocWithAtomOptions<T> {
|
|||||||
/** Optionally create the document if it doesn’t exist with the following data */
|
/** Optionally create the document if it doesn’t exist with the following data */
|
||||||
createIfNonExistent?: T;
|
createIfNonExistent?: T;
|
||||||
/** Set this atom’s value to a function that updates the document. Uses same scope as `dataScope`. */
|
/** 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.
|
* Gets the Firestore instance initiated in globalScope.
|
||||||
* Updates an atom and optionally Suspends that atom until the first snapshot
|
* Updates an atom and Suspends that atom until the first snapshot is received.
|
||||||
* is received.
|
|
||||||
*
|
*
|
||||||
* @param dataAtom - Atom to store data in
|
* @param dataAtom - Atom to store data in
|
||||||
* @param dataScope - Atom scope
|
* @param dataScope - Atom scope
|
||||||
@@ -42,14 +41,14 @@ interface IUseFirestoreDocWithAtomOptions<T> {
|
|||||||
* @param options - {@link IUseFirestoreDocWithAtomOptions}
|
* @param options - {@link IUseFirestoreDocWithAtomOptions}
|
||||||
*/
|
*/
|
||||||
export function useFirestoreDocWithAtom<T = DocumentData>(
|
export function useFirestoreDocWithAtom<T = DocumentData>(
|
||||||
dataAtom: PrimitiveAtom<DocumentData>,
|
dataAtom: PrimitiveAtom<T>,
|
||||||
dataScope: Scope | undefined,
|
dataScope: Scope | undefined,
|
||||||
path: string | undefined,
|
path: string | undefined,
|
||||||
options?: IUseFirestoreDocWithAtomOptions<T>
|
options?: IUseFirestoreDocWithAtomOptions<T>
|
||||||
) {
|
) {
|
||||||
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
|
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
|
||||||
const setDataAtom = useUpdateAtom(dataAtom, dataScope);
|
const setDataAtom = useSetAtom(dataAtom, dataScope);
|
||||||
const setUpdateDataAtom = useUpdateAtom(
|
const setUpdateDataAtom = useSetAtom(
|
||||||
options?.updateDataAtom || (dataAtom as any),
|
options?.updateDataAtom || (dataAtom as any),
|
||||||
globalScope
|
globalScope
|
||||||
);
|
);
|
||||||
@@ -72,7 +71,7 @@ export function useFirestoreDocWithAtom<T = DocumentData>(
|
|||||||
|
|
||||||
// Suspend data atom until we get the first snapshot
|
// Suspend data atom until we get the first snapshot
|
||||||
if (!disableSuspense) {
|
if (!disableSuspense) {
|
||||||
setDataAtom(new Promise(() => {}));
|
setDataAtom(new Promise(() => {}) as unknown as T);
|
||||||
suspended = true;
|
suspended = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +83,13 @@ export function useFirestoreDocWithAtom<T = DocumentData>(
|
|||||||
|
|
||||||
const unsubscribe = onSnapshot(
|
const unsubscribe = onSnapshot(
|
||||||
ref,
|
ref,
|
||||||
(doc) => {
|
(docSnapshot) => {
|
||||||
try {
|
try {
|
||||||
if (!doc.exists() && !!createIfNonExistent) {
|
if (!docSnapshot.exists() && !!createIfNonExistent) {
|
||||||
setDoc(doc.ref, createIfNonExistent);
|
setDoc(docSnapshot.ref, createIfNonExistent);
|
||||||
setDataAtom(createIfNonExistent);
|
setDataAtom(createIfNonExistent);
|
||||||
} else {
|
} else {
|
||||||
setDataAtom(doc.data() || {});
|
setDataAtom(docSnapshot.data() || ({} as T));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) onError(error as FirestoreError);
|
if (onError) onError(error as FirestoreError);
|
||||||
@@ -99,7 +98,7 @@ export function useFirestoreDocWithAtom<T = DocumentData>(
|
|||||||
suspended = false;
|
suspended = false;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (suspended) setDataAtom({});
|
if (suspended) setDataAtom({} as T);
|
||||||
if (onError) onError(error);
|
if (onError) onError(error);
|
||||||
else handleError(error);
|
else handleError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ function JotaiTest() {
|
|||||||
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
|
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
|
||||||
const [userSettings] = useAtom(userSettingsAtom, globalScope);
|
const [userSettings] = useAtom(userSettingsAtom, globalScope);
|
||||||
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
|
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
|
||||||
// console.log("publicSettings", publicSettings);
|
|
||||||
// console.log("userSettings", userSettings);
|
|
||||||
|
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
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 { memo, useEffect, useCallback } from "react";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom, useSetAtom } from "jotai";
|
||||||
import { useUpdateAtom, useAtomCallback } from "jotai/utils";
|
import { useAtomCallback } from "jotai/utils";
|
||||||
|
|
||||||
import { FirebaseOptions, initializeApp } from "firebase/app";
|
import { FirebaseOptions, initializeApp } from "firebase/app";
|
||||||
import { getAuth, connectAuthEmulator, getIdTokenResult } from "firebase/auth";
|
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.
|
* 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
|
// Set projectId from Firebase project
|
||||||
const [firebaseConfig] = useAtom(firebaseConfigAtom, globalScope);
|
const [firebaseConfig] = useAtom(firebaseConfigAtom, globalScope);
|
||||||
const setProjectId = useUpdateAtom(projectIdAtom, globalScope);
|
const setProjectId = useSetAtom(projectIdAtom, globalScope);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProjectId(firebaseConfig.projectId || "");
|
setProjectId(firebaseConfig.projectId || "");
|
||||||
}, [firebaseConfig.projectId, setProjectId]);
|
}, [firebaseConfig.projectId, setProjectId]);
|
||||||
@@ -98,9 +98,9 @@ export default function ProjectSourceFirebase() {
|
|||||||
// Get current user and store in atoms
|
// Get current user and store in atoms
|
||||||
const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope);
|
const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope);
|
||||||
const [currentUser, setCurrentUser] = useAtom(currentUserAtom, globalScope);
|
const [currentUser, setCurrentUser] = useAtom(currentUserAtom, globalScope);
|
||||||
const setUserRoles = useUpdateAtom(userRolesAtom, globalScope);
|
const setUserRoles = useSetAtom(userRolesAtom, globalScope);
|
||||||
// Must use `useAtomCallback`, otherwise `useAtom(updateUserSettingsAtom)`
|
// Must use `useAtomCallback`, otherwise `useAtom(updateUserSettingsAtom)`
|
||||||
// will cause infinite render
|
// will cause infinite re-render
|
||||||
const updateUserSettings = useAtomCallback(
|
const updateUserSettings = useAtomCallback(
|
||||||
useCallback((get) => get(updateUserSettingsAtom), []),
|
useCallback((get) => get(updateUserSettingsAtom), []),
|
||||||
globalScope
|
globalScope
|
||||||
@@ -137,7 +137,8 @@ export default function ProjectSourceFirebase() {
|
|||||||
updateDataAtom: updatePublicSettingsAtom,
|
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(
|
useFirestoreDocWithAtom(
|
||||||
projectSettingsAtom,
|
projectSettingsAtom,
|
||||||
globalScope,
|
globalScope,
|
||||||
@@ -162,4 +163,6 @@ export default function ProjectSourceFirebase() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ProjectSourceFirebase;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useUpdateAtom } from "jotai/utils";
|
import { useSetAtom } from "jotai";
|
||||||
import { firebaseConfigAtom } from "@src/sources/ProjectSourceFirebase";
|
import { firebaseConfigAtom } from "@src/sources/ProjectSourceFirebase";
|
||||||
import { globalScope } from "@src/atoms/globalScope";
|
import { globalScope } from "@src/atoms/globalScope";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const envConfig = {
|
|||||||
|
|
||||||
export default function RowyProject({ children }: React.PropsWithChildren<{}>) {
|
export default function RowyProject({ children }: React.PropsWithChildren<{}>) {
|
||||||
const [hasConfig, setHasConfig] = useState(false);
|
const [hasConfig, setHasConfig] = useState(false);
|
||||||
const setConfigAtom = useUpdateAtom(firebaseConfigAtom, globalScope);
|
const setConfigAtom = useSetAtom(firebaseConfigAtom, globalScope);
|
||||||
|
|
||||||
if (!hasConfig) {
|
if (!hasConfig) {
|
||||||
return (
|
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(/Nav/i)).toBeInTheDocument();
|
||||||
expect(await screen.findByText(/{"emulator":true}/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"
|
resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a"
|
||||||
integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo=
|
integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo=
|
||||||
|
|
||||||
jotai@^1.6.4:
|
jotai@^1.6.5:
|
||||||
version "1.6.4"
|
version "1.6.5"
|
||||||
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.4.tgz#4d9904362c53c4293d32e21fb358d3de34b82912"
|
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.5.tgz#989efe63dc65beea23b3a5eab56f689d78e38070"
|
||||||
integrity sha512-XC0ExLhdE6FEBdIjKTe6kMlHaAUV/QiwN7vZond76gNr/WdcdonJOEW79+5t8u38sR41bJXi26B1dRi7cCRz9A==
|
integrity sha512-B+DGV5ALIkIeyA1Bi9yBpdGmcWkmDS/p8C1XFYx9jFBs+lkeOdu0WAozAPCG4Qq1EcQ68vF+07HmPYFN5kf9OQ==
|
||||||
|
|
||||||
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
@@ -8005,6 +8005,14 @@ makeerror@1.0.x:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tmpl "1.0.x"
|
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:
|
material-design-lite@^1.2.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/material-design-lite/-/material-design-lite-1.3.0.tgz#d004ce3fee99a1eeb74a78b8a325134a5f1171d3"
|
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"
|
resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
|
||||||
integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
|
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:
|
renderkid@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a"
|
resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a"
|
||||||
|
|||||||
Reference in New Issue
Block a user