diff --git a/src/App.tsx b/src/App.tsx index 5c1aec54..0c5b9683 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,7 +52,7 @@ const UserSettingsPage = lazy(() => import("./pages/Settings/UserSettings" /* we // prettier-ignore const UserManagementPage = lazy(() => import("./pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */)); // prettier-ignore -const SetupPage = lazy(() => import("@src/pages/Setup" /* webpackChunkName: "SetupPage" */)); +const BasicSetupPage = lazy(() => import("@src/pages/Setup/BasicSetup" /* webpackChunkName: "BasicSetupPage" */)); export default function App() { return ( @@ -97,7 +97,7 @@ export default function App() { } + render={() => } /> { @@ -66,6 +77,16 @@ export default function Step6Finish() { /> + + ); } diff --git a/src/components/Setup/BasicSetup/StepRules.tsx b/src/components/Setup/BasicSetup/StepRules.tsx new file mode 100644 index 00000000..60f1444f --- /dev/null +++ b/src/components/Setup/BasicSetup/StepRules.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { useSnackbar } from "notistack"; +import type { ISetupStep, ISetupStepBodyProps } from "../types"; + +import { + Typography, + FormControlLabel, + Checkbox, + Button, + Grid, +} from "@mui/material"; +import CopyIcon from "@src/assets/icons/Copy"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; +import DoneIcon from "@mui/icons-material/Done"; + +import SetupItem from "../SetupItem"; + +import { useAppContext } from "@src/contexts/AppContext"; +import { CONFIG } from "@src/config/dbPaths"; +import { + RULES_START, + RULES_END, + REQUIRED_RULES, + ADMIN_RULES, + RULES_UTILS, +} from "@src/config/firestoreRules"; + +export default { + id: "rules", + shortTitle: "Firestore Rules", + title: "Set up Firestore Rules", + body: StepRules, +} as ISetupStep; + +function StepRules({ isComplete, setComplete }: ISetupStepBodyProps) { + const { projectId } = useAppContext(); + const { enqueueSnackbar } = useSnackbar(); + + const [adminRule, setAdminRule] = useState(true); + + const rules = + RULES_START + + (adminRule ? ADMIN_RULES : "") + + REQUIRED_RULES + + RULES_UTILS + + RULES_END; + + return ( + <> + + Rowy configuration is stored in the {CONFIG} collection on + Firestore. Your users will need read access to this collection and + admins will need write access. + + + + setAdminRule(e.target.checked)} + /> + } + label="Allow admins to read and write all documents" + sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }} + /> + + $1` + ), + }} + /> + +
+ + + + + + + + + +
+
+ + } + onClick={() => setComplete()} + > + Mark as done + + ) + } + status={isComplete ? "complete" : "incomplete"} + /> + + ); +} diff --git a/src/components/Setup/BasicSetup/StepStorageRules.tsx b/src/components/Setup/BasicSetup/StepStorageRules.tsx new file mode 100644 index 00000000..408ffc6e --- /dev/null +++ b/src/components/Setup/BasicSetup/StepStorageRules.tsx @@ -0,0 +1,112 @@ +import { useSnackbar } from "notistack"; +import type { ISetupStep, ISetupStepBodyProps } from "../types"; + +import { Typography, Button, Grid } from "@mui/material"; +import CopyIcon from "@src/assets/icons/Copy"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; +import DoneIcon from "@mui/icons-material/Done"; + +import SetupItem from "../SetupItem"; + +import { useAppContext } from "@src/contexts/AppContext"; +import { + RULES_START, + RULES_END, + REQUIRED_RULES, +} from "@src/config/storageRules"; + +export default { + id: "storageRules", + shortTitle: "Storage Rules", + title: "Set up Firebase Storage Rules", + body: StepStorageRules, +} as ISetupStep; + +const rules = RULES_START + REQUIRED_RULES + RULES_END; + +function StepStorageRules({ isComplete, setComplete }: ISetupStepBodyProps) { + const { projectId } = useAppContext(); + const { enqueueSnackbar } = useSnackbar(); + + return ( + <> + + Image and File fields store files in Firebase Storage. Your users will + need read and write access. + + + + $1` + ), + }} + /> + +
+ + + + + + + + + +
+
+ + } + onClick={() => setComplete()} + sx={{ mt: -0.5 }} + > + Mark as done + + ) + } + status={isComplete ? "complete" : "incomplete"} + /> + + ); +} diff --git a/src/components/Setup/BasicSetup/StepWelcome.tsx b/src/components/Setup/BasicSetup/StepWelcome.tsx new file mode 100644 index 00000000..72ea8208 --- /dev/null +++ b/src/components/Setup/BasicSetup/StepWelcome.tsx @@ -0,0 +1,91 @@ +import type { ISetupStep, ISetupStepBodyProps } from "../types"; + +import { + FormControlLabel, + Checkbox, + Typography, + Link, + Button, +} from "@mui/material"; + +import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; +import { useAppContext } from "@src/contexts/AppContext"; + +export default { + id: "welcome", + layout: "centered", + shortTitle: "Welcome", + title: "Welcome", + body: StepWelcome, +} as ISetupStep; + +function StepWelcome({ isComplete, setComplete }: ISetupStepBodyProps) { + const { projectId } = useAppContext(); + + return ( + <> +
+ + Get started with Rowy in just a few minutes. + + + We have no access to your data and it always stays on your Firebase + project. + + + Project: {projectId} + +
+ + setComplete(e.target.checked)} + /> + } + label={ + <> + I agree to the{" "} + + Terms and Conditions + {" "} + and{" "} + + Privacy Policy + + + } + sx={{ + pr: 1, + textAlign: "left", + alignItems: "flex-start", + p: 0, + m: 0, + }} + /> + + + + ); +} diff --git a/src/components/Setup/Step1Oauth.tsx b/src/components/Setup/RowyAppSetup/StepOauth.tsx similarity index 78% rename from src/components/Setup/Step1Oauth.tsx rename to src/components/Setup/RowyAppSetup/StepOauth.tsx index 4a648e38..b7029cfb 100644 --- a/src/components/Setup/Step1Oauth.tsx +++ b/src/components/Setup/RowyAppSetup/StepOauth.tsx @@ -1,16 +1,20 @@ -import { ISetupStepBodyProps } from "@src/pages/Setup"; +import type { ISetupStep, ISetupStepBodyProps } from "../types"; import { Typography, Link, Button } from "@mui/material"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; -import SetupItem from "./SetupItem"; +import SetupItem from "../SetupItem"; import { WIKI_LINKS } from "@src/constants/externalLinks"; -export default function Step1Oauth({ - completion, - setCompletion, -}: ISetupStepBodyProps) { +export default { + id: "oauth", + shortTitle: "Access", + title: "Allow Firebase access", + body: StepOauth, +} as ISetupStep; + +function StepOauth({ isComplete, setComplete }: ISetupStepBodyProps) { return ( <>
@@ -44,7 +48,7 @@ export default function Step1Oauth({ height="20" /> } - onClick={() => setCompletion((c) => ({ ...c, oauth: true }))} + onClick={() => setComplete()} > Sign in with Google diff --git a/src/components/Setup/RowyAppSetup/StepProject.tsx b/src/components/Setup/RowyAppSetup/StepProject.tsx new file mode 100644 index 00000000..1a886ab9 --- /dev/null +++ b/src/components/Setup/RowyAppSetup/StepProject.tsx @@ -0,0 +1,80 @@ +import { ISetupStep, ISetupStepBodyProps } from "../types"; + +import { + useMediaQuery, + Typography, + Stack, + TextField, + MenuItem, + Divider, + Button, +} from "@mui/material"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; + +import SetupItem from "../SetupItem"; + +export default { + id: "project", + shortTitle: "Project", + title: "Select project", + body: StepProject, +} as ISetupStep; + +function StepProject({ isComplete, setComplete }: ISetupStepBodyProps) { + const isMobile = useMediaQuery((theme: any) => theme.breakpoints.down("md")); + + return ( + <> + + Select which Firebase project to set up Rowy on. + + + + + + v || ( + + Select a project… + + ), + }} + > + lorem + ipsum + dolor + sit + amet + + + + OR + + + + + + + ); +} diff --git a/src/components/Setup/Step3Rules.tsx b/src/components/Setup/RowyAppSetup/StepRules.tsx similarity index 66% rename from src/components/Setup/Step3Rules.tsx rename to src/components/Setup/RowyAppSetup/StepRules.tsx index 2e087924..ad7e169c 100644 --- a/src/components/Setup/Step3Rules.tsx +++ b/src/components/Setup/RowyAppSetup/StepRules.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { ISetupStepBodyProps } from "@src/pages/Setup"; +import type { ISetupStep, ISetupStepBodyProps } from "../types"; import { Typography, @@ -14,24 +14,32 @@ import InfoIcon from "@mui/icons-material/InfoOutlined"; import CopyIcon from "@src/assets/icons/Copy"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; -import SetupItem from "./SetupItem"; +import SetupItem from "../SetupItem"; import DiffEditor from "@src/components/CodeEditor/DiffEditor"; import { useAppContext } from "@src/contexts/AppContext"; import { CONFIG } from "@src/config/dbPaths"; import { - requiredRules, - adminRules, - utilFns, - insecureRule, + RULES_START, + RULES_END, + REQUIRED_RULES, + ADMIN_RULES, + RULES_UTILS, + INSECURE_RULES, } from "@src/config/firestoreRules"; import { rowyRun } from "@src/utils/rowyRun"; import { runRoutes } from "@src/constants/runRoutes"; // import { useConfirmation } from "@src/components/ConfirmationDialog"; +export default { + id: "rules", + shortTitle: "Firestore Rules", + title: "Set up Firestore Rules", + body: StepRules, +} as ISetupStep; + const insecureRuleRegExp = new RegExp( - insecureRule - .replace(/\//g, "\\/") + INSECURE_RULES.replace(/\//g, "\\/") .replace(/\*/g, "\\*") .replace(/\s{2,}/g, "\\s+") .replace(/\s/g, "\\s*") @@ -39,25 +47,23 @@ const insecureRuleRegExp = new RegExp( .replace(/;/g, ";?") ); -export default function Step3Rules({ +function StepRules({ rowyRunUrl, - completion, - setCompletion, -}: ISetupStepBodyProps) { + isComplete, + setComplete, +}: ISetupStepBodyProps & { rowyRunUrl: string }) { const { projectId, getAuthToken } = useAppContext(); // const { requestConfirmation } = useConfirmation(); const [error, setError] = useState(false); - const [hasRules, setHasRules] = useState(completion.rules); + const [hasRules, setHasRules] = useState(isComplete); const [adminRule, setAdminRule] = useState(true); const [showManualMode, setShowManualMode] = useState(false); - const rules = `${ - error === "security-rules/not-found" - ? `rules_version = '2';\n\nservice cloud.firestore {\n match /databases/{database}/documents {\n` - : "" - }${adminRule ? adminRules : ""}${requiredRules}${utilFns}${ - error === "security-rules/not-found" ? " }\n}" : "" + const rules = `${error === "security-rules/not-found" ? RULES_START : ""}${ + adminRule ? ADMIN_RULES : "" + }${REQUIRED_RULES}${RULES_UTILS}${ + error === "security-rules/not-found" ? RULES_END : "" }`.replace("\n", ""); const [currentRules, setCurrentRules] = useState(""); @@ -120,7 +126,7 @@ export default function Step3Rules({ if (!res.success) throw new Error(res.message); const isSuccessful = await checkRules(rowyRunUrl, authToken); if (isSuccessful) { - setCompletion((c) => ({ ...c, rules: true })); + setComplete(); setHasRules(true); } setRulesStatus(""); @@ -137,7 +143,7 @@ export default function Step3Rules({ const isSuccessful = await checkRules(rowyRunUrl, authToken); if (isSuccessful) { - setCompletion((c) => ({ ...c, rules: true })); + setComplete(); setHasRules(true); } setRulesStatus(""); @@ -154,7 +160,7 @@ export default function Step3Rules({ // confirm: "Skip", // cancel: "cancel", // handleConfirm: async () => { - // setCompletion((c) => ({ ...c, rules: true })); + // setComplete(); // setHasRules(true); // }, // }); @@ -215,6 +221,7 @@ export default function Step3Rules({ color="primary" onClick={setRules} loading={rulesStatus === "LOADING"} + style={{ position: "sticky", bottom: 8 }} > Set Firestore Rules @@ -236,75 +243,92 @@ export default function Step3Rules({ )} {!hasRules && showManualMode && ( - - + + $1` - ), - }} - /> + "& .comment": { color: "info.dark" }, + }} + dangerouslySetInnerHTML={{ + __html: rules.replace( + /(\/\/.*$)/gm, + `$1` + ), + }} + /> -
- - - +
+ + + + + + + + + + + {rulesStatus !== "LOADING" && + typeof rulesStatus === "string" && ( + + {rulesStatus} + + )} + +
+ - - - - - - - Verify - - {rulesStatus !== "LOADING" && typeof rulesStatus === "string" && ( - - {rulesStatus} - - )} - -
-
-
+ + Verify + + } + > + {rulesStatus !== "LOADING" && typeof rulesStatus === "string" && ( + + {rulesStatus} + + )} + + )} {hasRules && ( @@ -334,10 +358,10 @@ export const checkRules = async ( const sanitizedRules = rules.replace(/\s{2,}/g, " ").replace(/\n/g, " "); const hasRules = sanitizedRules.includes( - requiredRules.replace(/\s{2,}/g, " ").replace(/\n/g, " ") + REQUIRED_RULES.replace(/\s{2,}/g, " ").replace(/\n/g, " ") ) && sanitizedRules.includes( - utilFns.replace(/\s{2,}/g, " ").replace(/\n/g, " ") + RULES_UTILS.replace(/\s{2,}/g, " ").replace(/\n/g, " ") ); return hasRules; } catch (e: any) { diff --git a/src/components/Setup/Step0Welcome.tsx b/src/components/Setup/RowyAppSetup/StepWelcome.tsx similarity index 71% rename from src/components/Setup/Step0Welcome.tsx rename to src/components/Setup/RowyAppSetup/StepWelcome.tsx index 8ade6cd5..4157f585 100644 --- a/src/components/Setup/Step0Welcome.tsx +++ b/src/components/Setup/RowyAppSetup/StepWelcome.tsx @@ -1,4 +1,4 @@ -import { ISetupStepBodyProps } from "@src/pages/Setup"; +import type { ISetupStep, ISetupStepBodyProps } from "../types"; import { FormControlLabel, @@ -8,20 +8,17 @@ import { Button, } from "@mui/material"; -import { useAppContext } from "@src/contexts/AppContext"; import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; -export interface IStep0WelcomeProps extends ISetupStepBodyProps { - handleContinue: () => void; -} - -export default function Step0Welcome({ - completion, - setCompletion, - handleContinue, -}: IStep0WelcomeProps) { - const { projectId } = useAppContext(); +export default { + id: "welcome", + layout: "centered", + shortTitle: "Welcome", + title: "Welcome", + body: StepWelcome, +} as ISetupStep; +function StepWelcome({ isComplete, setComplete }: ISetupStepBodyProps) { return ( <>
@@ -32,18 +29,13 @@ export default function Step0Welcome({ We have no access to your data and it always stays on your Firebase project. - - Project: {projectId} -
- setCompletion((c) => ({ ...c, welcome: e.target.checked })) - } + checked={isComplete} + onChange={(e) => setComplete(e.target.checked)} /> } label={ @@ -83,8 +75,8 @@ export default function Step0Welcome({ variant="contained" color="primary" size="large" - disabled={!completion.welcome} - onClick={handleContinue} + disabled={!isComplete} + type="submit" > Get started diff --git a/src/components/Setup/Step1RowyRun.tsx b/src/components/Setup/RowyRunSetup/StepRowyRun.tsx similarity index 88% rename from src/components/Setup/Step1RowyRun.tsx rename to src/components/Setup/RowyRunSetup/StepRowyRun.tsx index 60c86699..f4d05ac0 100644 --- a/src/components/Setup/Step1RowyRun.tsx +++ b/src/components/Setup/RowyRunSetup/StepRowyRun.tsx @@ -1,32 +1,37 @@ import { useState, useEffect } from "react"; import { useLocation, useHistory } from "react-router-dom"; import queryString from "query-string"; -import { ISetupStepBodyProps } from "@src/pages/Setup"; +import type { ISetupStep, ISetupStepBodyProps } from "../types"; import { Button, Typography, Stack, TextField } from "@mui/material"; import LoadingButton from "@mui/lab/LoadingButton"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; -import SetupItem from "./SetupItem"; +import SetupItem from "../SetupItem"; import { rowyRun } from "@src/utils/rowyRun"; import { runRoutes } from "@src/constants/runRoutes"; import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks"; -export default function Step1RowyRun({ - completion, - setCompletion, - rowyRunUrl: paramsRowyRunUrl, -}: ISetupStepBodyProps) { +export default { + id: "rowyRun", + shortTitle: "Rowy Run", + title: "Set up Rowy Run", + body: StepRowyRun, +} as ISetupStep; + +function StepRowyRun({ + isComplete, + setComplete, +}: // rowyRunUrl: paramsRowyRunUrl, +ISetupStepBodyProps) { const { pathname } = useLocation(); const history = useHistory(); - const [isValidRowyRunUrl, setIsValidRowyRunUrl] = useState( - completion.rowyRun - ); - const [isLatestVersion, setIsLatestVersion] = useState(completion.rowyRun); + const [isValidRowyRunUrl, setIsValidRowyRunUrl] = useState(isComplete); + const [isLatestVersion, setIsLatestVersion] = useState(isComplete); - const [rowyRunUrl, setRowyRunUrl] = useState(paramsRowyRunUrl); + const [rowyRunUrl, setRowyRunUrl] = useState("paramsRowyRunUrl"); const [latestVersion, setLatestVersion] = useState(""); const [verificationStatus, setVerificationStatus] = useState< "IDLE" | "LOADING" | "FAIL" @@ -45,7 +50,7 @@ export default function Step1RowyRun({ if (result.isLatestVersion) { setIsLatestVersion(true); - setCompletion((c) => ({ ...c, rowyRun: true })); + setComplete(); history.replace({ pathname, search: queryString.stringify({ rowyRunUrl }), @@ -57,9 +62,9 @@ export default function Step1RowyRun({ } }; - useEffect(() => { - if (!isValidRowyRunUrl && paramsRowyRunUrl) console.log(paramsRowyRunUrl); - }, [paramsRowyRunUrl, isValidRowyRunUrl]); + // useEffect(() => { + // if (!isValidRowyRunUrl && paramsRowyRunUrl) console.log(paramsRowyRunUrl); + // }, [paramsRowyRunUrl, isValidRowyRunUrl]); const deployButton = window.location.hostname.includes( EXTERNAL_LINKS.rowyAppHostName diff --git a/src/components/Setup/SetupItem.tsx b/src/components/Setup/SetupItem.tsx index ea099950..3612ccbc 100644 --- a/src/components/Setup/SetupItem.tsx +++ b/src/components/Setup/SetupItem.tsx @@ -31,8 +31,17 @@ export default function SetupItem({ )} - - {title} + + + {title} + {children} diff --git a/src/components/Setup/SetupLayout.tsx b/src/components/Setup/SetupLayout.tsx new file mode 100644 index 00000000..9b3c6bc4 --- /dev/null +++ b/src/components/Setup/SetupLayout.tsx @@ -0,0 +1,269 @@ +import { useState, createElement } from "react"; +import { use100vh } from "react-div-100vh"; +import { SwitchTransition } from "react-transition-group"; +import type { ISetupStep } from "./types"; + +import { + useMediaQuery, + Paper, + Stepper, + Step, + StepButton, + MobileStepper, + IconButton, + Typography, + Stack, + DialogActions, +} from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import LoadingButton from "@mui/lab/LoadingButton"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; + +import BrandedBackground, { Wrapper } from "@src/assets/BrandedBackground"; +import Logo from "@src/assets/Logo"; +import ScrollableDialogContent from "@src/components/Modal/ScrollableDialogContent"; +import { SlideTransition } from "@src/components/Modal/SlideTransition"; + +import { analytics } from "analytics"; + +const BASE_WIDTH = 1024; + +export interface ISetupLayoutProps { + steps: ISetupStep[]; + completion: Record; + setCompletion: React.Dispatch>>; + continueButtonLoading?: boolean; +} + +export default function SetupLayout({ + steps, + completion, + setCompletion, + continueButtonLoading = false, +}: ISetupLayoutProps) { + const fullScreenHeight = use100vh() ?? 0; + const isMobile = useMediaQuery((theme: any) => theme.breakpoints.down("sm")); + + // Store current step’s ID to prevent confusion + const [stepId, setStepId] = useState("welcome"); + // Get current step object + const step = + steps.find((step) => step.id === (stepId || steps[0].id)) ?? steps[0]; + // Get current step index + const stepIndex = steps.indexOf(step); + const listedSteps = steps.filter((step) => step.layout !== "centered"); + + // Continue goes to the next incomplete step + const handleContinue = () => { + let nextIncompleteStepIndex = stepIndex + 1; + while (completion[steps[nextIncompleteStepIndex]?.id]) { + // console.log("iteration", steps[nextIncompleteStepIndex]?.id); + nextIncompleteStepIndex++; + } + + const nextStepId = steps[nextIncompleteStepIndex].id; + analytics.logEvent("setup_step", { step: nextStepId }); + setStepId(nextStepId); + }; + + // Inject props into step.body + const body = createElement(step.body, { + completion, + setCompletion, + isComplete: completion[step.id], + setComplete: (value: boolean = true) => + setCompletion((c) => ({ ...c, [step.id]: value })), + }); + + return ( + + +
{ + e.preventDefault(); + try { + handleContinue(); + } catch (e: any) { + throw new Error(e.message); + } + return false; + }} + > + + alpha(theme.palette.background.paper, 0.75), + backdropFilter: "blur(20px) saturate(150%)", + + maxWidth: BASE_WIDTH, + width: (theme) => `calc(100vw - ${theme.spacing(2)})`, + height: (theme) => + `calc(${ + fullScreenHeight > 0 ? `${fullScreenHeight}px` : "100vh" + } - ${theme.spacing( + 2 + )} - env(safe-area-inset-top) - env(safe-area-inset-bottom))`, + resize: "both", + + p: 0, + "& > *, & > .MuiDialogContent-root": { px: { xs: 2, sm: 4 } }, + display: "flex", + flexDirection: "column", + + "& .MuiTypography-inherit, & .MuiDialogContent-root": { + typography: "body1", + }, + + "& p": { + maxWidth: "70ch", + }, + }} + > + {stepId === "welcome" ? null : !isMobile ? ( + + {listedSteps.map(({ id, shortTitle }, i) => ( + + setStepId(id)} + disabled={i > 0 && !completion[listedSteps[i - 1]?.id]} + sx={{ py: 2, my: -2, borderRadius: 1 }} + > + {shortTitle} + + + ))} + + ) : ( + setStepId(steps[stepIndex - 1].id)} + > + + + } + nextButton={ + setStepId(steps[stepIndex + 1].id)} + > + + + } + position="static" + sx={{ + background: "none", + p: 0, + "& .MuiMobileStepper-dot": { mx: 0.5 }, + }} + /> + )} + + {step.layout === "centered" ? ( + + + {stepId === "welcome" && ( + + + + + + )} + + + + + {step.title} + + + + + + + + {body} + + + + + + ) : ( + <> + + + + {step.title} + + + + + + + + {body} + + + + + )} + + {step.layout !== "centered" && ( + + + Continue + + + )} + +
+
+ ); +} diff --git a/src/components/Setup/Step2Project.tsx b/src/components/Setup/Step2Project.tsx deleted file mode 100644 index 7dcb7de3..00000000 --- a/src/components/Setup/Step2Project.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ISetupStepBodyProps } from "@src/pages/Setup"; - -import { - Typography, - TextField, - MenuItem, - Divider, - Button, -} from "@mui/material"; -import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; - -import SetupItem from "./SetupItem"; - -import { WIKI_LINKS } from "@src/constants/externalLinks"; - -export default function Step1Oauth({ - completion, - setCompletion, -}: ISetupStepBodyProps) { - return ( - <> - - Select which Firebase project to set up Rowy on. - - - - - lorem - ipsum - dolor - sit - amet - - - OR - - - - - ); -} diff --git a/src/components/Setup/Step2ProjectOwner.tsx b/src/components/Setup/Step2ProjectOwner.tsx deleted file mode 100644 index b61ee872..00000000 --- a/src/components/Setup/Step2ProjectOwner.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useState, useEffect } from "react"; -import { ISetupStepBodyProps } from "@src/pages/Setup"; - -import { Typography, Stack, Button, IconButton } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; -import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; - -import SetupItem from "./SetupItem"; -import SignInWithGoogle from "./SignInWithGoogle"; - -import { useAppContext } from "@src/contexts/AppContext"; -import { rowyRun } from "@src/utils/rowyRun"; -import { runRoutes } from "@src/constants/runRoutes"; -import CopyIcon from "@src/assets/icons/Copy"; - -export default function Step2ProjectOwner({ - rowyRunUrl, - completion, - setCompletion, -}: ISetupStepBodyProps) { - const { projectId, currentUser, getAuthToken } = useAppContext(); - - const [email, setEmail] = useState(""); - useEffect(() => { - rowyRun({ serviceUrl: rowyRunUrl, route: runRoutes.projectOwner }) - .then((data) => setEmail(data.email)) - .catch((e: any) => { - console.error(e); - alert(`Failed to get project owner email: ${e.message}`); - }); - }, [rowyRunUrl]); - - const [isDomainAuthorized, setIsDomainAuthorized] = useState( - !!currentUser || completion.projectOwner - ); - const isSignedIn = currentUser?.email?.toLowerCase() === email.toLowerCase(); - const [hasRoles, setHasRoles] = useState( - completion.projectOwner - ); - - const setRoles = async () => { - setHasRoles("LOADING"); - try { - const authToken = await getAuthToken(); - const res = await rowyRun({ - route: runRoutes.setOwnerRoles, - serviceUrl: rowyRunUrl, - authToken, - }); - - if (!res.success) - throw new Error(`${res.message}. Project owner: ${res.ownerEmail}`); - - setHasRoles(true); - setCompletion((c) => ({ ...c, projectOwner: true })); - } catch (e: any) { - console.error(e); - setHasRoles(e.message); - } - }; - - return ( - <> - - The project owner requires full access to manage this project. The - default project owner is the Google Cloud account used to deploy Rowy - Run: {email} - - - - {!(isSignedIn || isDomainAuthorized) && ( - <> -
    -
  1. the Google auth provider enabled and
  2. -
  3. - this domain authorized:{" "} - {window.location.hostname} - - navigator.clipboard.writeText(window.location.hostname) - } - > - - -
  4. -
- - - - - - - - )} -
- - {isDomainAuthorized && ( - - Sign in as the project owner: {email} - - ) - } - > - {!isSignedIn && ( - - )} - - )} - - {isSignedIn && ( - - {hasRoles !== true && ( -
- - Assign roles - - - {typeof hasRoles === "string" && hasRoles !== "LOADING" && ( - - {hasRoles} - - )} -
- )} -
- )} - - ); -} - -export const checkProjectOwner = async ( - rowyRunUrl: string, - currentUser: firebase.default.User | null | undefined, - userRoles: string[] | null, - signal?: AbortSignal -) => { - if (!currentUser || !Array.isArray(userRoles)) return false; - - try { - const res = await rowyRun({ - serviceUrl: rowyRunUrl, - route: runRoutes.projectOwner, - signal, - }); - const email = res.email; - if (currentUser.email !== email) return false; - return userRoles.includes("ADMIN") && userRoles.includes("OWNER"); - } catch (e: any) { - console.error(e); - return false; - } -}; diff --git a/src/components/Setup/Step4Migrate.tsx b/src/components/Setup/Step4Migrate.tsx deleted file mode 100644 index 1e3d0fb7..00000000 --- a/src/components/Setup/Step4Migrate.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from "react"; -import { ISetupStepBodyProps } from "@src/pages/Setup"; - -import { Typography } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - -import SetupItem from "./SetupItem"; - -import { useAppContext } from "@src/contexts/AppContext"; -import { CONFIG } from "@src/config/dbPaths"; -import { rowyRun } from "@src/utils/rowyRun"; -import { runRoutes } from "@src/constants/runRoutes"; - -export default function Step4Migrate({ - 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, - serviceUrl: 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 tables to{" "} - Rowy. - - - - 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({ - serviceUrl: rowyRunUrl, - route: runRoutes.checkFT2Rowy, - authToken, - signal, - }); - return res.migrationRequired; - } catch (e: any) { - console.error(e); - return false; - } -}; diff --git a/src/components/Setup/types.d.ts b/src/components/Setup/types.d.ts new file mode 100644 index 00000000..a4e3037f --- /dev/null +++ b/src/components/Setup/types.d.ts @@ -0,0 +1,15 @@ +export interface ISetupStep { + id: string; + layout?: "centered"; + shortTitle: string; + title: React.ReactNode; + description?: React.ReactNode; + body: React.ComponentType; +} + +export interface ISetupStepBodyProps { + completion: Record; + setCompletion: React.Dispatch>>; + isComplete: boolean; + setComplete: (value: boolean = true) => void; +} diff --git a/src/config/firestoreRules.ts b/src/config/firestoreRules.ts index e23047bd..8164abdf 100644 --- a/src/config/firestoreRules.ts +++ b/src/config/firestoreRules.ts @@ -1,6 +1,16 @@ import { CONFIG, USERS, PUBLIC_SETTINGS } from "./dbPaths"; -export const requiredRules = ` +export const RULES_START = `rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { +`; + +export const RULES_END = ` + } +}`; + +export const REQUIRED_RULES = ` // Rowy: Allow signed in users to read Rowy configuration and admins to write match /${CONFIG}/{docId} { allow read: if request.auth != null; @@ -21,14 +31,14 @@ export const requiredRules = ` } ` as const; -export const adminRules = ` +export const ADMIN_RULES = ` // Allow admins to read and write all documents match /{document=**} { allow read, write: if hasAnyRole(["ADMIN", "OWNER"]); } ` as const; -export const utilFns = ` +export const RULES_UTILS = ` // Rowy: Utility functions function isDocOwner(docId) { return request.auth != null && (request.auth.uid == resource.id || request.auth.uid == docId); @@ -38,7 +48,7 @@ export const utilFns = ` } ` as const; -export const insecureRule = ` +export const INSECURE_RULES = ` match /{document=**} { allow read, write: if true; } diff --git a/src/config/storageRules.ts b/src/config/storageRules.ts new file mode 100644 index 00000000..5abdd083 --- /dev/null +++ b/src/config/storageRules.ts @@ -0,0 +1,16 @@ +export const RULES_START = `rules_version = '2'; + +service firebase.storage { + match /b/{bucket}/o { +`; + +export const RULES_END = ` + } +}`; + +export const REQUIRED_RULES = ` + // Rowy: Allow signed in users with Roles to read and write to Storage + match /{allPaths=**} { + allow read, write: if request.auth.token.roles.size() > 0; + } +`; diff --git a/src/pages/Setup/BasicSetup.tsx b/src/pages/Setup/BasicSetup.tsx new file mode 100644 index 00000000..7c59d967 --- /dev/null +++ b/src/pages/Setup/BasicSetup.tsx @@ -0,0 +1,25 @@ +import { useState } from "react"; + +import SetupLayout from "@src/components/Setup/SetupLayout"; +import StepWelcome from "@src/components/Setup/BasicSetup/StepWelcome"; +import StepRules from "@src/components/Setup/BasicSetup/StepRules"; +import StepStorageRules from "@src/components/Setup/BasicSetup/StepStorageRules"; +import StepFinish from "@src/components/Setup/BasicSetup/StepFinish"; + +const steps = [StepWelcome, StepRules, StepStorageRules, StepFinish]; + +export default function BasicSetupPage() { + const [completion, setCompletion] = useState>({ + welcome: false, + rules: false, + storageRules: false, + }); + + return ( + + ); +} diff --git a/src/pages/Setup/RowyAppSetup.tsx b/src/pages/Setup/RowyAppSetup.tsx new file mode 100644 index 00000000..07242d42 --- /dev/null +++ b/src/pages/Setup/RowyAppSetup.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; + +import SetupLayout from "@src/components/Setup/SetupLayout"; +import StepWelcome from "@src/components/Setup/RowyAppSetup/StepWelcome"; +import StepOauth from "@src/components/Setup/RowyAppSetup/StepOauth"; +import StepProject from "@src/components/Setup/RowyAppSetup/StepProject"; +import StepRules from "@src/components/Setup/RowyAppSetup/StepRules"; +import StepStorageRules from "@src/components/Setup/BasicSetup/StepStorageRules"; +import StepFinish from "@src/components/Setup/BasicSetup/StepFinish"; + +const steps = [ + StepWelcome, + StepOauth, + StepProject, + StepRules, + StepStorageRules, + StepFinish, +]; + +export default function RowyAppSetupPage() { + const [completion, setCompletion] = useState>({ + welcome: false, + rules: false, + storageRules: false, + }); + + return ( + + ); +} diff --git a/src/pages/Setup.tsx b/src/pages/Setup/Setup.tsx.old similarity index 96% rename from src/pages/Setup.tsx rename to src/pages/Setup/Setup.tsx.old index f30e0c5d..77746075 100644 --- a/src/pages/Setup.tsx +++ b/src/pages/Setup/Setup.tsx.old @@ -28,12 +28,11 @@ import Logo from "@src/assets/Logo"; import ScrollableDialogContent from "@src/components/Modal/ScrollableDialogContent"; import { SlideTransition } from "@src/components/Modal/SlideTransition"; -import Step0Welcome from "@src/components/Setup/Step0Welcome"; +import StepWelcome from "@src/components/Setup/RowyAppSetup/StepWelcome"; import Step1Oauth from "@src/components/Setup/Step1Oauth"; -import Step2Project from "@src/components/Setup/Step2Project"; // prettier-ignore import Step2ProjectOwner, { checkProjectOwner } from "@src/components/Setup/Step2ProjectOwner"; -import Step3Rules, { checkRules } from "@src/components/Setup/Step3Rules"; +// import Step3Rules, { checkRules } from "@src/components/Setup/Step3Rules"; import Step4Migrate, { checkMigrate } from "@src/components/Setup/Step4Migrate"; import Step5Finish from "@src/components/Setup/Step6Finish"; @@ -157,8 +156,7 @@ export default function SetupPage() { layout: "centered" as "centered", shortTitle: "Welcome", title: `Welcome`, - body: , - actions: <>, + body: , }, // { // id: "rowyRun", @@ -182,19 +180,19 @@ export default function SetupPage() { id: "project", shortTitle: `Project`, title: `Select project`, - body: , + body: , }, { id: "rules", shortTitle: `Firestore Rules`, title: `Set up Firestore Rules`, - body: , + body: , }, { id: "storageRules", shortTitle: `Storage Rules`, title: `Set up Firestore Rules`, - body: , + body: , }, { id: "finish", @@ -214,7 +212,7 @@ export default function SetupPage() { // // ), }, - ].filter((x) => x.id); + ]; const step = steps.find((step) => step.id === (stepId || steps[0].id)) ?? steps[0];