diff --git a/package.json b/package.json index 9f048dff..09c5c091 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@mui/material": "^5.0.0", "@mui/styles": "^5.0.0", "@rowy/form-builder": "^0.1.2", - "@rowy/multiselect": "^0.1.6", + "@rowy/multiselect": "^0.1.7", "@tinymce/tinymce-react": "^3.4.0", "algoliasearch": "^4.8.6", "ansi-to-react": "^6.1.5", diff --git a/src/components/Modal/ScrollableDialogContent.tsx b/src/components/Modal/ScrollableDialogContent.tsx index 4f221682..4a58edf3 100644 --- a/src/components/Modal/ScrollableDialogContent.tsx +++ b/src/components/Modal/ScrollableDialogContent.tsx @@ -40,7 +40,7 @@ export default function ScrollableDialogContent({ style={{ visibility: scrollInfo.y.percentage > 0 ? "visible" : "hidden", }} - sx={{ mb: "-1px", ...dividerSx, ...topDividerSx }} + sx={{ ...dividerSx, ...topDividerSx }} /> )} @@ -51,7 +51,7 @@ export default function ScrollableDialogContent({ style={{ visibility: scrollInfo.y.percentage < 1 ? "visible" : "hidden", }} - sx={{ mt: "-1px", ...dividerSx, ...bottomDividerSx }} + sx={{ ...dividerSx, ...bottomDividerSx }} /> )} diff --git a/src/components/Setup/SetupItem.tsx b/src/components/Setup/SetupItem.tsx index c4958b08..43be2dd6 100644 --- a/src/components/Setup/SetupItem.tsx +++ b/src/components/Setup/SetupItem.tsx @@ -20,6 +20,7 @@ export default function SetupItem({ alignItems="flex-start" aria-busy={status === "loading"} aria-describedby={status === "loading" ? "progress" : undefined} + style={{ width: "100%" }} > {status === "complete" ? ( diff --git a/src/components/Setup/Step0Welcome.tsx b/src/components/Setup/Step0Welcome.tsx index e4be86a0..f416a03d 100644 --- a/src/components/Setup/Step0Welcome.tsx +++ b/src/components/Setup/Step0Welcome.tsx @@ -5,7 +5,7 @@ import OpenInNewIcon from "components/InlineOpenInNewIcon"; import { useAppContext } from "contexts/AppContext"; -export default function Welcome({ +export default function Step0Welcome({ completion, setCompletion, }: ISetupStepBodyProps) { @@ -18,7 +18,7 @@ export default function Welcome({ Get up and running in around 5 minutes. - You’ll easily set up backend functionality, Firestore Rules, and user + You’ll easily set up back-end functionality, Firestore Rules, and user management. diff --git a/src/components/Setup/Step2ServiceAccount.tsx b/src/components/Setup/Step2ServiceAccount.tsx index cc56f681..1d863b28 100644 --- a/src/components/Setup/Step2ServiceAccount.tsx +++ b/src/components/Setup/Step2ServiceAccount.tsx @@ -160,6 +160,7 @@ export const checkServiceAccount = async ( const res = await rowyRun({ rowyRunUrl, route: runRoutes.serviceAccountAccess, + signal, }); return Object.values(res).reduce( (acc, value) => acc && value, diff --git a/src/components/Setup/Step4Rules.tsx b/src/components/Setup/Step4Rules.tsx new file mode 100644 index 00000000..48033279 --- /dev/null +++ b/src/components/Setup/Step4Rules.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect } from "react"; +import { ISetupStepBodyProps } from "pages/Setup"; + +import { + Typography, + FormControlLabel, + Checkbox, + Button, + Link, + TextField, + Grid, +} from "@mui/material"; +import LoadingButton from "@mui/lab/LoadingButton"; +import CopyIcon from "assets/icons/Copy"; +import InlineOpenInNewIcon from "components/InlineOpenInNewIcon"; + +import SetupItem from "./SetupItem"; +import SignInWithGoogle from "./SignInWithGoogle"; + +import { name } from "@root/package.json"; +import { useAppContext } from "contexts/AppContext"; +import { CONFIG } from "config/dbPaths"; +import { requiredRules, adminRules, utilFns } from "config/firestoreRules"; +import { rowyRun } from "utils/rowyRun"; +import { runRoutes } from "constants/runRoutes"; + +export default function Step4Rules({ + rowyRunUrl, + completion, + setCompletion, +}: ISetupStepBodyProps) { + const { projectId, currentUser, getAuthToken } = useAppContext(); + + const [hasRules, setHasRules] = useState(completion.rules); + const [adminRule, setAdminRule] = useState(true); + + const rules = `${ + adminRule ? adminRules : "" + }${requiredRules}${utilFns}`.replace("\n", ""); + + const [currentRules, setCurrentRules] = useState(""); + useEffect(() => { + if (rowyRunUrl && !hasRules && !currentRules) + getAuthToken() + .then((authToken) => + rowyRun({ + rowyRunUrl, + route: runRoutes.firestoreRules, + authToken, + }) + ) + .then((data) => setCurrentRules(data?.source?.[0]?.content ?? "")); + }, [rowyRunUrl, hasRules, currentRules, getAuthToken]); + + const [newRules, setNewRules] = useState(""); + useEffect(() => { + const inserted = currentRules.replace( + /match\s*\/databases\/\{database\}\/documents\s*\{/, + `match /databases/{database}/documents {\n` + rules + ); + setNewRules(inserted); + }, [currentRules, rules]); + + const [rulesStatus, setRulesStatus] = useState<"IDLE" | "LOADING" | string>( + "IDLE" + ); + const setRules = async () => { + setRulesStatus("LOADING"); + try { + const authToken = await getAuthToken(); + if (!authToken) throw new Error("Failed to generate auth token"); + + const res = await rowyRun({ + rowyRunUrl, + route: runRoutes.setFirestoreRules, + authToken, + body: { ruleset: newRules }, + }); + if (!res.success) throw new Error(res.message); + + const isSuccessful = await checkRules(rowyRunUrl, authToken); + if (isSuccessful) { + setCompletion((c) => ({ ...c, rules: true })); + setHasRules(true); + } + + setRulesStatus("IDLE"); + } catch (e: any) { + console.error(e); + setRulesStatus(e.message); + } + }; + + return ( + <> + + {name} configuration is stored in the {CONFIG} collection + on Firestore. Your users will need read access to this collection and + admins will need write access. + + + + {!hasRules && ( + <> + setAdminRule(e.target.checked)} + /> + } + label="Allow admins to read and write all documents" + sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }} + /> + + $1` + ), + }} + /> + + + + )} + + + {!hasRules && ( + + You can add these rules{" "} + + in the Firebase Console + + {" "} + or directly below: + + } + > + setNewRules(e.target.value)} + multiline + rows={5} + fullWidth + sx={{ + "& .MuiInputBase-input": { + fontFamily: "mono", + letterSpacing: 0, + resize: "vertical", + }, + }} + /> + + + Set Firestore Rules + + + {rulesStatus !== "LOADING" && typeof rulesStatus === "string" && ( + + {rulesStatus} + + )} + + )} + + ); +} + +export const checkRules = async ( + rowyRunUrl: string, + authToken: string, + signal?: AbortSignal +) => { + if (!authToken) return false; + + try { + const res = await rowyRun({ + rowyRunUrl, + route: runRoutes.firestoreRules, + authToken, + signal, + }); + const rules = res?.source?.[0]?.content || ""; + if (!rules) return false; + + const sanitizedRules = rules.replace(/\s{2,}/g, " ").replace(/\n/g, " "); + const hasRules = + sanitizedRules.includes( + requiredRules.replace(/\s{2,}/g, " ").replace(/\n/g, " ") + ) && + sanitizedRules.includes( + utilFns.replace(/\s{2,}/g, " ").replace(/\n/g, " ") + ); + + return hasRules; + } catch (e: any) { + console.error(e); + return false; + } +}; diff --git a/src/components/Setup/Step5Migrate.tsx b/src/components/Setup/Step5Migrate.tsx new file mode 100644 index 00000000..ed92bf41 --- /dev/null +++ b/src/components/Setup/Step5Migrate.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from "react"; +import { ISetupStepBodyProps } from "pages/Setup"; + +import { Typography, Button } from "@mui/material"; +import LoadingButton from "@mui/lab/LoadingButton"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + +import SetupItem from "./SetupItem"; +import SignInWithGoogle from "./SignInWithGoogle"; + +import { name } from "@root/package.json"; +import { useAppContext } from "contexts/AppContext"; +import { CONFIG } from "config/dbPaths"; +import { rowyRun } from "utils/rowyRun"; +import { runRoutes } from "constants/runRoutes"; + +export default function Step5Migrate({ + rowyRunUrl, + completion, + setCompletion, +}: ISetupStepBodyProps) { + const { getAuthToken } = useAppContext(); + + const [status, setStatus] = useState<"LOADING" | boolean | string>( + completion.migrate + ); + + const migrate = async () => { + setStatus("LOADING"); + try { + const authToken = await getAuthToken(); + + const res = await rowyRun({ + route: runRoutes.migrateFT2Rowy, + rowyRunUrl, + authToken, + }); + if (!res.success) throw new Error(res.message); + + const check = await checkMigrate(rowyRunUrl, authToken); + if (!check.migrationRequired) { + setCompletion((c) => ({ ...c, migrate: true })); + setStatus(true); + } + } catch (e: any) { + console.error(e); + setStatus(e.message); + } + }; + + return ( + <> + + It looks like you’ve previously configured your Firestore database for + Firetable. You can migrate this configuration, including your XX tables + to {name}. + + + + Configuration migrated to the {CONFIG} collection. + + ) : ( + <> + Migrate your configuration to the {CONFIG}{" "} + collection. + + ) + } + > + {status !== true && ( + <> + + Migrate + + {status !== "LOADING" && typeof status === "string" && ( + + {status} + + )} + + )} + + + ); +} + +export const checkMigrate = async ( + rowyRunUrl: string, + authToken: string, + signal?: AbortSignal +) => { + if (!authToken) return false; + + try { + const res = await rowyRun({ + rowyRunUrl, + route: runRoutes.checkFT2Rowy, + authToken, + signal, + }); + return res.migrationRequired; + } catch (e: any) { + console.error(e); + return false; + } +}; diff --git a/src/components/Setup/Step6Finish.tsx b/src/components/Setup/Step6Finish.tsx new file mode 100644 index 00000000..30b81f53 --- /dev/null +++ b/src/components/Setup/Step6Finish.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { useSnackbar } from "notistack"; + +import { Typography, Stack, RadioGroup, Radio } from "@mui/material"; +import ThumbUpIcon from "@mui/icons-material/ThumbUpAlt"; +import ThumbUpOffIcon from "@mui/icons-material/ThumbUpOffAlt"; +import ThumbDownIcon from "@mui/icons-material/ThumbDownAlt"; +import ThumbDownOffIcon from "@mui/icons-material/ThumbDownOffAlt"; + +import { name } from "@root/package.json"; +import { analytics } from "analytics"; + +export default function Step6Finish() { + const { enqueueSnackbar } = useSnackbar(); + + const [rating, setRating] = useState<"up" | "down" | undefined>(); + + const handleRate = (e) => { + setRating(e.target.value); + analytics.logEvent("rate_setup", { rating: e.target.value }); + enqueueSnackbar("Thanks for your feedback!"); + }; + + return ( + <> + + You can now continue to {name} and create a table from your Firestore + collections. + + + + + How was your setup experience? + + + + } + icon={} + inputProps={{ "aria-label": "Thumbs up" }} + value="up" + color="secondary" + disabled={rating !== undefined} + /> + } + icon={} + inputProps={{ "aria-label": "Thumbs down" }} + value="down" + color="secondary" + disabled={rating !== undefined} + /> + + + + ); +} diff --git a/src/config/dbPaths.ts b/src/config/dbPaths.ts index 6be48db3..b5a3ed85 100644 --- a/src/config/dbPaths.ts +++ b/src/config/dbPaths.ts @@ -1,8 +1,10 @@ -export const SETTINGS = "_rowy_/settings"; -export const PUBLIC_SETTINGS = "_rowy_/publicSettings"; +export const CONFIG = "_rowy_" as const; -export const TABLE_SCHEMAS = SETTINGS + "/schema"; -export const TABLE_GROUP_SCHEMAS = SETTINGS + "/groupSchema"; +export const SETTINGS = `${CONFIG}/settings` as const; +export const PUBLIC_SETTINGS = `${CONFIG}/publicSettings` as const; -export const USER_MANAGEMENT = "_rowy_/userManagement"; -export const USERS = USER_MANAGEMENT + "/users"; +export const TABLE_SCHEMAS = `${SETTINGS}/schema` as const; +export const TABLE_GROUP_SCHEMAS = `${SETTINGS}/groupSchema` as const; + +export const USER_MANAGEMENT = `${CONFIG}/userManagement` as const; +export const USERS = `${USER_MANAGEMENT}/users` as const; diff --git a/src/config/firestoreRules.ts b/src/config/firestoreRules.ts new file mode 100644 index 00000000..0f674357 --- /dev/null +++ b/src/config/firestoreRules.ts @@ -0,0 +1,39 @@ +import { CONFIG, USERS, PUBLIC_SETTINGS } from "./dbPaths"; + +export const requiredRules = ` + // Rowy: Allow signed in users to read Rowy configuration and admins to write + match /${CONFIG}/{docId} { + allow read: if request.auth != null; + allow write: if hasAnyRole(["ADMIN", "OWNER"]); + match /{document=**} { + allow read: if request.auth != null; + allow write: if hasAnyRole(["ADMIN", "OWNER"]); + } + } + // Rowy: Allow users to edit their settings + match /${USERS}/{userId} { + allow get, update, delete: if isDocOwner(userId); + allow create: if request.auth != null; + } + // Rowy: Allow public to read public Rowy configuration + match /${PUBLIC_SETTINGS} { + allow get: if true; + } +` as const; + +export const adminRules = ` + // Allow admins to read and write all documents + match /{document=**} { + allow read, write: if hasAnyRole(["ADMIN", "OWNER"]); + } +` as const; + +export const utilFns = ` + // Rowy: Utility functions + function isDocOwner(docId) { + return request.auth != null && (request.auth.uid == resource.id || request.auth.uid == docId); + } + function hasAnyRole(roles) { + return request.auth != null && request.auth.token.roles.hasAny(roles); + } +` as const; diff --git a/src/constants/runRoutes.ts b/src/constants/runRoutes.ts index 8e8a60cd..db25131c 100644 --- a/src/constants/runRoutes.ts +++ b/src/constants/runRoutes.ts @@ -31,18 +31,21 @@ type actionScriptRequest = { export type runRouteRequest = actionScriptRequest | impersonateUserRequest; -export const runRoutes: Record = { - impersonateUser: { path: "/impersonateUser", method: "GET" }, - version: { path: "/version", method: "GET" }, - region: { path: "/region", method: "GET" }, - firestoreRules: { path: "/firestoreRules", method: "GET" }, - setFirestoreRules: { path: "/setFirestoreRules", method: "POST" }, - listCollections: { path: "/listCollections", method: "GET" }, - serviceAccountAccess: { path: "/serviceAccountAccess", method: "GET" }, - checkFT2Rowy: { path: "/checkFT2Rowy", method: "GET" }, - migrateFT2Rowy: { path: "/migrateFT2Rowy", method: "GET" }, - actionScript: { path: "/actionScript", method: "POST" }, - buildFunction: { path: "/buildFunction", method: "POST" }, - projectOwner: { path: "/projectOwner", method: "GET" }, - setOwnerRoles: { path: "/setOwnerRoles", method: "GET" }, -}; +export const runRoutes = { + impersonateUser: { path: "/impersonateUser", method: "GET" } as RunRoute, + version: { path: "/version", method: "GET" } as RunRoute, + region: { path: "/region", method: "GET" } as RunRoute, + firestoreRules: { path: "/firestoreRules", method: "GET" } as RunRoute, + setFirestoreRules: { path: "/setFirestoreRules", method: "POST" } as RunRoute, + listCollections: { path: "/listCollections", method: "GET" } as RunRoute, + serviceAccountAccess: { + path: "/serviceAccountAccess", + method: "GET", + } as RunRoute, + checkFT2Rowy: { path: "/checkFT2Rowy", method: "GET" } as RunRoute, + migrateFT2Rowy: { path: "/migrateFT2Rowy", method: "GET" } as RunRoute, + actionScript: { path: "/actionScript", method: "POST" } as RunRoute, + buildFunction: { path: "/buildFunction", method: "POST" } as RunRoute, + projectOwner: { path: "/projectOwner", method: "GET" } as RunRoute, + setOwnerRoles: { path: "/setOwnerRoles", method: "GET" } as RunRoute, +} as const; diff --git a/src/pages/Setup.tsx b/src/pages/Setup.tsx index a9bdddcf..64330f4f 100644 --- a/src/pages/Setup.tsx +++ b/src/pages/Setup.tsx @@ -34,6 +34,9 @@ import Step1RowyRun, { checkRowyRun } from "components/Setup/Step1RowyRun"; import Step2ServiceAccount, { checkServiceAccount } from "components/Setup/Step2ServiceAccount"; // prettier-ignore import Step3ProjectOwner, { checkProjectOwner } from "@src/components/Setup/Step3ProjectOwner"; +import Step4Rules, { checkRules } from "components/Setup/Step4Rules"; +import Step5Migrate, { checkMigrate } from "components/Setup/Step5Migrate"; +import Step6Finish from "components/Setup/Step6Finish"; import { name } from "@root/package.json"; import routes from "constants/routes"; @@ -60,6 +63,7 @@ const checkAllSteps = async ( rowyRunUrl: string, currentUser: firebase.default.User | null | undefined, userRoles: string[] | null, + authToken: string, signal: AbortSignal ) => { console.log("Check all steps"); @@ -69,23 +73,30 @@ const checkAllSteps = async ( if (rowyRunValidation.isValidRowyRunUrl) { if (rowyRunValidation.isLatestVersion) completion.rowyRun = true; - const serviceAccount = await checkServiceAccount(rowyRunUrl, signal); - if (serviceAccount) completion.serviceAccount = true; - - const projectOwner = await checkProjectOwner( - rowyRunUrl, - currentUser, - userRoles, - signal - ); - if (projectOwner) completion.projectOwner = true; + const promises = [ + checkServiceAccount(rowyRunUrl, signal).then((serviceAccount) => { + if (serviceAccount) completion.serviceAccount = true; + }), + checkProjectOwner(rowyRunUrl, currentUser, userRoles, signal).then( + (projectOwner) => { + if (projectOwner) completion.projectOwner = true; + } + ), + checkRules(rowyRunUrl, authToken, signal).then((rules) => { + if (rules) completion.rules = true; + }), + checkMigrate(rowyRunUrl, authToken, signal).then((requiresMigration) => { + if (requiresMigration) completion.migrate = false; + }), + ]; + await Promise.all(promises); } return completion; }; export default function SetupPage() { - const { currentUser, userRoles } = useAppContext(); + const { currentUser, userRoles, getAuthToken } = useAppContext(); const fullScreenHeight = use100vh() ?? 0; const isMobile = useMediaQuery((theme: any) => theme.breakpoints.down("sm")); @@ -110,16 +121,24 @@ export default function SetupPage() { if (rowyRunUrl) { setCheckingAllSteps(true); - checkAllSteps(rowyRunUrl, currentUser, userRoles, signal).then( - (result) => { - if (!signal.aborted) setCompletion((c) => ({ ...c, ...result })); - setCheckingAllSteps(false); - } + getAuthToken().then((authToken) => + checkAllSteps( + rowyRunUrl, + currentUser, + userRoles, + authToken, + signal + ).then((result) => { + if (!signal.aborted) { + setCompletion((c) => ({ ...c, ...result })); + setCheckingAllSteps(false); + } + }) ); } return () => controller.abort(); - }, [rowyRunUrl, currentUser, userRoles]); + }, [rowyRunUrl, currentUser, userRoles, getAuthToken]); const stepProps = { completion, setCompletion, checkAllSteps, rowyRunUrl }; @@ -176,14 +195,14 @@ export default function SetupPage() { id: "rules", shortTitle: `Rules`, title: `Set Up Firestore Rules`, - body: `x`, + body: , }, completion.migrate !== undefined ? { id: "migrate", shortTitle: `Migrate`, title: `Migrate to ${name} (optional)`, - body: `x`, + body: , } : ({} as ISetupStep), { @@ -191,21 +210,17 @@ export default function SetupPage() { layout: "centered" as "centered", shortTitle: `Finish`, title: `You’re all set up!`, - body: ( -
- You can now create a table from your Firestore collections or continue - to {name} -
- ), + body: , actions: ( - <> - - - + ), }, ].filter((x) => x.id); @@ -239,7 +254,7 @@ export default function SetupPage() { backdropFilter: "blur(20px) saturate(150%)", maxWidth: 840, - width: "100%", + width: (theme) => `calc(100vw - ${theme.spacing(2)})`, maxHeight: (theme) => `calc(${ fullScreenHeight > 0 ? `${fullScreenHeight}px` : "100vh" @@ -249,7 +264,7 @@ export default function SetupPage() { height: 840 * 0.75, p: 0, - "& > *": { px: { xs: 2, sm: 4 } }, + "& > *, & > .MuiDialogContent-root": { px: { xs: 2, sm: 4 } }, display: "flex", flexDirection: "column", @@ -373,7 +388,10 @@ export default function SetupPage() { - + {step.body} diff --git a/src/theme/components.tsx b/src/theme/components.tsx index 964587ac..7c6f2e72 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -42,14 +42,29 @@ export const components = (theme: Theme): ThemeOptions => { components: { MuiCssBaseline: { styleOverrides: { - code: { + // https://css-tricks.com/revisiting-prefers-reduced-motion-the-reduced-motion-media-query/ + "@media screen and (prefers-reduced-motion: reduce), (update: slow)": + { + "*:not(.MuiCircularProgress-root *):not(.MuiLinearProgress-root *)": + { + animationDuration: "0.001ms !important", + animationIteratonCount: "1 !important", + transitionDuration: "0.001ms !important", + scrollBehavior: "auto !important", + }, + }, + + "code, pre, pre.MuiTypography-root": { fontFamily: theme.typography.fontFamilyMono, letterSpacing: 0, - backgroundColor: theme.palette.action.selected, + backgroundColor: theme.palette.action.hover, borderRadius: theme.shape.borderRadius, padding: `${1 / 16}em ${4 / 16}em`, }, + "pre, pre.MuiTypography-root": { + padding: `${4 / 16}em ${8 / 16}em`, + }, ".chrome-picker": { colorScheme: "light", @@ -818,7 +833,7 @@ export const components = (theme: Theme): ThemeOptions => { MuiStepIcon: { styleOverrides: { root: { - color: theme.palette.action.selected, + color: theme.palette.action.hover, "&.Mui-completed:not(.Mui-active)": { color: theme.palette.text.disabled, }, diff --git a/yarn.lock b/yarn.lock index 9e089817..619d2e8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2672,10 +2672,10 @@ use-debounce "^3.4.3" yup "^0.32.9" -"@rowy/multiselect@^0.1.6": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@rowy/multiselect/-/multiselect-0.1.6.tgz#a1edfbc67d4f9267cb73a3d33d5a2570662b74d1" - integrity sha512-NxyskBT/8GA1ARtWv1XSBr8ltfmArhnETujbf/PgT7sRtDhv5dFB/XNSMH46rNYuN6zGmG1jHR/pIRs2fTy08w== +"@rowy/multiselect@^0.1.7": + version "0.1.7" + resolved "https://registry.yarnpkg.com/@rowy/multiselect/-/multiselect-0.1.7.tgz#d751cf12886a560f25ba9aa98908ab1aa124b78b" + integrity sha512-sCvnWl5sP6W5N3NQI360diu+Iktxh4VmsaiHmTk9Y85BdPPjeTTKcTuKqbewZsYXDkk4gEcxOpx5XD00Ap9xhw== "@sindresorhus/is@^0.14.0": version "0.14.0"