diff --git a/.env b/.env index dff793235..d898b21b1 100644 --- a/.env +++ b/.env @@ -12,8 +12,7 @@ JWT_TOKEN_EXPIRES=15 REFRESH_TOKEN_EXPIRES=43200 # Activation token lifetime (7 days in minutes) -ACTIVATION_TOKEN_EXPIRES=10080 +NEXT_PUBLIC_ACTIVATION_TOKEN_EXPIRES=10080 # Email ACCOUNT_MAIL_SENDER=contact@fabrique.social.gouv.fr -NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 diff --git a/.env.development b/.env.development index c42d1c03a..416687dc8 100644 --- a/.env.development +++ b/.env.development @@ -3,6 +3,10 @@ ## ACCOUNT_EMAIL_WEBHOOK_URL=http://host.docker.internal:3000/api/webhooks/account +FRONTEND_URL=http://localhost:3000 +PORT=3000 + + ## ## frontend secrets ## diff --git a/.gitignore b/.gitignore index 0fd385c92..033103d36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .next *.DS_Store -node_modules \ No newline at end of file +node_modules +.env.production diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3461cad58..9e4b59d45 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,6 +17,14 @@ variables: ENABLE_AZURE_POSTGRES: 1 VALUES_FILE: ./.k8s/app.values.yml +Build: + extends: .autodevops_build + variables: + # these variables are needed at build time because embedded in the front + NEXT_PUBLIC_SENTRY_DSN: https://yyy@sentry.fabrique.social.gouv.fr/yyy + NEXT_PUBLIC_MATOMO_URL: https://matomo.io + NEXT_PUBLIC_MATOMO_SITE_ID: 4242 + Create namespace: extends: - .autodevops_create_namespace @@ -53,7 +61,7 @@ Deploy app Hasura (dev): - .autodevops_deploy_app_dev - .deploy_hasura variables: - PG_HOST: cdtnadmin.postgres.database.azure.com + PG_HOST: cdtnadmindevserver.postgres.database.azure.com HELM_RENDER_ARGS: >- --set deployment.env[7].name=HASURA_GRAPHQL_DATABASE_URL --set deployment.env[7].value=postgresql://user_${CI_COMMIT_SHORT_SHA}%40${PG_HOST}:pass_${CI_COMMIT_SHORT_SHA}@${PG_HOST}:5432/db_${CI_COMMIT_SHORT_SHA}?sslmode=require diff --git a/.k8s/app.values.yml b/.k8s/app.values.yml index b5ff6f23c..e7b0710bf 100644 --- a/.k8s/app.values.yml +++ b/.k8s/app.values.yml @@ -31,13 +31,13 @@ deployment: env: - name: PRODUCTION value: "${PRODUCTION}" - - name: NEXT_PUBLIC_FRONTEND_URL + - name: FRONTEND_URL value: https://${HOST} - name: GRAPHQL_ENDPOINT value: "http://hasura-cdtn-admin/v1/graphql" - name: ACCOUNT_MAIL_SENDER value: "contact@fabrique.social.gouv.fr" - - name: NEXT_PUBLIC_FRONTEND_URL + - name: PORT value: "${PORT}" envFrom: - secretRef: diff --git a/Dockerfile b/Dockerfile index 1cfb70655..b90072a88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,9 @@ COPY package.json yarn.lock ./ RUN yarn --production --frozen-lockfile COPY next.config.js ./ +COPY .env ./.env COPY .next/ ./.next +COPY public/ ./public USER node diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 64a257a3b..4f6aedc3c 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -21,7 +21,18 @@ columns: - id - role - filter: {} + filter: + id: + _eq: X-Hasura-User-Id + update_permissions: + - role: user + permission: + columns: + - role + filter: + id: + _eq: X-Hasura-User-Id + check: null - table: schema: auth name: users @@ -44,6 +55,32 @@ table: schema: auth name: user_roles + select_permissions: + - role: user + permission: + columns: + - active + - created_at + - default_role + - email + - id + - name + - secret_token + - secret_token_expires_at + - updated_at + filter: + id: + _eq: X-Hasura-User-Id + update_permissions: + - role: user + permission: + columns: + - email + - name + filter: + id: + _eq: X-Hasura-User-Id + check: null event_triggers: - name: account_email definition: @@ -85,31 +122,3 @@ columns: - role filter: {} -- table: - schema: public - name: users - array_relationships: - - name: roles - using: - manual_configuration: - remote_table: - schema: auth - name: user_roles - column_mapping: - id: user_id - select_permissions: - - role: user - permission: - columns: - - active - - created_at - - default_role - - email - - id - - name - - secret_token - - secret_token_expires_at - - updated_at - filter: - id: - _eq: X-Hasura-User-Id diff --git a/hasura/migrations/1588758007277_init/up.sql b/hasura/migrations/1588758007277_init/up.sql index 671081b4d..9b6fe153f 100644 --- a/hasura/migrations/1588758007277_init/up.sql +++ b/hasura/migrations/1588758007277_init/up.sql @@ -44,7 +44,7 @@ create schema auth; create table auth.users( id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, email email UNIQUE NOT NULL, - password text NOT NULL CONSTRAINT password_min_length CHECK ( char_length(password) >= 8 ), + password text DEFAULT 'mot de passe'::text NOT NULL CONSTRAINT password_min_length CHECK ( char_length(password) >= 8 ), name text NOT NULL, active boolean DEFAULT false NOT NULL, default_role text DEFAULT 'user'::text NOT NULL REFERENCES public.roles (role) on update cascade on delete restrict, @@ -80,7 +80,7 @@ COMMENT ON TABLE auth.user_roles IS 'User_role table allow many-to-many relationship between users and roles'; WITH admin_row AS ( - INSERT INTO auth.users(email, password, name, default_role, active) VALUES ('sre@fabrique.social.gouv.fr', '$argon2i$v=19$m=4096,t=3,p=1$n9eoWSv+5sCgc7SjB5hLig$iBQ7NzrHHLkJSku/dCetNs+n/JI1CMdkWaoZsUekLU8', 'big boss', 'admin', true) + INSERT INTO auth.users(email, password, name, default_role, active) VALUES ('codedutravailnumerique@travail.gouv.fr', '$argon2i$v=19$m=4096,t=3,p=1$n9eoWSv+5sCgc7SjB5hLig$iBQ7NzrHHLkJSku/dCetNs+n/JI1CMdkWaoZsUekLU8', 'big boss', 'admin', true) RETURNING id, default_role ) INSERT INTO auth.user_roles(role, user_id) SELECT default_role, id FROM admin_row; @@ -110,9 +110,3 @@ CREATE TRIGGER "set_auth_refresh_tokens_updated_at" COMMENT ON TRIGGER "set_auth_refresh_tokens_updated_at" ON auth.refresh_tokens IS 'trigger to set value of column "updated_at" to current timestamp on row update'; - - --- --- Public user view --- -CREATE VIEW users AS SELECT id, name, email, active, default_role, secret_token, secret_token_expires_at, created_at, updated_at from auth.users; diff --git a/package.json b/package.json index 384c79364..f9c902a68 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dependencies": { "@hapi/boom": "^9.1.0", "@hapi/joi": "^17.1.1", - "@reach/menu-button": "^0.10.2", + "@reach/dialog": "^0.10.3", + "@reach/menu-button": "^0.10.3", + "@reach/visually-hidden": "^0.10.2", "@sentry/browser": "^5.15.5", "@sentry/integrations": "^5.15.5", "@sentry/node": "^5.15.5", @@ -20,11 +22,12 @@ "http-proxy-middleware": "^1.0.3", "jsonwebtoken": "^8.5.1", "next": "^9.4.0", - "next-urql": "^0.3.7", + "next-urql": "^0.3.8", "nodemailer": "^6.4.6", "polished": "^3.6.3", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-hook-form": "^5.7.2", "react-icons": "^3.10.0", "react-is": "^16.13.1", "sentry-testkit": "^3.2.1", @@ -45,9 +48,9 @@ ] }, "scripts": { - "dev": "next dev -p ${NEXT_PUBLIC_FRONTEND_PORT:=3000}", + "dev": "next dev", "build": "next build", - "start": "next start -p ${NEXT_PUBLIC_FRONTEND_PORT:=3000}", + "start": "next start", "lint": "eslint src/*", "test": "jest" }, @@ -56,6 +59,7 @@ "@commitlint/config-conventional": "^8.3.4", "@socialgouv/eslint-config-react": "^0.21.0", "@socialgouv/eslint-config-recommended": "^0.21.0", + "@urql/devtools": "^2.0.2", "eslint": "^7.0.0", "eslint-plugin-import": "^2.20.2", "husky": "^4.2.5", diff --git a/src/components/CustomUrqlClient.js b/src/components/CustomUrqlClient.js deleted file mode 100644 index b212075d1..000000000 --- a/src/components/CustomUrqlClient.js +++ /dev/null @@ -1,23 +0,0 @@ -import { dedupExchange, cacheExchange, fetchExchange } from "@urql/core"; -import { withUrqlClient } from "next-urql"; -import { refreshToken } from "src/lib/auth"; -import { authExchange } from "src/lib/authTokenExchange"; -export const withCustomUrqlClient = (Component) => - withUrqlClient( - (ctx) => { - return { - url: `${process.env.NEXT_PUBLIC_FRONTEND_URL}/api/graphql`, - fetchOptions: { - refreshToken: () => refreshToken(ctx), - }, - }; - }, - (ssrExchange) => [ - dedupExchange, - cacheExchange, - ssrExchange, - authExchange, - // tapExchange((op) => console.log("tap", op.operationName)), - fetchExchange, - ] - )(Component); diff --git a/src/components/Roles.js b/src/components/Roles.js index 0af4ecbe4..5fede816c 100644 --- a/src/components/Roles.js +++ b/src/components/Roles.js @@ -2,15 +2,16 @@ import React from "react"; import { useQuery } from "urql"; import { Alert, Badge } from "theme-ui"; -const query = ` +export const getRoleQuery = ` query getRoles{ roles { role } } `; + export function Roles() { - const [results] = useQuery({ query }); + const [results] = useQuery({ getRoleQuery }); const { data, error, fetching } = results; if (fetching) return

loading

