restructure SetupPage to allow for multiple flows

This commit is contained in:
Sidney Alcantara
2022-02-16 16:03:46 +11:00
parent 362eca3eed
commit da94a1c4eb
24 changed files with 1001 additions and 515 deletions

View File

@@ -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() {
<Route
exact
path={routes.setup}
render={() => <SetupPage />}
render={() => <BasicSetupPage />}
/>
<Route
exact

View File

@@ -35,8 +35,8 @@ export default function CodeEditorHelper({
description: `firebase Storage can be accessed through this, storage.bucket() returns default storage bucket of the firebase project.`,
},
{
key: "utilFns",
description: `utilFns provides a set of functions that are commonly used, such as easy access to GCP Secret Manager`,
key: "RULES_UTILS",
description: `RULES_UTILS provides a set of functions that are commonly used, such as easy access to GCP Secret Manager`,
},
];

View File

@@ -24,7 +24,7 @@ type ExtensionContext = {
requiredFields: string[];
extensionBody: any;
};
utilFns: any;
RULES_UTILS: any;
};
// extension body definition

View File

@@ -1,7 +1,7 @@
/**
* utility functions
*/
declare namespace utilFns {
declare namespace RULES_UTILS {
/**
* Sends out an email through sendGrid
*/

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from "react";
import { useSnackbar } from "notistack";
import { Link } from "react-router-dom";
import type { ISetupStep } from "../types";
import { Typography, Stack, RadioGroup, Radio } from "@mui/material";
import { Typography, Stack, RadioGroup, Radio, Button } from "@mui/material";
import ThumbUpIcon from "@mui/icons-material/ThumbUpAlt";
import ThumbUpOffIcon from "@mui/icons-material/ThumbUpOffAlt";
import ThumbDownIcon from "@mui/icons-material/ThumbDownAlt";
@@ -9,8 +11,17 @@ import ThumbDownOffIcon from "@mui/icons-material/ThumbDownOffAlt";
import { analytics } from "analytics";
import { db } from "@src/firebase";
import { routes } from "@src/constants/routes";
export default function Step6Finish() {
export default {
id: "finish",
layout: "centered",
shortTitle: "Finish",
title: "Youre all set up!",
body: StepFinish,
} as ISetupStep;
function StepFinish() {
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
@@ -66,6 +77,16 @@ export default function Step6Finish() {
/>
</RadioGroup>
</Stack>
<Button
variant="contained"
color="primary"
size="large"
component={Link}
to={routes.auth}
>
Sign in to your Rowy project
</Button>
</>
);
}

View File

@@ -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 (
<>
<Typography variant="inherit">
Rowy configuration is stored in the <code>{CONFIG}</code> collection on
Firestore. Your users will need read access to this collection and
admins will need write access.
</Typography>
<SetupItem
status="incomplete"
title="Add the following rules to enable access to Rowy configuration:"
>
<FormControlLabel
control={
<Checkbox
checked={adminRule}
onChange={(e) => setAdminRule(e.target.checked)}
/>
}
label="Allow admins to read and write all documents"
sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }}
/>
<Typography
component="pre"
sx={{
width: "100%",
height: 400,
resize: "both",
overflow: "auto",
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
<div>
<Grid container spacing={1}>
<Grid item>
<Button
startIcon={<CopyIcon />}
onClick={() => {
navigator.clipboard.writeText(rules);
enqueueSnackbar("Copied to clipboard");
}}
>
Copy to clipboard
</Button>
</Grid>
<Grid item>
<Button
variant="contained"
color="primary"
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Set up in Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
</Grid>
</div>
</SetupItem>
<SetupItem
title={
isComplete ? (
"Marked as done"
) : (
<Button
variant="contained"
color="primary"
startIcon={<DoneIcon />}
onClick={() => setComplete()}
>
Mark as done
</Button>
)
}
status={isComplete ? "complete" : "incomplete"}
/>
</>
);
}

View File

@@ -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 (
<>
<Typography variant="inherit">
Image and File fields store files in Firebase Storage. Your users will
need read and write access.
</Typography>
<SetupItem
status="incomplete"
title="Add the following rules to allow users to access Firebase Storage:"
>
<Typography
component="pre"
sx={{
width: "100%",
height: 250,
resize: "both",
overflow: "auto",
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
<div>
<Grid container spacing={1}>
<Grid item>
<Button
startIcon={<CopyIcon />}
onClick={() => {
navigator.clipboard.writeText(rules);
enqueueSnackbar("Copied to clipboard");
}}
>
Copy to clipboard
</Button>
</Grid>
<Grid item>
<Button
variant="contained"
color="primary"
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Set up in Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
</Grid>
</div>
</SetupItem>
<SetupItem
title={
isComplete ? (
"Marked as done"
) : (
<Button
variant="contained"
color="primary"
startIcon={<DoneIcon />}
onClick={() => setComplete()}
sx={{ mt: -0.5 }}
>
Mark as done
</Button>
)
}
status={isComplete ? "complete" : "incomplete"}
/>
</>
);
}

View File

@@ -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 (
<>
<div>
<Typography variant="body1" paragraph>
Get started with Rowy in just a few minutes.
</Typography>
<Typography variant="body1" paragraph>
We have no access to your data and it always stays on your Firebase
project.
</Typography>
<Typography variant="body1" paragraph>
Project: <code>{projectId}</code>
</Typography>
</div>
<FormControlLabel
control={
<Checkbox
checked={isComplete}
onChange={(e) => setComplete(e.target.checked)}
/>
}
label={
<>
I agree to the{" "}
<Link
href={EXTERNAL_LINKS.terms}
target="_blank"
rel="noopener noreferrer"
variant="body2"
color="text.primary"
>
Terms and Conditions
</Link>{" "}
and{" "}
<Link
href={EXTERNAL_LINKS.privacy}
target="_blank"
rel="noopener noreferrer"
variant="body2"
color="text.primary"
>
Privacy Policy
</Link>
</>
}
sx={{
pr: 1,
textAlign: "left",
alignItems: "flex-start",
p: 0,
m: 0,
}}
/>
<Button
variant="contained"
color="primary"
size="large"
disabled={!isComplete}
type="submit"
>
Get started
</Button>
</>
);
}

View File

@@ -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 (
<>
<div>
@@ -44,7 +48,7 @@ export default function Step1Oauth({
height="20"
/>
}
onClick={() => setCompletion((c) => ({ ...c, oauth: true }))}
onClick={() => setComplete()}
>
Sign in with Google
</Button>

View File

@@ -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 (
<>
<Typography variant="inherit">
Select which Firebase project to set up Rowy on.
</Typography>
<SetupItem
title="Select an existing project or create a new one."
status="incomplete"
>
<Stack
spacing={2}
direction={isMobile ? "column" : "row"}
justifyContent="flex-start"
alignItems="center"
>
<TextField
label="Project"
select
fullWidth
style={isMobile ? undefined : { minWidth: 300 }}
helperText={isMobile ? undefined : " "}
SelectProps={{
displayEmpty: true,
renderValue: (v: any) =>
v || (
<Typography color="text.disabled">
Select a project
</Typography>
),
}}
>
<MenuItem value="lorem">lorem</MenuItem>
<MenuItem value="ipsum">ipsum</MenuItem>
<MenuItem value="dolor">dolor</MenuItem>
<MenuItem value="sit">sit</MenuItem>
<MenuItem value="amet">amet</MenuItem>
</TextField>
<Divider orientation={isMobile ? "horizontal" : "vertical"} flexItem>
OR
</Divider>
<Button
onClick={() => setComplete()}
style={isMobile ? undefined : { minWidth: 300 }}
>
Create project in Firebase Console
<InlineOpenInNewIcon />
</Button>
</Stack>
</SetupItem>
</>
);
}

View File

@@ -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<string | false>(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
</LoadingButton>
@@ -236,75 +243,92 @@ export default function Step3Rules({
)}
{!hasRules && showManualMode && (
<SetupItem
status="incomplete"
title={
error === "security-rules/not-found"
? "Add the following rules in the Firebase Console to enable access to Rowy configuration:"
: "Alternatively, you can add these rules in the Firebase Console."
}
>
<Typography
variant="caption"
component="pre"
sx={{
width: "100%",
height: 400,
resize: "both",
overflow: "auto",
<>
<SetupItem
status="incomplete"
title={
error === "security-rules/not-found"
? "Add the following rules in the Firebase Console to enable access to Rowy configuration:"
: "Alternatively, you can add these rules in the Firebase Console."
}
>
<Typography
variant="caption"
component="pre"
sx={{
width: "100%",
height: 400,
resize: "both",
overflow: "auto",
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
<div>
<Grid container spacing={1}>
<Grid item>
<Button
startIcon={<CopyIcon />}
onClick={() => navigator.clipboard.writeText(rules)}
>
Copy to clipboard
</Button>
<div>
<Grid container spacing={1}>
<Grid item>
<Button
startIcon={<CopyIcon />}
onClick={() => navigator.clipboard.writeText(rules)}
>
Copy to clipboard
</Button>
</Grid>
<Grid item>
<Button
variant="contained"
color="primary"
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Set up in Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
<Grid item>
{rulesStatus !== "LOADING" &&
typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
)}
</Grid>
</Grid>
</div>
</SetupItem>
<Grid item>
<Button
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
<Grid item>
<LoadingButton
variant="contained"
color="primary"
onClick={verifyRules}
loading={rulesStatus === "LOADING"}
>
Verify
</LoadingButton>
{rulesStatus !== "LOADING" && typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
)}
</Grid>
</Grid>
</div>
</SetupItem>
<SetupItem
status="incomplete"
title={
<LoadingButton
variant="contained"
color="primary"
onClick={verifyRules}
loading={rulesStatus === "LOADING"}
>
Verify
</LoadingButton>
}
>
{rulesStatus !== "LOADING" && typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
)}
</SetupItem>
</>
)}
{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) {

View File

@@ -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 (
<>
<div>
@@ -32,18 +29,13 @@ export default function Step0Welcome({
We have no access to your data and it always stays on your Firebase
project.
</Typography>
<Typography variant="body1">
Project: <b>{projectId}</b>
</Typography>
</div>
<FormControlLabel
control={
<Checkbox
checked={completion.welcome}
onChange={(e) =>
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
</Button>

View File

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

View File

@@ -31,8 +31,17 @@ export default function SetupItem({
<ArrowIcon aria-label="Item" color="primary" />
)}
<Stack spacing={2} alignItems="flex-start" style={{ flexGrow: 1 }}>
<Typography variant="inherit">{title}</Typography>
<Stack
spacing={2}
alignItems="flex-start"
style={{ flexGrow: 1, minWidth: 0 }}
>
<Typography
variant="inherit"
sx={{ "& .MuiButton-root": { mt: -0.5 } }}
>
{title}
</Typography>
{children}
</Stack>

View File

@@ -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<string, boolean>;
setCompletion: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
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 steps 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 (
<Wrapper>
<BrandedBackground />
<form
onSubmit={(e) => {
e.preventDefault();
try {
handleContinue();
} catch (e: any) {
throw new Error(e.message);
}
return false;
}}
>
<Paper
component="main"
elevation={4}
sx={{
backgroundColor: (theme) =>
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 ? (
<Stepper
activeStep={stepIndex - 1}
nonLinear
sx={{
mt: 2.5,
mb: 3,
"& .MuiStep-root:first-child": { pl: 0 },
"& .MuiStep-root:last-child": { pr: 0 },
userSelect: "none",
}}
>
{listedSteps.map(({ id, shortTitle }, i) => (
<Step key={id} completed={completion[id]}>
<StepButton
onClick={() => setStepId(id)}
disabled={i > 0 && !completion[listedSteps[i - 1]?.id]}
sx={{ py: 2, my: -2, borderRadius: 1 }}
>
{shortTitle}
</StepButton>
</Step>
))}
</Stepper>
) : (
<MobileStepper
variant="dots"
steps={listedSteps.length}
activeStep={stepIndex - 1}
backButton={
<IconButton
aria-label="Previous step"
disabled={stepIndex === 0}
onClick={() => setStepId(steps[stepIndex - 1].id)}
>
<ChevronLeftIcon />
</IconButton>
}
nextButton={
<IconButton
aria-label="Next step"
disabled={!completion[stepId]}
onClick={() => setStepId(steps[stepIndex + 1].id)}
>
<ChevronRightIcon />
</IconButton>
}
position="static"
sx={{
background: "none",
p: 0,
"& .MuiMobileStepper-dot": { mx: 0.5 },
}}
/>
)}
{step.layout === "centered" ? (
<ScrollableDialogContent disableTopDivider>
<Stack
alignItems="center"
justifyContent="center"
spacing={3}
sx={{
minHeight: "100%",
maxWidth: 440,
margin: "0 auto",
textAlign: "center",
py: 3,
}}
>
{stepId === "welcome" && (
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={50}>
<Logo size={2} />
</SlideTransition>
</SwitchTransition>
)}
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={100}>
<Typography
variant="h4"
component="h1"
sx={{ mb: 1, typography: { xs: "h5", md: "h4" } }}
>
{step.title}
</Typography>
</SlideTransition>
</SwitchTransition>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={150}>
<Stack spacing={4} alignItems="center">
{body}
</Stack>
</SlideTransition>
</SwitchTransition>
</Stack>
</ScrollableDialogContent>
) : (
<>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={50}>
<Typography
variant="h4"
component="h1"
sx={{ mb: 1, typography: { xs: "h5", md: "h4" } }}
>
{step.title}
</Typography>
</SlideTransition>
</SwitchTransition>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={100}>
<ScrollableDialogContent
disableTopDivider={step.layout === "centered"}
sx={{ overflowX: "auto" }}
>
<Stack spacing={4}>{body}</Stack>
</ScrollableDialogContent>
</SlideTransition>
</SwitchTransition>
</>
)}
{step.layout !== "centered" && (
<DialogActions>
<LoadingButton
variant="contained"
color="primary"
size="large"
type="submit"
loading={continueButtonLoading}
disabled={!completion[stepId]}
>
Continue
</LoadingButton>
</DialogActions>
)}
</Paper>
</form>
</Wrapper>
);
}

View File

@@ -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 (
<>
<Typography variant="inherit">
Select which Firebase project to set up Rowy on.
</Typography>
<SetupItem
title="Select an existing project or create a new one."
status="incomplete"
>
<TextField label="Project" select style={{ minWidth: 200 }}>
<MenuItem value="lorem">lorem</MenuItem>
<MenuItem value="ipsum">ipsum</MenuItem>
<MenuItem value="dolor">dolor</MenuItem>
<MenuItem value="sit">sit</MenuItem>
<MenuItem value="amet">amet</MenuItem>
</TextField>
<Divider style={{ width: 200 }}>OR</Divider>
<Button onClick={() => setCompletion((c) => ({ ...c, project: true }))}>
Create project in Firebase Console
<InlineOpenInNewIcon />
</Button>
</SetupItem>
</>
);
}

View File

@@ -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<boolean | "LOADING" | string>(
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 (
<>
<Typography variant="inherit">
The project owner requires full access to manage this project. The
default project owner is the Google Cloud account used to deploy Rowy
Run: <b style={{ userSelect: "all" }}>{email}</b>
</Typography>
<SetupItem
status={isSignedIn || isDomainAuthorized ? "complete" : "incomplete"}
title={
isSignedIn || isDomainAuthorized
? "Firebase Authentication is set up."
: "Check that Firebase Authentication is set up with:"
}
>
{!(isSignedIn || isDomainAuthorized) && (
<>
<ol>
<li>the Google auth provider enabled and</li>
<li>
this domain authorized:{" "}
<b style={{ userSelect: "all" }}>{window.location.hostname}</b>
<IconButton
onClick={() =>
navigator.clipboard.writeText(window.location.hostname)
}
>
<CopyIcon />
</IconButton>
</li>
</ol>
<Stack spacing={1} direction="row">
<Button
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/authentication/providers`}
target="_blank"
rel="noopener noreferrer"
>
Set up in Firebase Console
<InlineOpenInNewIcon />
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setIsDomainAuthorized(true)}
>
Done
</Button>
</Stack>
</>
)}
</SetupItem>
{isDomainAuthorized && (
<SetupItem
status={isSignedIn ? "complete" : "incomplete"}
title={
isSignedIn ? (
`Youre signed in as the project owner.`
) : (
<>
Sign in as the project owner: <b>{email}</b>
</>
)
}
>
{!isSignedIn && (
<SignInWithGoogle
matchEmail={email}
loading={!email ? true : undefined}
/>
)}
</SetupItem>
)}
{isSignedIn && (
<SetupItem
status={hasRoles === true ? "complete" : "incomplete"}
title={
hasRoles === true
? "The project owner has the admin and owner roles."
: "Assign the admin and owner roles to the project owner."
}
>
{hasRoles !== true && (
<div>
<LoadingButton
variant="contained"
color="primary"
loading={hasRoles === "LOADING"}
onClick={setRoles}
>
Assign roles
</LoadingButton>
{typeof hasRoles === "string" && hasRoles !== "LOADING" && (
<Typography
variant="caption"
color="error"
display="block"
sx={{ mt: 0.5 }}
>
{hasRoles}
</Typography>
)}
</div>
)}
</SetupItem>
)}
</>
);
}
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;
}
};

View File

@@ -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 (
<>
<Typography variant="inherit">
It looks like youve previously configured your Firestore database for
Firetable. You can migrate this configuration, including your tables to{" "}
Rowy.
</Typography>
<SetupItem
status={status === true ? "complete" : "incomplete"}
title={
status === true ? (
<>
Configuration migrated to the <code>{CONFIG}</code> collection.
</>
) : (
<>
Migrate your configuration to the <code>{CONFIG}</code>{" "}
collection.
</>
)
}
>
{status !== true && (
<>
<LoadingButton
variant="contained"
color="primary"
loading={status === "LOADING"}
onClick={migrate}
>
Migrate
</LoadingButton>
{status !== "LOADING" && typeof status === "string" && (
<Typography variant="caption" color="error">
{status}
</Typography>
)}
</>
)}
</SetupItem>
</>
);
}
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;
}
};

15
src/components/Setup/types.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
export interface ISetupStep {
id: string;
layout?: "centered";
shortTitle: string;
title: React.ReactNode;
description?: React.ReactNode;
body: React.ComponentType<ISetupStepBodyProps>;
}
export interface ISetupStepBodyProps {
completion: Record<string, boolean>;
setCompletion: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
isComplete: boolean;
setComplete: (value: boolean = true) => void;
}

View File

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

View File

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

View File

@@ -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<Record<string, boolean>>({
welcome: false,
rules: false,
storageRules: false,
});
return (
<SetupLayout
steps={steps}
completion={completion}
setCompletion={setCompletion}
/>
);
}

View File

@@ -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<Record<string, boolean>>({
welcome: false,
rules: false,
storageRules: false,
});
return (
<SetupLayout
steps={steps}
completion={completion}
setCompletion={setCompletion}
/>
);
}

View File

@@ -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: <Step0Welcome handleContinue={handleContinue} {...stepProps} />,
actions: <></>,
body: <StepWelcome {...stepProps} />,
},
// {
// id: "rowyRun",
@@ -182,19 +180,19 @@ export default function SetupPage() {
id: "project",
shortTitle: `Project`,
title: `Select project`,
body: <Step2Project {...stepProps} />,
body: <Step1Oauth {...stepProps} />,
},
{
id: "rules",
shortTitle: `Firestore Rules`,
title: `Set up Firestore Rules`,
body: <Step3Rules {...stepProps} />,
body: <Step1Oauth {...stepProps} />,
},
{
id: "storageRules",
shortTitle: `Storage Rules`,
title: `Set up Firestore Rules`,
body: <Step3Rules {...stepProps} />,
body: <Step1Oauth {...stepProps} />,
},
{
id: "finish",
@@ -214,7 +212,7 @@ export default function SetupPage() {
// </Button>
// ),
},
].filter((x) => x.id);
];
const step =
steps.find((step) => step.id === (stepId || steps[0].id)) ?? steps[0];