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 (
-
-
-
- Rôle |
- Nom d’utilisateur |
- Email |
- Date de création |
- Activé |
- Actions |
-
-
-
- {data.users.map(
- ({ id, default_role, name, email, created_at, active }) => (
-
-
-
- {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 (
+
+ );
+}
+
+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
+
- {children}
+ {title}
+ {children}
);
}
+Layout.propTypes = {
+ title: PropTypes.string.isRequired,
+ children: PropTypes.node,
+};
diff --git a/src/components/layout/header.js b/src/components/layout/header.js
index 842e750d7..d29a6d8eb 100644
--- a/src/components/layout/header.js
+++ b/src/components/layout/header.js
@@ -1,15 +1,18 @@
/** @jsx jsx */
-import { jsx, Box, Image, Text } from "theme-ui";
+import { Container } from "next/app";
+import Link from "next/link";
+import { useRouter } from "next/router";
import { IoMdContact } from "react-icons/io";
-import { Button } from "src/components/button";
+import { MenuButton, MenuItem } from "src/components/button";
+import { Box, Image, jsx, Text } from "theme-ui";
import { useAuth } from "../../hooks/useAuth";
-import Link from "next/link";
-import { Container } from "next/app";
export function Header() {
- console.log("[header]");
const { user, logout } = useAuth();
- console.log({ user });
+ const router = useRouter();
+ function updateProfile() {
+ router.push("/user/account");
+ }
return (
{user?.name}
-
+
+
+
+
)}
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 ;
}
+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 (
+ <>
+
+
+
+
+ Rôle |
+ Nom d’utilisateur |
+ Email |
+ Date de création |
+ Activé |
+ Actions |
+
+
+
+ {data.users.map(
+ ({ id, roles: [{ role }], name, email, created_at, active }) => (
+
+
+
+ {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/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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
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==