; diff --git a/src/components/UserList.js b/src/components/UserList.js deleted file mode 100644 index abce2f222..000000000 --- a/src/components/UserList.js +++ /dev/null @@ -1,116 +0,0 @@ -/** @jsx jsx */ -import { useQuery } from "urql"; -import { css, jsx, Badge } from "theme-ui"; -import { IoIosCheckmark, IoMdCloseCircle, IoMdMore } from "react-icons/io"; -import PropTypes from "prop-types"; -import { IconButton } from "./button"; - -const query = ` -query getUser { - users { - id - email - name - active - created_at - default_role - roles { - role - } - } -} -`; - -export function UserList() { - const [result] = useQuery({ - query, - }); - const { data, fetching, error } = result; - if (fetching) return

chargement...

; - if (error) - return ( -
-
{JSON.stringify(error, 0, 2)}
-
- ); - - return ( - - - - - - - - - - - - - {data.users.map( - ({ id, default_role, name, email, created_at, active }) => ( - - - - - - - - - ) - )} - -
RôleNom d’utilisateurEmailDate de créationActivéActions
- - {default_role} - - {name}{email}{new Date(created_at).toLocaleDateString("fr-FR")} - {active ? : } - - - - -
- ); -} -const Tr = (props) => ; - -const cellPropTypes = { - align: PropTypes.oneOf(["left", "right", "center"]), -}; -const Th = ({ align = "left", ...props }) => ( - -); -Th.propTypes = cellPropTypes; -const Td = ({ align = "left", ...props }) => ( - -); -Td.propTypes = cellPropTypes; - -const styles = { - table: css({ - borderCollapse: "collapse", - borderRadius: "small", - overflow: "hidden", - width: "100%", - }), - th: css({ - px: "xsmall", - py: "xsmall", - borderBottom: "1px solid", - // bg: "info", - // color: "white", - fontWeight: "semibold", - fontSize: "medium", - }), - td: css({ - px: "xsmall", - py: "xxsmall", - fontWeight: 300, - "tr:nth-of-type(even) &": { - bg: "highlight", - }, - }), -}; diff --git a/src/components/button/index.js b/src/components/button/index.js index 321b6f6df..e9e54e8c8 100644 --- a/src/components/button/index.js +++ b/src/components/button/index.js @@ -1,23 +1,38 @@ /** @jsx jsx */ import React from "react"; -import { jsx, Button as BaseButton } from "theme-ui"; +import { + jsx, + Button as BaseButton, + IconButton as BaseIconButton, +} from "theme-ui"; import PropTypes from "prop-types"; +import { + Menu, + MenuButton as ReachMenuButton, + MenuList, + MenuItem as ReachMenuItem, +} from "@reach/menu-button"; +import { IoMdMore } from "react-icons/io"; + const buttonPropTypes = { - variant: PropTypes.oneOf(["secondary", "primary"]), + variant: PropTypes.oneOf(["secondary", "primary", "link"]), size: PropTypes.oneOf(["small", "normal"]), }; const defaultButtonStyles = { cursor: "pointer", appearance: "none", - display: "inline-block", + display: "inline-flex", + alignItems: "center", textAlign: "center", lineHeight: "inherit", textDecoration: "none", fontSize: "inherit", fontWeight: "bold", + minWidth: 0, m: 0, + p: 1, borderRadius: "small", borderWidth: 2, borderStyle: "solid", @@ -35,11 +50,7 @@ const smallSize = { export const Button = React.forwardRef(({ outline, ...props }, ref) => (
- {outline ? ( - - ) : ( - - )} + {outline ? : }
)); Button.propTypes = { @@ -52,8 +63,8 @@ function SolidButton({ variant = "primary", size = "normal", ...props }) { theme.buttons[variant].color, bg: (theme) => theme.buttons[variant].color, color: (theme) => theme.buttons[variant].text, @@ -98,16 +109,15 @@ OutlineButton.propTypes = buttonPropTypes; export function IconButton({ variant = "primary", size = "large", ...props }) { return ( - theme.buttons[variant].color, "&:hover:not([disabled])": { color: (theme) => theme.buttons[variant].text, @@ -122,3 +132,59 @@ export function IconButton({ variant = "primary", size = "large", ...props }) { ); } IconButton.propTypes = buttonPropTypes; + +export function MenuButton({ variant = "primary", size = "large", children }) { + return ( + + theme.buttons[variant].color, + "&:hover:not([disabled])": { + color: (theme) => theme.buttons[variant].text, + bg: (theme) => theme.buttons.icon.bgHover, + }, + "&[disabled]": { + color: "text", + bg: "neutral", + }, + }} + > +
+ +
+
+ + {children} + +
+ ); +} + +MenuButton.propTypes = { + ...buttonPropTypes, +}; + +export function MenuItem(props) { + return ( + + ); +} diff --git a/src/components/dialog/index.js b/src/components/dialog/index.js new file mode 100644 index 000000000..c74ed50a1 --- /dev/null +++ b/src/components/dialog/index.js @@ -0,0 +1,38 @@ +/** @jsx jsx */ + +import { Dialog as ReachDialog } from "@reach/dialog"; +import VisuallyHidden from "@reach/visually-hidden"; +import PropTypes from "prop-types"; +import { IoMdClose } from "react-icons/io"; +import { css, jsx } from "theme-ui"; +import { IconButton } from "../button"; +import { Stack } from "../layout/Stack"; + +export function Dialog({ isOpen = false, onDismiss, children }) { + return ( + + + Close + + + {children} + + ); +} + +const styles = { + dialog: css({ + position: "relative", + }), + closeBt: css({ + position: "absolute", + right: "xxsmall", + top: "xxsmall", + }), +}; + +Dialog.propTypes = { + isOpen: PropTypes.bool, + onDismiss: PropTypes.func.isRequired, + children: PropTypes.nodes, +}; diff --git a/src/components/forms/ErrorMessage.js b/src/components/forms/ErrorMessage.js new file mode 100644 index 000000000..d9ed4563e --- /dev/null +++ b/src/components/forms/ErrorMessage.js @@ -0,0 +1,18 @@ +/** @jsx jsx */ + +import { jsx } from "theme-ui"; +import PropTypes from "prop-types"; +import { ErrorMessage } from "react-hook-form"; + +export function FormErrorMessage({ errors, fieldName }) { + return ( + + {({ message }) =>
{message}
} +
+ ); +} + +FormErrorMessage.propTypes = { + fieldName: PropTypes.string, + errors: PropTypes.object, +}; diff --git a/src/components/layout/Container.js b/src/components/layout/Container.js index 55571be20..53b8a8715 100644 --- a/src/components/layout/Container.js +++ b/src/components/layout/Container.js @@ -1,7 +1,7 @@ /** @jsx jsx */ import { jsx } from "theme-ui"; -export function Container({ props }) { +export function Container(props) { return (
+ {React.Children.map(children, (child) => ( + + {child} + + ))} + + ); +} +Inline.propTypes = { + space: spacePropTypes, + component: PropTypes.oneOf(["div", "ul", "ol"]), + children: PropTypes.node, +}; diff --git a/src/components/layout/Stack.js b/src/components/layout/Stack.js index aa6066962..ed5f42e19 100644 --- a/src/components/layout/Stack.js +++ b/src/components/layout/Stack.js @@ -1,5 +1,7 @@ /** @jsx jsx */ + import { jsx } from "theme-ui"; +import { spacePropTypes } from "./spaces"; export function Stack({ gap = "medium", ...props }) { return ( @@ -12,3 +14,6 @@ export function Stack({ gap = "medium", ...props }) { /> ); } +Stack.propTypes = { + gap: spacePropTypes, +}; diff --git a/src/components/layout/auth.layout.js b/src/components/layout/auth.layout.js index 1501f95dc..bb29b264e 100644 --- a/src/components/layout/auth.layout.js +++ b/src/components/layout/auth.layout.js @@ -1,21 +1,31 @@ /** @jsx jsx */ +import PropTypes from "prop-types"; import { IconContext } from "react-icons"; -import { Box, Flex, jsx } from "theme-ui"; +import { Box, Flex, jsx, Card, Heading } from "theme-ui"; import { Header } from "./header"; import { Nav } from "./Nav"; +import Head from "next/head"; -export function Layout({ children }) { +export function Layout({ children, title }) { return ( + + {title} | Admin cdtn +
)} diff --git a/src/components/layout/password.layout.js b/src/components/layout/password.layout.js new file mode 100644 index 000000000..26bc525e1 --- /dev/null +++ b/src/components/layout/password.layout.js @@ -0,0 +1,34 @@ +/** @jsx jsx */ +import PropTypes from "prop-types"; +import { Box, Flex, jsx, Card, Heading } from "theme-ui"; +import { Header } from "./header"; +import Head from "next/head"; +import { Stack } from "./Stack"; + +export function PasswordLayout({ children, title }) { + return ( + <> + + {title} | Admin cdtn + + +
+ + + {title} + {children} + + + + + ); +} +PasswordLayout.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node, +}; diff --git a/src/components/layout/spaces.js b/src/components/layout/spaces.js new file mode 100644 index 000000000..004232bd1 --- /dev/null +++ b/src/components/layout/spaces.js @@ -0,0 +1,33 @@ +import PropTypes from "prop-types"; +import { theme } from "src/theme"; +import { get } from "theme-ui"; +const spaces = Object.keys(theme.space); + +export const spacePropTypes = PropTypes.oneOfType([ + PropTypes.oneOf(spaces), + PropTypes.arrayOf(PropTypes.oneOf(spaces)), +]); + +export function invertSpace(scaleOrValue) { + if (Array.isArray(scaleOrValue)) { + return invertScale(scaleOrValue); + } + return invertValue(scaleOrValue); +} + +function invertScale(scale) { + return scale.map(invertValue); +} + +function invertValue(value) { + const themeValue = get(theme, `space.${value}`); + if (Object.prototype.toString.call(themeValue) === "[object String]") { + return `calc(${themeValue} * -1)`; + } + if (Object.prototype.toString.call(themeValue) === "[object Number]") { + return themeValue * -1; + } + throw new Error( + "invertValue unsupported type " + Object.prototype.toString.call(themeValue) + ); +} diff --git a/src/components/list/index.js b/src/components/list/index.js index 472f428e7..c9939c6f3 100644 --- a/src/components/list/index.js +++ b/src/components/list/index.js @@ -1,10 +1,17 @@ /** @jsx jsx */ import { jsx } from "theme-ui"; +import PropTypes from "prop-types"; export function List({ children }) { return
    {children}
; } +List.propTypes = { + children: PropTypes.node, +}; export function Li({ children }) { return
  • {children}
  • ; } +Li.propTypes = { + children: PropTypes.node, +}; diff --git a/src/components/login/index.js b/src/components/login/index.js index 53eb08cc2..800546f68 100644 --- a/src/components/login/index.js +++ b/src/components/login/index.js @@ -64,28 +64,14 @@ const LoginForm = ({ authenticate, resetPassword, onSuccess }) => { Se connecter -
    - -
    diff --git a/src/components/user/List.js b/src/components/user/List.js new file mode 100644 index 000000000..d034726d5 --- /dev/null +++ b/src/components/user/List.js @@ -0,0 +1,166 @@ +/** @jsx jsx */ +import { useQuery, useMutation } from "urql"; +import { css, jsx, Badge, Text } from "theme-ui"; +import { IoIosCheckmark, IoMdCloseCircle } from "react-icons/io"; +import PropTypes from "prop-types"; +import { MenuButton, MenuItem, Button } from "../button"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { Dialog } from "../dialog"; +import { Inline } from "../layout/Inline"; + +const query = ` +query getUsers { + users: auth_users { + __typename + id + email + name + active + created_at + default_role + roles: user_roles { + role + } + } +} +`; + +const deleteUserMutation = ` +mutation deleteUser($id: uuid!) { + delete_auth_users_by_pk(id: $id) { + __typename + } +} +`; + +export function UserList() { + const router = useRouter(); + const [showDialog, setShowDialog] = useState(false); + const [selectedUser, setSelectedUser] = useState(); + const open = () => setShowDialog(true); + const close = () => setShowDialog(false); + + const [result] = useQuery({ + query, + }); + const { data, fetching, error } = result; + const [, executeDelete] = useMutation(deleteUserMutation); + + function confirmDeleteUser(id, email) { + setSelectedUser({ id, email }); + open(); + } + + function onDeleteUser() { + executeDelete({ id: selectedUser.id }); + close(); + } + + if (fetching) return

    chargement...

    ; + if (error) + return ( +
    +
    {JSON.stringify(error, 0, 2)}
    +
    + ); + + return ( + <> + + Etes vous sur de vouloir supprimer l’utilisateur + {selectedUser?.email} + + + + + + + + + + + + + + + + + + {data.users.map( + ({ id, roles: [{ role }], name, email, created_at, active }) => ( + + + + + + + + + ) + )} + +
    RôleNom d’utilisateurEmailDate de créationActivéActions
    + + {role} + + {name}{email}{new Date(created_at).toLocaleDateString("fr-FR")} + {active ? : } + + + + router.push("/user/edit/[id]", `/user/edit/${id}`) + } + > + Modifier + + confirmDeleteUser(id, email)}> + Supprimer + + +
    + + ); +} +const Tr = (props) => ; + +const cellPropTypes = { + align: PropTypes.oneOf(["left", "right", "center"]), +}; +const Th = ({ align = "left", ...props }) => ( + +); +Th.propTypes = cellPropTypes; +const Td = ({ align = "left", ...props }) => ( + +); +Td.propTypes = cellPropTypes; + +const styles = { + table: css({ + borderCollapse: "collapse", + borderRadius: "small", + overflow: "hidden", + width: "100%", + }), + th: css({ + px: "xsmall", + py: "xsmall", + borderBottom: "1px solid", + // bg: "info", + // color: "white", + fontWeight: "semibold", + fontSize: "medium", + }), + td: css({ + px: "xsmall", + py: "xxsmall", + fontWeight: 300, + "tr:nth-of-type(even) &": { + bg: "highlight", + }, + }), +}; diff --git a/src/components/user/PasswordForm.js b/src/components/user/PasswordForm.js new file mode 100644 index 000000000..fa8a85f4a --- /dev/null +++ b/src/components/user/PasswordForm.js @@ -0,0 +1,104 @@ +/** @jsx jsx */ +import PropTypes from "prop-types"; +import { useForm } from "react-hook-form"; +import { Button } from "src/components/button"; +import { Field, jsx, NavLink } from "theme-ui"; +import { FormErrorMessage } from "../forms/ErrorMessage"; +import { Stack } from "../layout/Stack"; + +import Link from "next/link"; +import { Inline } from "../layout/Inline"; +import { useAuth } from "src/hooks/useAuth"; + +export function PasswordForm({ + onSubmit, + action = "/api/change_password", + lostPassword = false, + backHref = "/account", +}) { + let loading; + const { user } = useAuth(); + const { register, handleSubmit, errors, setError, watch } = useForm(); + const hasError = Object.keys(errors).length > 0; + const buttonLabel = "Changer le mot de passe"; + const passwordFieldRegistration = { + required: { value: true, message: "Ce champ est requis" }, + minLength: { + value: 8, + message: "Le mot de passe doit faire au moins 8 caractères", + }, + }; + async function localSubmit(data) { + loading = true; + try { + await onSubmit(data); + } catch (err) { + console.error("[ PasswordForm ]", err); + setError( + "oldPassword", + "validate", + "L'ancien mot de passe ne correspond pas." + ); + } + loading = false; + } + + return ( +
    + + {!lostPassword && ( +
    + + +
    + )} + +
    + + +
    + +
    + + value === watch("password") || + "Les mots de passe ne correspondent pas.", + })} + /> + +
    + + + + Annuler + + +
    + +
    + ); +} + +PasswordForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + action: PropTypes.string, + lostPassword: PropTypes.bool, + backHref: PropTypes.string, +}; diff --git a/src/components/user/UserForm.js b/src/components/user/UserForm.js new file mode 100644 index 000000000..4f0e765fe --- /dev/null +++ b/src/components/user/UserForm.js @@ -0,0 +1,93 @@ +/** @jsx jsx */ +import PropTypes from "prop-types"; +import { useForm } from "react-hook-form"; +import { Button } from "src/components/button"; +import { Field, jsx, Label, Select, NavLink } from "theme-ui"; +import { FormErrorMessage } from "../forms/ErrorMessage"; +import { Stack } from "../layout/Stack"; +import { useQuery } from "urql"; +import { getRoleQuery } from "../Roles"; +import Link from "next/link"; +import { Inline } from "../layout/Inline"; + +export function UserForm({ + onSubmit, + loading = false, + user, + isAdmin = false, + backHref = "/users", +}) { + const [results] = useQuery({ query: getRoleQuery }); + const { data, fetching, error } = results; + const { register, handleSubmit, errors } = useForm(); + const hasError = Object.keys(errors).length > 0; + let buttonLabel = "Créer le compte"; + if (user) { + buttonLabel = "Modifier les informations"; + } + return ( +
    + +
    + + +
    +
    + + +
    + {isAdmin && ( +
    + + +
    + )} + + + + Annuler + + +
    +
    + ); +} + +UserForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + loading: PropTypes.bool, + user: PropTypes.object, + isAdmin: PropTypes.bool, + backHref: PropTypes.string, +}; diff --git a/src/hoc/CustomUrqlClient.js b/src/hoc/CustomUrqlClient.js new file mode 100644 index 000000000..c400de557 --- /dev/null +++ b/src/hoc/CustomUrqlClient.js @@ -0,0 +1,34 @@ +import { cacheExchange, dedupExchange, fetchExchange } from "@urql/core"; +import { withUrqlClient } from "next-urql"; +import { refreshToken } from "src/lib/auth"; +import { authExchange } from "src/lib/authTokenExchange"; + +export const withCustomUrqlClient = (Component) => + withUrqlClient( + (ctx) => { + const url = ctx?.req + ? `${process.env.FRONTEND_URL}/api/graphql` + : `/api/graphql`; + console.log("[ withUrqlClient ]", ctx?.req ? "server" : "client", { + url, + }); + return { + url, + fetchOptions: { + refreshToken: () => refreshToken(ctx), + }, + }; + }, + (ssrExchange) => + [ + process.env.NODE_ENV !== "production" + ? require("@urql/devtools").devtoolsExchange + : [], + dedupExchange, + cacheExchange, + ssrExchange, + authExchange, + // tapExchange((op) => console.log("tap", op.operationName)), + fetchExchange, + ].flatMap((a) => a) + )(Component); diff --git a/src/components/hoc.js b/src/hoc/withTheme.js similarity index 100% rename from src/components/hoc.js rename to src/hoc/withTheme.js diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 000000000..7d5884771 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,15 @@ +import { useRef, useEffect } from "react"; + +export function usePrevious(value) { + // The ref object is a generic container whose current property is mutable ... + // ... and can hold any value, similar to an instance property on a class + const ref = useRef(); + + // Store current value in ref + useEffect(() => { + ref.current = value; + }, [value]); // Only re-run if value changes + + // Return previous value (happens before update in useEffect above) + return ref.current; +} diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js index 7215178ba..522490f5b 100644 --- a/src/hooks/useAuth.js +++ b/src/hooks/useAuth.js @@ -1,43 +1,43 @@ -import React, { createContext, useState, useEffect, useContext } from "react"; import PropTypes from "prop-types"; -import { getToken, setToken, refreshToken, getRawtoken } from "src/lib/auth"; -import { useQuery } from "urql"; +import React, { createContext, useContext, useEffect, useState } from "react"; +import { getUserId } from "src/lib/auth"; import { request } from "src/lib/request"; -import { getDisplayName } from "next/dist/next-server/lib/utils"; +import { useQuery } from "urql"; export const AuthContext = createContext({ user: null, setUser: () => {}, }); -const getUserQuery = ` -query getUser { - user: users { +export const getUserQuery = ` +query getUser($id: uuid!) { + user:auth_users_by_pk(id: $id) { + __typename id email name active default_role - roles { + roles: user_roles { role } } } `; -export function AuthProvider({ token, children }) { - // hydrate jwt-token from server - if (token && !getToken()) { - setToken(token); - } - console.log("[AuthProvider] token", token ? "✅" : "❌"); +export function AuthProvider({ children }) { const [user, setUser] = useState(null); - const [result] = useQuery({ query: getUserQuery }); + const id = getUserId(); + const [result] = useQuery({ + query: getUserQuery, + variables: { id }, + }); + useEffect(() => { - if (result.data) { - setUser(result.data.user[0]); + if (result.data?.user) { + setUser(result.data?.user); } - }, [result.data, token]); + }, [result.data?.user]); return ( @@ -50,61 +50,25 @@ AuthProvider.propTypes = { }; export function useAuth() { - console.log("[useAuth]"); - const { user, setUser } = useContext(AuthContext); + const { user } = useContext(AuthContext); async function logout() { - setToken(null); try { await request("/api/logout", { credentials: "include", mode: "same-origin", }); - setUser(null); - window.location.reload(); } catch (error) { console.error("[ client logout ] failed", error); } + window.location = "/login"; } const isAuth = Boolean(user); + const isAdmin = user?.roles.some((item) => item.role === "admin"); return { user, + isAdmin, isAuth, logout, }; } - -export function withAuthProvider(WrappedComponent) { - return class extends React.Component { - static displayName = `withAuthSync(${getDisplayName(WrappedComponent)})`; - static async getInitialProps(ctx) { - console.log( - "[withAuthProvider] getInitialProps", - ctx.req ? "server" : "client", - getToken() ? "found token" : "no token" - ); - - const jwt_token = getToken(); - if (!jwt_token) { - try { - await refreshToken(ctx); - } catch { - console.error("[withAuthProvider] no token"); - } - } - - const componentProps = - WrappedComponent.getInitialProps && - (await WrappedComponent.getInitialProps(ctx)); - const rawToken = getRawtoken(); - return { ...componentProps, rawToken }; - } - render() { - return ( - - - - ); - } - }; -} diff --git a/src/lib/auth.js b/src/lib/auth.js index 32295c2ed..588ca6952 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -1,6 +1,9 @@ +import { parse, serialize } from "cookie"; +import { getDisplayName } from "next/dist/next-server/lib/utils"; import Router from "next/router"; +import React from "react"; +import { AuthProvider } from "../hooks/useAuth"; import { request } from "./request"; -import { parse, serialize } from "cookie"; import { setRefreshTokenCookie } from "./setRefreshTokenCookie"; let token = null; @@ -8,16 +11,15 @@ let token = null; function getToken() { return token ? token.jwt_token : null; } -function getRawtoken() { - return token ? { ...token } : token; -} + function getUserId() { return token ? token.user_id : null; } function isTokenExpired() { - if (!token) return true; - return Date.now() > new Date(token.jwt_token_expiry); + const expired = !token || Date.now() > new Date(token.jwt_token_expiry); + console.log("[ isTokenExpired ]", { expired }); + return expired; } async function refreshToken(ctx) { @@ -26,30 +28,36 @@ async function refreshToken(ctx) { "Cache-Control": "no-cache", }; if (ctx && ctx.req) { - const cookies = parse(ctx.req.headers.cookie); + const cookies = parse(ctx.req.headers.cookie || ""); if (cookies && cookies.refresh_token) { + console.log("[ auth.refreshToken ] add cookie", cookies.refresh_token); headers["Cookie"] = serialize("refresh_token", cookies.refresh_token); } } const tokenData = await request( - `${process.env.NEXT_PUBLIC_FRONTEND_URL}/api/refresh_token`, + ctx && ctx.req + ? `${process.env.FRONTEND_URL}/api/refresh_token` + : "/api/refresh_token", { credentials: "include", mode: "same-origin", headers, - body: { refresh_token: token?.refresh_token }, + body: {}, } ); // for ServerSide call, we need to set the Cookie header // to update the refresh_token value if (ctx && ctx.res) { + console.log( + "[ auth.refreshToken ] setting cookie", + tokenData.refresh_token + ); setRefreshTokenCookie(ctx.res, tokenData.refresh_token); } - - setToken(tokenData); + return tokenData; } catch (error) { - console.error("[ auth.refreshToken error]", { error }); + console.error("[ auth.refreshToken error ]", { error }); if (ctx && ctx.res) { ctx.res.writeHead(302, { Location: "/login" }); ctx.res.end(); @@ -59,19 +67,59 @@ async function refreshToken(ctx) { return; } } - - return getToken(); } function setToken(tokenData) { - token = { ...tokenData }; + token = tokenData; +} + +function withAuthProvider(WrappedComponent) { + return class extends React.Component { + static displayName = `withAuthProvider(${getDisplayName( + WrappedComponent + )})`; + static async getInitialProps(ctx) { + console.log( + "[withAuthProvider] getInitialProps ", + ctx.pathname, + ctx.req ? "server" : "client", + token ? "found token" : "no token" + ); + + // eachtime we render a page on the server + // we need to set token to null to be sure + // that will not re-use an old token since + // token is a global var + // Once urlq exchange will have access to context + // we could use context to pass token to urlqclient + if (ctx?.req) { + token = null; + } + if (!token) { + token = await refreshToken(ctx); + } + + const componentProps = + WrappedComponent.getInitialProps && + (await WrappedComponent.getInitialProps(ctx)); + + return { ...componentProps }; + } + render() { + return ( + + + + ); + } + }; } export { - getRawtoken, getToken, getUserId, isTokenExpired, refreshToken, setToken, + withAuthProvider, }; diff --git a/src/lib/authTokenExchange.js b/src/lib/authTokenExchange.js index 02255d479..eba6b5258 100644 --- a/src/lib/authTokenExchange.js +++ b/src/lib/authTokenExchange.js @@ -9,7 +9,7 @@ import { share, takeUntil, } from "wonka"; -import { getToken, isTokenExpired } from "./auth"; +import { getToken, isTokenExpired, setToken } from "./auth"; // come from https://gist.github.com/kitten/6050e4f447cb29724546dd2e0e68b470#file-authexchangewithteardown-js @@ -18,7 +18,6 @@ const addTokenToOperation = (operation, token) => { typeof operation.context.fetchOptions === "function" ? operation.context.fetchOptions() : operation.context.fetchOptions || {}; - return { ...operation, context: { @@ -66,17 +65,20 @@ export const authExchange = ({ forward }) => { mergeMap((operation) => { const refreshTokenFn = operation.context.fetchOptions.refreshToken; // check whether the token is expired + console.log("[ authExchange ]", operation.operationName); const isExpired = isTokenExpired(); - console.log(["authExchange"], getToken() ? " ok !!" : "nope :("); // If it's not expired then just add it to the operation immediately if (!isExpired) { return fromValue(addTokenToOperation(operation, getToken())); } - console.log("[authExchange] no valid token"); // If it's expired and we aren't refreshing it yet, start refreshing it if (isExpired && !refreshTokenPromise) { - refreshTokenPromise = refreshTokenFn(); // we share the promise + console.log("[ authExchange ] no pending refresh"); + refreshTokenPromise = refreshTokenFn().then((data) => { + setToken(data); + return data.jwt_token; + }); } const { key } = operation; diff --git a/src/lib/jwt.js b/src/lib/jwt.js index cd7e867d1..df46b2c99 100644 --- a/src/lib/jwt.js +++ b/src/lib/jwt.js @@ -1,4 +1,5 @@ import jwt, { verify } from "jsonwebtoken"; + const { HASURA_GRAPHQL_JWT_SECRET, JWT_TOKEN_EXPIRES = 15 } = process.env; const jwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET); diff --git a/src/lib/me.js b/src/lib/me.js index abdfd4801..8d7f7282d 100644 --- a/src/lib/me.js +++ b/src/lib/me.js @@ -2,7 +2,7 @@ import { createClient } from "urql"; export function getConnectedUser(jwt_token) { createClient({ - url: `${process.env.NEXT_PUBLIC_FRONTEND_URL}/graphql`, + url: `${process.env.FRONTEND_URL}/graphql`, requestPolicy: "cache-and-network", fetchOptions: { headers: { diff --git a/src/pages/_app.js b/src/pages/_app.js index bee03d7e0..304d568ac 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -4,25 +4,29 @@ import * as Sentry from "@sentry/node"; import { init } from "@socialgouv/matomo-next"; import { theme } from "src/theme"; import { ThemeProvider } from "theme-ui"; +import "@reach/menu-button/styles.css"; +import "@reach/dialog/styles.css"; Sentry.init({ enabled: process.env.NODE_ENV === "production", - dsn: process.env.SENTRY_DSN, + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, }); class MyApp extends App { componentDidMount() { - init({ url: process.env.MATOMO_URL, siteId: process.env.MATOMO_SITE_ID }); + init({ + url: process.env.NEXT_PUBLIC_MATOMO_URL, + siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID, + }); // force onload swapping on stylesheet since it's not work on nextjs // @see _document.js - const fontCss = window.document.getElementById("fonts"); + const fontCss = document.getElementById("fonts"); if (fontCss) { fontCss.media = "all"; } } render() { - console.log("_app render"); const { Component, pageProps } = this.props; // Workaround for https://github.com/zeit/next.js/issues/8592 diff --git a/src/pages/_document.js b/src/pages/_document.js index 7b30665d9..eef10c837 100644 --- a/src/pages/_document.js +++ b/src/pages/_document.js @@ -23,13 +23,7 @@ class MyDocument extends Document { - +
    diff --git a/src/pages/_error.js b/src/pages/_error.js index 5edaa5735..3fc8a65dc 100644 --- a/src/pages/_error.js +++ b/src/pages/_error.js @@ -1,4 +1,5 @@ import React from "react"; +import PropTypes from "prop-types"; import Error from "next/error"; import * as Sentry from "@sentry/node"; @@ -13,6 +14,12 @@ const MyError = ({ hasGetInitialPropsRun, statusCode, err }) => { return ; }; +MyError.propTypes = { + err: PropTypes.object, + hasGetInitialPropsRun: PropTypes.bool, + statusCode: PropTypes.number.isRequired, +}; + MyError.getInitialProps = async ({ res, err, asPath }) => { const errorInitialProps = await Error.getInitialProps({ res, err }); // Workaround for https://github.com/zeit/next.js/issues/8592, mark when diff --git a/src/pages/api/activate_account.js b/src/pages/api/activate_account.js index ffcf5eb56..81a488e8a 100644 --- a/src/pages/api/activate_account.js +++ b/src/pages/api/activate_account.js @@ -3,7 +3,7 @@ import Joi from "@hapi/joi"; import { hash } from "argon2"; import { createErrorFor } from "src/lib/apiError"; import { client } from "src/lib/graphqlApiClient"; -import { activateUserMutation } from "./activate.gql"; +import { activateUserMutation } from "./password.gql"; export function createRequestHandler({ mutation, @@ -20,7 +20,7 @@ export function createRequestHandler({ const schema = Joi.object({ token: Joi.string().guid({ version: "uuidv4" }).required(), - password: Joi.string().required().min(8), + password: Joi.string().required(), }); const { error, value } = schema.validate(req.body); @@ -39,7 +39,11 @@ export function createRequestHandler({ .toPromise(); } catch (error) { console.error(error); - return apiError(Boom.unauthorized("Invalid secret_token")); + return apiError(Boom.serverUnavailable("update failed")); + } + if (result.error) { + console.error(error); + return apiError(Boom.unauthorized("request failed")); } if (result.data["update_user"].affected_rows === 0) { diff --git a/src/pages/api/change_password.js b/src/pages/api/change_password.js index 0e5ecae87..ca28ad368 100644 --- a/src/pages/api/change_password.js +++ b/src/pages/api/change_password.js @@ -1,8 +1,75 @@ -import { changePasswordMutation } from "./activate.gql"; -import { createRequestHandler } from "./activate_account"; - -export default createRequestHandler({ - mutation: changePasswordMutation, - error_message: "The secret token has expired or there is no account.", - success_message: "password changed !", -}); +import Boom from "@hapi/boom"; +import Joi from "@hapi/joi"; +import { hash, verify } from "argon2"; +import { createErrorFor } from "src/lib/apiError"; +import { client } from "src/lib/graphqlApiClient"; +import { changeMyPasswordMutation, getOldPassword } from "./password.gql"; + +export default async function changePassword(req, res) { + const apiError = createErrorFor(res); + + if (req.method === "GET") { + res.setHeader("Allow", ["POST"]); + return apiError(Boom.methodNotAllowed("GET method not allowed")); + } + + const schema = Joi.object({ + id: Joi.string().guid({ version: "uuidv4" }).required(), + oldPassword: Joi.string().required(), + password: Joi.string().required(), + }); + + const { error, value } = schema.validate(req.body); + if (error) { + return apiError(Boom.badRequest(error.details[0].message)); + } + + let result; + try { + result = await client + .query(getOldPassword, { + id: value.id, + }) + .toPromise(); + } catch (error) { + console.error(error); + return apiError(Boom.serverUnavailable("get old password failed")); + } + + if (result.error) { + console.error(result.error); + return apiError(Boom.badRequest("request error")); + } + + const { user } = result.data; + if (!user) { + return apiError(Boom.unauthorized("Invalid id or password")); + } + // see if password hashes matches + const match = await verify(user.password, value.oldPassword); + + if (!match) { + console.error("old password does not match"); + return apiError(Boom.unauthorized("Invalid id or password")); + } + + try { + result = await client + .query(changeMyPasswordMutation, { + id: value.id, + password: await hash(value.password), + }) + .toPromise(); + } catch (error) { + console.error(error); + return apiError(Boom.serverUnavailable("set new password failed")); + } + if (result.error) { + console.error(result.error); + return apiError(Boom.badRequest("request error")); + } + + console.log("[change password]", value.id); + + res.json({ message: "password updated" }); +} diff --git a/src/pages/api/login.js b/src/pages/api/login.js index 842d559a5..b5bdf802e 100644 --- a/src/pages/api/login.js +++ b/src/pages/api/login.js @@ -70,7 +70,9 @@ export default async function login(req, res) { .query(refreshTokenMutation, { refresh_token_data: { user_id: user.id, - expires_at: getExpiryDate(process.env.REFRESH_TOKEN_EXPIRES || 43200), + expires_at: getExpiryDate( + parseInt(process.env.REFRESH_TOKEN_EXPIRES, 10) + ), }, }) .toPromise(); diff --git a/src/pages/api/logout.js b/src/pages/api/logout.js index defd08e4d..a8761a400 100644 --- a/src/pages/api/logout.js +++ b/src/pages/api/logout.js @@ -37,6 +37,7 @@ export default async function logout(req, res) { console.error(e); // let this error pass. Just log out the user by sending https status code 200 back } + console.log("[ logout ]", { refresh_token }); res.setHeader( "Set-Cookie", cookie.serialize("refresh_token", "deleted", { diff --git a/src/pages/api/activate.gql.js b/src/pages/api/password.gql.js similarity index 68% rename from src/pages/api/activate.gql.js rename to src/pages/api/password.gql.js index 807ecc3bd..0a3f46de5 100644 --- a/src/pages/api/activate.gql.js +++ b/src/pages/api/password.gql.js @@ -34,3 +34,22 @@ mutation updatePassword($secret_token: uuid!, $now: timestamptz!, $password: Str affected_rows } }`; +export const getOldPassword = ` +query getPassword($id: uuid!) { + user: auth_users_by_pk(id: $id) { password } +} +`; + +export const changeMyPasswordMutation = ` +mutation updateMyPassword($id: uuid!, $password: String!) { + update_user: update_auth_users_by_pk( + _set: { + password: $password + } + pk_columns: { + id: $id + } + ){ + id + } +}`; diff --git a/src/pages/api/refresh_token.js b/src/pages/api/refresh_token.js index c50437b82..8542627ff 100644 --- a/src/pages/api/refresh_token.js +++ b/src/pages/api/refresh_token.js @@ -11,12 +11,12 @@ import { getRefreshTokenQuery, } from "./refreshToken.gql"; -export default async function refresh_token(req, res) { +export default async function refreshToken(req, res) { const apiError = createErrorFor(res); const schema = Joi.object({ refresh_token: Joi.string().guid({ version: "uuidv4" }).required(), }).unknown(); - + console.log("[ /api/refresh_token ]", req.cookies, req.hostname); let { error, value } = schema.validate(req.query); if (error) { @@ -31,12 +31,17 @@ export default async function refresh_token(req, res) { value = temp.value; } + const { refresh_token } = value; + if (error) { return apiError(Boom.badRequest(error.details[0].message)); } - const { refresh_token } = value; let result; try { + console.log({ + refresh_token, + current_timestampz: new Date(), + }); result = await client .query(getRefreshTokenQuery, { refresh_token, @@ -48,6 +53,11 @@ export default async function refresh_token(req, res) { console.error("Error connecting to GraphQL"); return apiError(Boom.unauthorized("Invalid 'refresh_token'")); } + console.log( + "[ api/refresh_token ]", + { refresh_token }, + result.data.refresh_tokens.length > 0 ? "found" : "unknown" + ); if (result.data.refresh_tokens.length === 0) { console.error("Incorrect user id or refresh token", refresh_token); @@ -70,7 +80,9 @@ export default async function refresh_token(req, res) { new_refresh_token_data: { user_id: user.id, refresh_token: new_refresh_token, - expires_at: getExpiryDate(process.env.REFRESH_TOKEN_EXPIRES), + expires_at: getExpiryDate( + parseInt(process.env.REFRESH_TOKEN_EXPIRES, 10) + ), }, }) .toPromise(); diff --git a/src/pages/api/register.js b/src/pages/api/register.js deleted file mode 100644 index 343a9b768..000000000 --- a/src/pages/api/register.js +++ /dev/null @@ -1,74 +0,0 @@ -import Boom from "@hapi/boom"; -import Joi from "@hapi/joi"; -import { hash } from "argon2"; -import { createErrorFor } from "src/lib/apiError"; -import { getExpiryDate } from "src/lib/duration"; -import { client } from "src/lib/graphqlApiClient"; -import { verifyJwtToken } from "src/lib/jwt"; - -export default async function register(req, res) { - const apiError = createErrorFor(res); - - try { - verifyJwtToken(req.headers.authorization); - } catch (e) { - console.error(e); - if (e.type === "badRequest") { - return apiError(Boom.badRequest(e.message)); - } - return apiError(Boom.unauthorized(e.message)); - } - - if (req.method === "GET") { - res.setHeader("Allow", ["POST"]); - return apiError(Boom.methodNotAllowed("GET method not allowed")); - } - - const schema = Joi.object({ - email: Joi.string().email().required(), - name: Joi.string().required(), - }); - - const { error, value } = schema.validate(req.body); - - if (error) { - return apiError(Boom.badRequest(error.details[0].message)); - } - - const { email, name } = value; - - // create user account - const mutation = ` - mutation ( - $user: auth_users_insert_input! - ) { - insert_auth_users ( - objects: [$user] - ) { - affected_rows - } - } - `; - - // create user and user_account in same mutation - try { - await client - .query(mutation, { - user: { - name, - email, - password: await hash(new Date().toISOString()), - user_roles: { data: [{ role: "user" }] }, - secret_token_expires_at: getExpiryDate( - parseInt(process.env.ACTIVATION_TOKEN_EXPIRES, 10) || 10080 - ), - }, - }) - .toPromise(); - } catch (error) { - console.error(error); - return apiError(Boom.badImplementation("Unable to create user.")); - } - console.log("[register]", name, email); - res.json("user created !"); -} diff --git a/src/pages/api/renew_password.js b/src/pages/api/renew_password.js new file mode 100644 index 000000000..607fa0e57 --- /dev/null +++ b/src/pages/api/renew_password.js @@ -0,0 +1,8 @@ +import { changePasswordMutation } from "./password.gql"; +import { createRequestHandler } from "./activate_account"; + +export default createRequestHandler({ + mutation: changePasswordMutation, + error_message: "The secret token has expired or there is no account.", + success_message: "password changed !", +}); diff --git a/src/pages/api/reset_password.js b/src/pages/api/reset_password.js index d3e5e10a3..8725f58fa 100644 --- a/src/pages/api/reset_password.js +++ b/src/pages/api/reset_password.js @@ -24,15 +24,20 @@ export default async function reset_password(req, res) { } const { email } = value; - + let result; try { - await client + result = await client .query(udpateSecretTokenMutation, { email, secret_token: uuidv4(), - expires: getExpiryDate(process.env.ACTIVATION_TOKEN_EXPIRES || 10080), + expires: getExpiryDate( + parseInt(process.env.NEXT_PUBLIC_ACTIVATION_TOKEN_EXPIRES, 10) + ), }) .toPromise(); + if (result.error) { + throw result.error; + } } catch (error) { // silently fail to not disclose if user exists or not console.error(error); @@ -49,11 +54,9 @@ mutation updateSecretTokenMutation( $expires: timestamptz!, $secret_token: uuid ) { - update_user: update_users( + update_user: update_auth_users( where: { - _and: { - email: { _eq: $email} , - } + email: { _eq: $email} , } _set: { secret_token_expires_at: $expires diff --git a/src/pages/api/webhooks/account.js b/src/pages/api/webhooks/account.js index 741f444f0..6c4841b53 100644 --- a/src/pages/api/webhooks/account.js +++ b/src/pages/api/webhooks/account.js @@ -2,10 +2,13 @@ import { createErrorFor } from "../../../../src/lib/apiError"; import Joi from "@hapi/joi"; import Boom from "@hapi/boom"; import sendmail from "../../../lib/sendmail"; - +const BASE_URL = + process.env.FRONTEND_URL || `http://localhost:${process.env.PORT}`; export default async function accountWebhook(req, res) { const apiError = createErrorFor(res); + console.log("[webhook] start webhook"); + if (req.headers["email-secret"] !== process.env.ACCOUNT_EMAIL_SECRET) { return apiError(Boom.unauthorized("Invalid secret token")); } @@ -47,7 +50,7 @@ export default async function accountWebhook(req, res) { const { data, op } = value.event; const { email, secret_token } = data.new; let subject = "Activation de votre compte"; - let activateUrl = `${process.env.FRONTEND_URL}/change_password?token=${secret_token}&activate=1`; // todo: dynamic hostname + let activateUrl = `${BASE_URL}/change_password?token=${secret_token}&activate=1`; // todo: dynamic hostname let text = `Bonjour, Vous pouvez activer votre compte ${email} afin d'accéder à l'outil d'administration du cdtn en suivant ce lien : ${activateUrl} @@ -56,7 +59,7 @@ export default async function accountWebhook(req, res) { `; if (op === "UPDATE") { - activateUrl = `${process.env.FRONTEND_URL}/change_password?token=${secret_token}`; // todo: dynamic hostname + activateUrl = `${BASE_URL}/change_password?token=${secret_token}`; // todo: dynamic hostname subject = "Réinitialisation de votre mot de passe"; text = ` Bonjour, @@ -78,6 +81,7 @@ L'equipe veille CDTN }; try { const results = await sendmail(mailOptions); + console.log("[webhook] email send", op); res.json(results); } catch (error) { console.error(error); diff --git a/src/pages/change_password.js b/src/pages/change_password.js new file mode 100644 index 000000000..5907b740d --- /dev/null +++ b/src/pages/change_password.js @@ -0,0 +1,57 @@ +/** @jsx jsx */ + +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { PasswordLayout } from "src/components/layout/password.layout"; +import { PasswordForm } from "src/components/user/PasswordForm"; +import { request } from "src/lib/request"; +import { jsx, NavLink, Text } from "theme-ui"; + +export default function ChangePasswordPage() { + const router = useRouter(); + const { activate, token } = router.query; + let title = "Nouveau mot de passe"; + let url = "/api/renew_password"; + if (activate) { + title = "Activer votre compte"; + url = "/api/activate_account"; + } + const [success, setSuccess] = useState(false); + let loading = false; + + async function updatePassword({ password }) { + loading = true; + try { + await request(url, { body: { password, token } }); + setSuccess(true); + } catch (error) { + console.error(error); + } + loading = false; + } + + if (success) { + return ( + + + Votre mot de passe à été ré-initialisez, suiviez le lien en dessous + pour vous connecter. + + + Se connecter + + + ); + } + return ( + + + + ); +} diff --git a/src/pages/index.js b/src/pages/index.js index 1ff924dbe..13b418a27 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -1,17 +1,13 @@ /** @jsx jsx */ -import { jsx, Text } from "theme-ui"; -import Head from "next/head"; -import { withCustomUrqlClient } from "src/components/CustomUrqlClient"; -import { withAuthProvider } from "src/hooks/useAuth"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; import { Layout } from "src/components/layout/auth.layout"; +import { withAuthProvider } from "src/lib/auth"; +import { jsx, Text } from "theme-ui"; export function IndexPage() { return ( - - - Home | Admin cdtn - + Administration des Contenu est gestion des alertes ); diff --git a/src/pages/login.js b/src/pages/login.js index f6eb8a0dc..8894a7ddb 100644 --- a/src/pages/login.js +++ b/src/pages/login.js @@ -12,7 +12,7 @@ export default function LoginPage() { const router = useRouter(); const resetPassword = () => { - router.push("/reset-password"); + router.push("/reset_password"); }; const goAdmin = () => { diff --git a/src/pages/reset_password.js b/src/pages/reset_password.js new file mode 100644 index 000000000..83be6b4bf --- /dev/null +++ b/src/pages/reset_password.js @@ -0,0 +1,79 @@ +/** @jsx jsx */ + +import { jsx, Field, Text, NavLink } from "theme-ui"; +import { PasswordLayout } from "src/components/layout/password.layout"; + +import { useForm } from "react-hook-form"; +import { Button } from "src/components/button"; +import { request } from "src/lib/request"; +import { Stack } from "src/components/layout/Stack"; +import { FormErrorMessage } from "src/components/forms/ErrorMessage"; +import { useState } from "react"; +import Link from "next/link"; + +export default function ResetPasswordPage() { + const [success, setSuccess] = useState(false); + const { register, handleSubmit, errors, setError } = useForm(); + const hasError = Object.keys(errors).length > 0; + let loading = false; + + async function resetPassword(data) { + console.log(data); + loading = true; + try { + await request("/api/reset_password", { body: data }); + setSuccess(true); + } catch (error) { + console.error(error); + setError("email", "validate", "désolé, une erreur est survenue :("); + } + loading = false; + } + + if (success) { + return ( + + + Nous venons de vous envoyer un lien pour ré-initialisez votre mot de + passe par mail. Vous pouvez consulter votre boîte au mail et suivre + les instructions pour definir un nouveau mot de passe. +
    +
    + + Se connecter + +
    + ); + } + return ( + +
    + + + Vous avez perdu votre mot de passe ? +
    + Saissisez votre adresse email et validez pour recevoir par mail un + lien pour ré-initialiser votre mot de passe. +
    + + + +
    +
    +
    + ); +} diff --git a/src/pages/user/account.js b/src/pages/user/account.js new file mode 100644 index 000000000..41201a3d3 --- /dev/null +++ b/src/pages/user/account.js @@ -0,0 +1,50 @@ +/** @jsx jsx */ + +import Link from "next/link"; +import { Button } from "src/components/button"; +import { Layout } from "src/components/layout/auth.layout"; +import { Inline } from "src/components/layout/Inline"; +import { Stack } from "src/components/layout/Stack"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; +import { useAuth } from "src/hooks/useAuth"; +import { withAuthProvider } from "src/lib/auth"; +import { jsx, Label, Text } from "theme-ui"; + +export function UserPage() { + const { user } = useAuth(); + + return ( + + {user && ( + +
    + + {user.name} +
    +
    + + {user.email} +
    +
    + + {user.roles[0].role} +
    + + + + + + + + +
    + )} +
    + ); +} + +export default withCustomUrqlClient(withAuthProvider(UserPage)); diff --git a/src/pages/user/edit.js b/src/pages/user/edit.js new file mode 100644 index 000000000..ff4d50474 --- /dev/null +++ b/src/pages/user/edit.js @@ -0,0 +1,77 @@ +/** @jsx jsx */ +import { useRouter } from "next/router"; +import { Layout } from "src/components/layout/auth.layout"; +import { UserForm } from "src/components/user/UserForm"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; +import { useAuth } from "src/hooks/useAuth"; +import { withAuthProvider } from "src/lib/auth"; +import { jsx } from "theme-ui"; +import { useMutation } from "urql"; + +const saveUserMutation = ` +mutation saveUser($id: uuid!, $name:String!, $email: citext!) { + update_auth_users(_set: { + name: $name, + email: $email, + }, + where: { + id: {_eq: $id} + } + ){ + returning { + __typename + } + } +} +`; + +const saveRoleMutation = ` +mutation saveRole($id: uuid!, $role:String!) { + update_auth_user_roles(_set: {role: $role}, where: { + users: { + id: {_eq: $id} + } + }) { + returning { + __typename + } + } +} +`; + +export function EditUserPage() { + const { user, isAdmin } = useAuth(); + const router = useRouter(); + const [userResult, saveUser] = useMutation(saveUserMutation); + const [roleResult, saveRole] = useMutation(saveRoleMutation); + function handleSubmit(data) { + const { name, email, role } = data; + console.log(user, name, email, role); + let rolePromise = Promise.resolve(); + if (user.roles.every((item) => item.role !== role)) { + rolePromise = saveRole({ id: user.id, role }); + } + rolePromise + .then(() => saveUser({ id: user.id, name, email })) + .then((result) => { + if (!result.error) { + router.push("/users"); + } + }); + } + return ( + + {user && ( + + )} + + ); +} + +export default withCustomUrqlClient(withAuthProvider(EditUserPage)); diff --git a/src/pages/user/edit/[id].js b/src/pages/user/edit/[id].js new file mode 100644 index 000000000..2d2b6ebbe --- /dev/null +++ b/src/pages/user/edit/[id].js @@ -0,0 +1,83 @@ +/** @jsx jsx */ + +import PropTypes from "prop-types"; +import { useRouter } from "next/router"; +import { Layout } from "src/components/layout/auth.layout"; +import { UserForm } from "src/components/user/UserForm"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; +import { withAuthProvider } from "src/lib/auth"; +import { jsx } from "theme-ui"; +import { useMutation } from "urql"; +import { getUserQuery, useAuth } from "src/hooks/useAuth"; + +const saveUserMutation = ` +mutation saveUser($id: uuid!, $name:String!, $email: citext!) { + update_auth_users_by_pk( + _set: { + name: $name, + email: $email, + }, + pk_columns: { id: $id} + ){ __typename } +} +`; + +const saveRoleMutation = ` +mutation saveRole($id: uuid!, $role:String!) { + update_auth_user_roles(_set: {role: $role}, where: { + user_id: {_eq: $id} + }){ + returning { __typename } + } +} +`; + +export function EditUserPage({ user }) { + const router = useRouter(); + const { isAdmin } = useAuth(); + const [userResult, saveUser] = useMutation(saveUserMutation); + const [roleResult, saveRole] = useMutation(saveRoleMutation); + function handleSubmit(data) { + const { name, email, default_role } = data; + let rolePromise = Promise.resolve(); + if (user.roles.every(({ role }) => role !== default_role)) { + rolePromise = saveRole({ id: user.id, role: default_role }); + } + rolePromise + .then(() => saveUser({ id: user.id, name, email })) + .then((result) => { + if (!result.error) { + router.push("/users"); + } + }); + } + return ( + + + + ); +} +EditUserPage.propTypes = { + user: PropTypes.object.isRequired, +}; + +EditUserPage.getInitialProps = async function ({ urqlClient, query }) { + const { id } = query; + const result = await urqlClient.query(getUserQuery, { id }).toPromise(); + if (!result.data.user) { + return Promise.reject({ + name: "not found", + statusCode: 404, + message: "User not found", + }); + } + return { user: result.data.user }; +}; + +export default withCustomUrqlClient(withAuthProvider(EditUserPage)); diff --git a/src/pages/user/new.js b/src/pages/user/new.js new file mode 100644 index 000000000..64b7a8105 --- /dev/null +++ b/src/pages/user/new.js @@ -0,0 +1,58 @@ +/** @jsx jsx */ +import { useRouter } from "next/router"; +import { Layout } from "src/components/layout/auth.layout"; +import { UserForm } from "src/components/user/UserForm"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; +import { withAuthProvider } from "src/lib/auth"; +import { Alert, jsx } from "theme-ui"; +import { useMutation } from "urql"; +import { getExpiryDate } from "src/lib/duration"; + +const registerUserMutation = ` +mutation registerUser($user: auth_users_insert_input! ) { + insert_auth_users( objects: [$user] ) { + returning { + id + __typename + } + } +} +`; + +function prepareMutationData(input) { + return { + user: { + ...input, + secret_token_expires_at: getExpiryDate( + parseInt(process.env.NEXT_PUBLIC_ACTIVATION_TOKEN_EXPIRES, 10) + ), + user_roles: { data: { role: input.default_role } }, + }, + }; +} +export function UserPage() { + const router = useRouter(); + const [result, registerUser] = useMutation(registerUserMutation); + const { fetching, error } = result; + + function handleCreate(data) { + registerUser(prepareMutationData(data)).then((result) => { + if (!result.error) { + router.push("/users"); + } + }); + } + + return ( + + {error && ( + +
    {JSON.stringify(error, 0, 2)}
    +
    + )} + +
    + ); +} + +export default withCustomUrqlClient(withAuthProvider(UserPage)); diff --git a/src/pages/user/password.js b/src/pages/user/password.js new file mode 100644 index 000000000..cc6d366ea --- /dev/null +++ b/src/pages/user/password.js @@ -0,0 +1,28 @@ +/** @jsx jsx */ + +import { useRouter } from "next/router"; +import { Layout } from "src/components/layout/auth.layout"; +import { PasswordForm } from "src/components/user/PasswordForm"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; +import { withAuthProvider } from "src/lib/auth"; +import { request } from "src/lib/request"; +import { jsx } from "theme-ui"; + +export function ChangeMyPasswordPage() { + const router = useRouter(); + async function handleChangePasword({ id, oldPassword, password }) { + await request("/api/change_password", { + body: { id, oldPassword, password }, + }); + + router.push("/user/account"); + } + + return ( + + + + ); +} + +export default withCustomUrqlClient(withAuthProvider(ChangeMyPasswordPage)); diff --git a/src/pages/users.js b/src/pages/users.js new file mode 100644 index 000000000..938209888 --- /dev/null +++ b/src/pages/users.js @@ -0,0 +1,27 @@ +/** @jsx jsx */ + +import Link from "next/link"; +import { IoIosAdd } from "react-icons/io"; +import { Button } from "src/components/button"; +import { withCustomUrqlClient } from "src/hoc/CustomUrqlClient"; +import { Layout } from "src/components/layout/auth.layout"; +import { UserList } from "src/components/user/List"; +import { withAuthProvider } from "src/lib/auth"; +import { Flex, jsx } from "theme-ui"; + +export function UserPage() { + return ( + + + + + + + + + ); +} + +export default withCustomUrqlClient(withAuthProvider(UserPage)); diff --git a/src/pages/users/index.js b/src/pages/users/index.js deleted file mode 100644 index 3f8e8729a..000000000 --- a/src/pages/users/index.js +++ /dev/null @@ -1,33 +0,0 @@ -/** @jsx jsx */ -import { jsx, Flex, Heading } from "theme-ui"; -import Head from "next/head"; - -import { withCustomUrqlClient } from "src/components/CustomUrqlClient"; -import { withAuthProvider } from "src/hooks/useAuth"; -import { Layout } from "src/components/layout/auth.layout"; -import { UserList } from "src/components/UserList"; -import Link from "next/link"; -import { Button } from "src/components/button"; -import { IoIosAdd } from "react-icons/io"; - -export function UserPage() { - return ( - - - Gestion des utilisateurs | Admin cdtn - - - Gestion des utilisateurs - - - - - - - - ); -} - -export default withCustomUrqlClient(withAuthProvider(UserPage)); diff --git a/src/pages/users/new.js b/src/pages/users/new.js deleted file mode 100644 index ce621df05..000000000 --- a/src/pages/users/new.js +++ /dev/null @@ -1,24 +0,0 @@ -/** @jsx jsx */ -import { jsx, Heading, NavLink } from "theme-ui"; -import Head from "next/head"; - -import { withCustomUrqlClient } from "src/components/CustomUrqlClient"; -import { withAuthProvider } from "src/hooks/useAuth"; -import { Layout } from "src/components/layout/auth.layout"; -import Link from "next/link"; - -export function UserPage() { - return ( - - - Nouvel utilisateur | Admin cdtn - - Nouvel utilisateur - - Retour - - - ); -} - -export default withCustomUrqlClient(withAuthProvider(UserPage)); diff --git a/src/theme.js b/src/theme.js index 26d9a6515..53f624edc 100644 --- a/src/theme.js +++ b/src/theme.js @@ -105,6 +105,11 @@ export const theme = { color: "secondary", colorHover: "secondaryHover", }, + link: { + text: "text", + color: "transparent", + colorHover: "muted", + }, icon: { bgHover: transparentize(0.8, "#3e486e"), }, @@ -124,15 +129,16 @@ export const theme = { }, badges: { primary: { - px: "xxsmall", bg: "primary", color: "white", fontSize: "medium", + px: "xxsmall", }, secondary: { bg: "secondary", color: "white", fontSize: "medium", + px: "xxsmall", }, }, forms: { @@ -143,6 +149,9 @@ export const theme = { input: { padding: "xsmall", }, + select: { + padding: "xsmall", + }, }, shadows: { card1: "0 0 8px rgba(0, 0, 0, 0.125)", diff --git a/yarn.lock b/yarn.lock index 5001bd30b..42f4b128e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -934,6 +934,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.0.0": + version "7.10.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839" + integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" @@ -1491,31 +1498,43 @@ dependencies: safe-buffer "^5.1.2" -"@reach/auto-id@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.10.2.tgz#a447af67241123dcb701ecd61931a2c786ed111e" - integrity sha512-PWFZevkHshiJV/z0L/5WQkWhe9QRzdZqC7N/JHRCoYo+odvCz9izXVRsxJf7p4sCuOCvnc8zNzAokFk2E1ZzDg== +"@reach/auto-id@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.10.3.tgz#d2b6fe3ccb81b0fb44dc8bd3aca567c94ac11f5e" + integrity sha512-LK3qIsurXnga+gUbjl6t6msrZ+F3aZMY+k2go5Xcns9b85bNRyF/LwlUtcGSqmhgqbVYvMcnLeEdSQLZRxCGnQ== dependencies: - "@reach/utils" "^0.10.2" + "@reach/utils" "^0.10.3" tslib "^1.11.2" -"@reach/descendants@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@reach/descendants/-/descendants-0.10.2.tgz#551c9f767a16bcad6d1abc41449bb46a12cd3ee3" - integrity sha512-jb2HlohyHZtNmm/VMDs5F/I3v+gs/7h6i++Uaubu5XiGkSN/3q5ecMh+cFHjsytQSLtp/7Yall/8+mb+KbIq2A== +"@reach/descendants@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/descendants/-/descendants-0.10.3.tgz#c2cbd14c172cb82189bf6f290b09577193926a1a" + integrity sha512-1uwe2w49xSMF0ei1KedydB30sEWfyksk5axI3nEanwUDO7Sd1kCyt2GHZHoP2ESr6VQx2a9ETzMw8gKHsoy79g== dependencies: - "@reach/utils" "^0.10.2" + "@reach/utils" "^0.10.3" tslib "^1.11.2" -"@reach/menu-button@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@reach/menu-button/-/menu-button-0.10.2.tgz#606a0aeb66421e35f2e52a21f4bb3b478af77431" - integrity sha512-OK/cQRPeed3xO3kyh+vZHYmOr5O12vFqgIEwtwxdyMco/p3UiiTOGHjyD+35v4YvUVyOznXDZHYGOA4JWhxAGQ== +"@reach/dialog@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/dialog/-/dialog-0.10.3.tgz#ba789809c3b194fff79d3bcb4a583c58e03edb83" + integrity sha512-RMpUHNjRQhkjGzKt9/oLmDhwUBikW3JbEzgzZngq5MGY5kWRPwYInLDkEA8We4E43AbBsl5J/PRzQha9V+EEXw== dependencies: - "@reach/auto-id" "^0.10.2" - "@reach/descendants" "^0.10.2" - "@reach/popover" "^0.10.2" - "@reach/utils" "^0.10.2" + "@reach/portal" "^0.10.3" + "@reach/utils" "^0.10.3" + prop-types "^15.7.2" + react-focus-lock "^2.3.1" + react-remove-scroll "^2.3.0" + tslib "^1.11.2" + +"@reach/menu-button@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/menu-button/-/menu-button-0.10.3.tgz#6e72cd122e16f28c4b15a140f329be256adc72c8" + integrity sha512-50C5nl7JJG9YcKqngmwTLVft+ZF2MMieto1GSCC7qEU8ykUNz0p69Ipup+Eqjk7KRHpSIYPlYIfAOS75dDuiZQ== + dependencies: + "@reach/auto-id" "^0.10.3" + "@reach/descendants" "^0.10.3" + "@reach/popover" "^0.10.3" + "@reach/utils" "^0.10.3" prop-types "^15.7.2" tslib "^1.11.2" @@ -1524,44 +1543,51 @@ resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.1.0.tgz#4e967a93852b6004c3895d9ed8d4e5b41895afde" integrity sha512-kE+jvoj/OyJV24C03VvLt5zclb9ArJi04wWXMMFwQvdZjdHoBlN4g0ZQFjyy/ejPF1Z/dpUD5dhRdBiUmIGZTA== -"@reach/popover@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.10.2.tgz#a0a047e1253f779c27f7839dfed6d2b82ad52328" - integrity sha512-NsRZJ1VjBOZLl4tbIAsR4CxYVsrtwYDvw11ZFqm+JszmsIMF2zT/WGnJ/dnbgUXIRg8gHs4YTwd9FvvU6iD8Bw== +"@reach/popover@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.10.3.tgz#82e29b91748869923756a165758a29c8269b93e3" + integrity sha512-41iNfdjd9/5HtYuhezTc9z9WGkloYFVB8wBmPX3QOTuBP4qYd0La5sXClrfyiVqPn/uj1gGzehrZKuh8oSkorw== dependencies: - "@reach/portal" "^0.10.2" - "@reach/rect" "^0.10.2" - "@reach/utils" "^0.10.2" + "@reach/portal" "^0.10.3" + "@reach/rect" "^0.10.3" + "@reach/utils" "^0.10.3" tabbable "^4.0.0" tslib "^1.11.2" -"@reach/portal@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.10.2.tgz#a2b4f69eaffad0115320e303d08ea57c8942b398" - integrity sha512-ZQU7Xlx9fBv1UurttmaS3dfWRT60Dn9Dtt/wmFyIf5Rquw64jNNL0XD5z0zqgVB+j5qnKERMf1hosYMU3BDSgQ== +"@reach/portal@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.10.3.tgz#2eb408cc246d3eabbbf3b47ca4dc9c381cdb1d88" + integrity sha512-t8c+jtDxMLSPRGg93sQd2s6dDNilh5/qdrwmx88ki7l9h8oIXqMxPP3kSkOqZ9cbVR0b2A68PfMhCDOwMGvkoQ== dependencies: - "@reach/utils" "^0.10.2" + "@reach/utils" "^0.10.3" tslib "^1.11.2" -"@reach/rect@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.10.2.tgz#6f1db6c7561e46c77505840c973d23fd5a57f7d4" - integrity sha512-Bk9J40toquXhvvdv4uFe4yIRm2Bkdi5ljYHL7NuBoOcn4xHnk/kjAeqmg8mDwaiZzo2vRekawGU5td2aeO/S0A== +"@reach/rect@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.10.3.tgz#b4fd7c730d27eaf5fdf04c9a65227cc805787803" + integrity sha512-OmnGfG+MdumviJXK5oPcrw2Nd4EgMPKLMCs03GrbkmZJwtXIQJNhQrVg60PQT6HKAKI0+0LobHKxHFT+7Ri7kw== dependencies: "@reach/observe-rect" "^1.1.0" - "@reach/utils" "^0.10.2" + "@reach/utils" "^0.10.3" prop-types "^15.7.2" tslib "^1.11.2" -"@reach/utils@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.10.2.tgz#b835a7d02bd9fa73a009f7a8286d906745809a2f" - integrity sha512-zLYnmE5khVolwYd1wAqa+r6885KDILrZJfVXRa5yUEZ/8ygXhUleiwNpqrAifAD6fG/AUsLa2Azi6n3Nfxf08A== +"@reach/utils@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.10.3.tgz#e30f9b172d131161953df7dd01553c57ca4e78f8" + integrity sha512-LoIZSfVAJMA+DnzAMCMfc/wAM39iKT8BQQ9gI1FODpxd8nPFP4cKisMuRXImh2/iVtG2Z6NzzCNgceJSrywqFQ== dependencies: "@types/warning" "^3.0.0" tslib "^1.11.2" warning "^4.0.3" +"@reach/visually-hidden@^0.10.2": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@reach/visually-hidden/-/visually-hidden-0.10.2.tgz#cbe391c78be2139be9583bae5666ab58eb82f682" + integrity sha512-RWC2CZsEB6sUOMnBCiuemyesMVNOOKJP53j4RgbdaJ2zGFL6N+bh/E5bfZnAiVhjJ0G0laViE9s7iROaRWNFew== + dependencies: + tslib "^1.11.2" + "@sentry/apm@5.15.5": version "5.15.5" resolved "https://registry.yarnpkg.com/@sentry/apm/-/apm-5.15.5.tgz#dc0515f16405de52b3ba0d26f8a6dc2fcefe5fcc" @@ -2021,6 +2047,13 @@ dependencies: wonka "^4.0.10" +"@urql/devtools@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@urql/devtools/-/devtools-2.0.2.tgz#0991bc3bb09444b162ef56518b76f64f286954fc" + integrity sha512-f3gIx4NDOuWdXC54m4VQlLKNtYHl6PITSEqexH6xiktzvH7QcwS5hNPg0Fkki8cas/DZtkmDpwxNV1uRZ5xlvA== + dependencies: + wonka ">= 4.0.9" + "@webassemblyjs/ast@1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" @@ -3966,6 +3999,11 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +detect-node@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" + integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== + diff-sequences@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.0.0.tgz#0760059a5c287637b842bd7085311db7060e88a6" @@ -4814,6 +4852,11 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +focus-lock@^0.6.7: + version "0.6.8" + resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-0.6.8.tgz#61985fadfa92f02f2ee1d90bc738efaf7f3c9f46" + integrity sha512-vkHTluRCoq9FcsrldC0ulQHiyBYgVJB2CX53I8r0nTC6KnEij7Of0jpBspjt3/CuNb6fyoj3aOh9J2HgQUM0og== + follow-redirects@^1.0.0: version "1.11.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.11.0.tgz#afa14f08ba12a52963140fe43212658897bc0ecb" @@ -4955,6 +4998,11 @@ get-caller-file@^2.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-stdin@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-7.0.0.tgz#8d5de98f15171a125c5e516643c7a6d0ea8a96f6" @@ -7007,7 +7055,7 @@ next-tick@~1.0.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= -next-urql@^0.3.7: +next-urql@^0.3.8: version "0.3.8" resolved "https://registry.yarnpkg.com/next-urql/-/next-urql-0.3.8.tgz#b9af59be9bcf037558fc78b26ddbb51e26bcea0b" integrity sha512-i0fOKI4tUmGh/4n8F+7r1UedyLDtDdaei+e6RC6eR0wPkcF04S4bX5N+JBLkvzVRONO5XRNcIBgo5UEmQS5YfQ== @@ -8341,6 +8389,13 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-clientside-effect@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837" + integrity sha512-nRmoyxeok5PBO6ytPvSjKp9xwXg9xagoTK1mMjwnQxqM9Hd7MNPl+LS1bOSOe+CV2+4fnEquc7H/S8QD3q697A== + dependencies: + "@babel/runtime" "^7.0.0" + react-dom@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" @@ -8351,6 +8406,23 @@ react-dom@^16.13.1: prop-types "^15.6.2" scheduler "^0.19.1" +react-focus-lock@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47" + integrity sha512-j15cWLPzH0gOmRrUg01C09Peu8qbcdVqr6Bjyfxj80cNZmH+idk/bNBYEDSmkAtwkXI+xEYWSmHYqtaQhZ8iUQ== + dependencies: + "@babel/runtime" "^7.0.0" + focus-lock "^0.6.7" + prop-types "^15.6.2" + react-clientside-effect "^1.2.2" + use-callback-ref "^1.2.1" + use-sidecar "^1.0.1" + +react-hook-form@^5.7.2: + version "5.7.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.7.2.tgz#a84e259e5d37dd30949af4f79c4dac31101b79ac" + integrity sha512-bJvY348vayIvEUmSK7Fvea/NgqbT2racA2IbnJz/aPlQ3GBtaTeDITH6rtCa6y++obZzG6E3Q8VuoXPir7QYUg== + react-icons@^3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-3.10.0.tgz#6c217a2dde2e8fa8d293210023914b123f317297" @@ -8368,6 +8440,25 @@ react-refresh@0.8.2: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.2.tgz#24bb0858eac92b0d7b0dd561747f0c9fd6c60327" integrity sha512-n8GXxo3DwM2KtFEL69DAVhGc4A1THn2qjmfvSo3nze0NLCoPbywazeJPqdp0RdSGLmyhQzeyA+XPXOobbYlkzg== +react-remove-scroll-bar@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.1.0.tgz#edafe9b42a42c0dad9bdd10712772a1f9a39d7b9" + integrity sha512-5X5Y5YIPjIPrAoMJxf6Pfa7RLNGCgwZ95TdnVPgPuMftRfO8DaC7F4KP1b5eiO8hHbe7u+wZNDbYN5WUTpv7+g== + dependencies: + react-style-singleton "^2.1.0" + tslib "^1.0.0" + +react-remove-scroll@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.3.0.tgz#3af06fe2f7130500704b676cdef94452c08fe593" + integrity sha512-UqVimLeAe+5EHXKfsca081hAkzg3WuDmoT9cayjBegd6UZVhlTEchleNp9J4TMGkb/ftLve7ARB5Wph+HJ7A5g== + dependencies: + react-remove-scroll-bar "^2.1.0" + react-style-singleton "^2.1.0" + tslib "^1.0.0" + use-callback-ref "^1.2.3" + use-sidecar "^1.0.1" + react-ssr-prepass@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/react-ssr-prepass/-/react-ssr-prepass-1.2.1.tgz#ba867bbe60714f42977dbe1ef9b8ff469b950602" @@ -8375,6 +8466,15 @@ react-ssr-prepass@^1.2.1: dependencies: object-is "^1.1.2" +react-style-singleton@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.1.0.tgz#7396885332e9729957f9df51f08cadbfc164e1c4" + integrity sha512-DH4ED+YABC1dhvSDYGGreAHmfuTXj6+ezT3CmHoqIEfxNgEYfIMoOtmbRp42JsUst3IPqBTDL+8r4TF7EWhIHw== + dependencies: + get-nonce "^1.0.0" + invariant "^2.2.4" + tslib "^1.0.0" + react@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" @@ -9850,7 +9950,7 @@ ts-pnp@^1.1.6: resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw== -tslib@^1.11.2, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.0.0, tslib@^1.11.2, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.13.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== @@ -10061,6 +10161,19 @@ urql@>=1.9.4, urql@^1.9.7: "@urql/core" "^1.11.0" wonka "^4.0.9" +use-callback-ref@^1.2.1, use-callback-ref@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.2.3.tgz#9f939dfb5740807bbf9dd79cdd4e99d27e827756" + integrity sha512-DPBPh1i2adCZoIArRlTuKRy7yue7QogtEnfv0AKrWsY+GA+4EKe37zhRDouNnyWMoNQFYZZRF+2dLHsWE4YvJA== + +use-sidecar@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.0.2.tgz#e72f582a75842f7de4ef8becd6235a4720ad8af6" + integrity sha512-287RZny6m5KNMTb/Kq9gmjafi7lQL0YHO1lYolU6+tY1h9+Z3uCtkJJ3OSOq3INwYf2hBryCcDh4520AhJibMA== + dependencies: + detect-node "^2.0.4" + tslib "^1.9.3" + use-subscription@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.4.1.tgz#edcbcc220f1adb2dd4fa0b2f61b6cc308e620069" @@ -10345,7 +10458,7 @@ wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" -wonka@^4.0.10, wonka@^4.0.13, wonka@^4.0.9: +"wonka@>= 4.0.9", wonka@^4.0.10, wonka@^4.0.13, wonka@^4.0.9: version "4.0.13" resolved "https://registry.yarnpkg.com/wonka/-/wonka-4.0.13.tgz#8d188160bd5742870c78ede7a4eba686d089a33f" integrity sha512-aWg92IVvbP/kp+q9rw+k/Uw3C/S2J0dTDNhEhivGVH3GXJZgpFk2nuyVtiS7Y1d0UG3m4jvOrR7bPXim6D/TBg==