From 1204229cdceab7d68d5096dfabd956eeaa457776 Mon Sep 17 00:00:00 2001 From: cskaandorp Date: Tue, 10 Oct 2023 13:28:57 +0200 Subject: [PATCH] Improve Docker recipe for authenticated app (#1518) --- DEVELOPMENT.md | 2 + Docker/README.md | 69 +++++++++++ Docker/auth_verified/.env | 15 +++ Docker/auth_verified/Dockerfile_backend | 70 ++++++++++++ Docker/auth_verified/Dockerfile_frontend | 26 +++++ Docker/auth_verified/asreview.conf | 18 +++ Docker/auth_verified/docker-compose.yml | 51 +++++++++ Docker/auth_verified/flask_config.toml | 24 ++++ Docker/auth_verified/wsgi.py | 3 + Dockerfile => Docker/simple/Dockerfile | 13 ++- asreview/entry_points/auth_tool.py | 107 ++++++++++++++++-- asreview/webapp/authentication/models.py | 25 +++- asreview/webapp/src/App.js | 41 ++++--- .../webapp/src/Components/ConfirmAccount.js | 11 +- .../webapp/src/Components/ResetPassword.js | 12 +- asreview/webapp/src/Components/SignIn.js | 3 + asreview/webapp/src/Components/SignInForm.js | 9 +- asreview/webapp/src/api/AuthAPI.js | 1 - asreview/webapp/src/globals.js | 4 +- asreview/webapp/start_flask.py | 49 ++++++-- asreview/webapp/tests/README.md | 6 +- setup.py | 6 +- 22 files changed, 503 insertions(+), 62 deletions(-) create mode 100644 Docker/README.md create mode 100644 Docker/auth_verified/.env create mode 100644 Docker/auth_verified/Dockerfile_backend create mode 100644 Docker/auth_verified/Dockerfile_frontend create mode 100644 Docker/auth_verified/asreview.conf create mode 100644 Docker/auth_verified/docker-compose.yml create mode 100644 Docker/auth_verified/flask_config.toml create mode 100644 Docker/auth_verified/wsgi.py rename Dockerfile => Docker/simple/Dockerfile (72%) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6fa9e40a2..9c663ccce 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -334,3 +334,5 @@ docker push ghcr.io/asreview/asreview:1.0 ``` If you are creating a Docker container that runs the app with a [config file](#full-configuration) do __not forget__ to override the IP-address of the Flask backend. Set the HOST variable to "0.0.0.0" since the default "localhost" can't be reached from outside the container. + +See the `Docker` folder for more information about running the ASReview app in Docker containers. diff --git a/Docker/README.md b/Docker/README.md new file mode 100644 index 000000000..959e74b64 --- /dev/null +++ b/Docker/README.md @@ -0,0 +1,69 @@ +# Building ASReview in Docker containers + +This folder contains two recipes to build different versions of the ASReview application in a Docker container. The `simple` folder lists a single Dockerfile that builds a simple, non authenticated version of the ASReview app. If you choose to create this container, and multiple people would like to use it, the app will be globally shared amongst all of them. This version makes more sense as a standalone app on your own computer for individual use. + +The `auth_verified` folder creates an authenticated version that allows multiple users to access the app and create their own private projects. It requires users to signup and signin in order to access the app. + +## Building the simple version + +Creating the docker container for a simple, non-authenticated version of the app is done with the following commands (run these commands from the __root__ folder of the app to ensure the correct context): + +``` +# create a volume +$ docker volume create asreview_simple +# build the container +$ docker build -t asreview -f ./Docker/simple/Dockerfile . +# run container +$ docker run -d -v asreview_simple_volume:/project_folder -p 8080:5000 asreview +``` + +with the external port 8080 being a suggestion. After the last command you find the app in your browser at `http://localhost:8080`. + +If you are creating a Docker container that runs the app with a config file, do __not forget__ to override the IP-address of the Flask backend. Set the HOST variable to "0.0.0.0" since the default "localhost" can't be reached from outside the container. + +## Building the authenticated, verified version + +If you would like to setup the ASReview application as a shared service, a more complicated container setup is required. A common, robust, setup for a Flask/React application is to use [NGINX](https://www.nginx.com/) to serve the frontend, and [Gunicorn](https://gunicorn.org/) to serve the backend. We build separate containers for a database (used for user accounts), and both front- and backend with [docker-compose](https://docs.docker.com/compose/). + +For account verification, but also for the forgot-password feature, an email server is required. But maintaining an email server can be demanding. If you would like to avoid it, a third-party service like [SendGrid](https://sendgrid.com/) might be a good alternative. In this recipe we use the SMTP Relay Service from Sendgrid: every email sent by the ASReview application will be relayed by this service. Sendgrid is for free if you don't expect the application to send more than 100 emails per day. Receiving reply emails from end-users is not possible if you use the Relay service, but that might be irrelevant. + +In the `auth_verified` folder you find 7 files: +1. `.env` - An environment variable file for all relevant parameters (ports, database and Gunicorn related parameters) +2. `asreview.conf` - a configuration files used by NGINX. +3. `docker-compose.yml` - the docker compose file that will create the Docker containers. +4. `Dockerfile_backend` - Dockerfile for the backend, installs all Python related software, including Gunicorn, and starts the backend server. +5. `Dockerfile_frontend` - Dockerfile for the frontend, installs Node, the React frontend and NGINX and starts the NGINX server. +6. `flask_config.toml` - the configuration file for the ASReview application. Contains the necessary EMAIL_CONFIG parameters to link the application to the Sendgrid Relay Service. +7. `wsgi.py` - a tiny Python file that serves the backend with Gunicorn. + +### SendGrid + +If you would like to use or try out [SendGrid](https://sendgrid.com/), go to their website, create an account and sign in. Once signed in, click on "Email API" in the menu and subsequently click on the "Integration Guide" link. Then, choose "SMTP Relay", create an API key and copy the resulting settings (Server, Ports, Username and Password) in your `flask_config.toml` file. It's important to continue with checking the "I've updated my settings" checkbox when it's visible __and__ to click on the "Next: verify Integration" button before you build the Docker containers. + +### Parameters in the .env file + +The .env file contains all necessary parameters to deploy all containers. All variables that end with the `_PORT` suffix refer to the internal and external network ports of the containers. The prefix of these variable explains for which container they are used. Note that the external port of the frontend container, the container that will be directly used by the end-user, is 8080, and not 80. Change this into 80 if you dont want to use port numbers in the URL of the ASReview web application. + +The `EMAIL_PASSWORD` refers to the password provided by the SendGrid Relay service, and the value of the `WORKERS` parameter determines how many instances of the ASReview app Gunicorn will start. + +All variables that start with the `POSTGRES` postfix are meant for the PostgreSQL database. The `_USER`, `_PASSWORD` variables are self-explanatory. the `_DB` variable determines the name of the database. + +### Creating and running the containers + +From the __root__ folder of the app execute the `docker compose` command: + +``` +$ docker compose -f ./Docker/auth_verified/docker-compose.yml up --build +``` + +### Short explanation of the docker-compose workflow + +Building the database container is straightforward, there is no Dockerfile involved. The container spins up a PostgreSQL database, protected by the username and password values in the `.env` file. The backend container depends on the database container to ensure the backend can only start when the database exists. + +The frontend container uses a multi-stage Dockerfile. The first phase builds the React frontend and copies it to the second phase which deploys a simple NGINX container. The `asreview.conf` file is used to configure NGINX to serve the frontend. + +The backend container is more complicated. It also uses a multi-stage Dockerfile. In the first stage all necessary Python/PostgreSQL related software is installed and the app is build. The app is copied into the second stage. Within the second stage the `flask_config.toml` file is copied into the container and all missing parameters (database-uri and email password) are adjusted according to the values in the `.env` file. The path of this Flask configuration file will be communicated to the Flask app by an environment variable.\ +Then a Gunicorn config file (`gunicorn.conf.py`) is created on the fly which sets the server port and the preferred amount of workers. After that a second file is created: an executable shell script that instructs the ASReview app to create the necessary tables in the database and start the Gunicorn server using the configuration described in the previous file. + +Note that a user of this recipe only has to change the necessary values in the `.env` file and execute the `docker compose` command to spin up an ASReview service, without an encrypted HTTP protocol! + \ No newline at end of file diff --git a/Docker/auth_verified/.env b/Docker/auth_verified/.env new file mode 100644 index 000000000..8b0767dda --- /dev/null +++ b/Docker/auth_verified/.env @@ -0,0 +1,15 @@ +BACKEND_EXTERNAL_PORT=5015 +BACKEND_INTERNAL_PORT=5005 + +EMAIL_PASSWORD="password" +WORKERS=4 + +FRONTEND_EXTERNAL_PORT=8080 +FRONTEND_INTERNAL_PORT=80 + +POSTGRES_EXTERNAL_PORT=5433 +POSTGRES_INTERNAL_PORT=5432 + +POSTGRES_PASSWORD="postgres" +POSTGRES_USER="postgres" +POSTGRES_DB="asreview_db" \ No newline at end of file diff --git a/Docker/auth_verified/Dockerfile_backend b/Docker/auth_verified/Dockerfile_backend new file mode 100644 index 000000000..5ff58c0b3 --- /dev/null +++ b/Docker/auth_verified/Dockerfile_backend @@ -0,0 +1,70 @@ +# First stage +FROM python:3.11-slim AS builder + +WORKDIR /app + +# Copy and build asreview +# git is used by versioneer to define the project version +COPY . /app +RUN rm -rf /app/asreview/webapp/build +RUN rm -rf /app/asreview/webapp/node_modules +RUN rm -rf /app/asreview/webapp/public +RUN rm -rf /app/asreview/webapp/src + +RUN apt-get update \ + && apt-get install -y git build-essential libpq-dev\ + && pip3 install --upgrade pip setuptools \ + && pip3 install --user gunicorn \ + && pip3 install --user . \ + && pip3 install --user asreview-datatools asreview-insights asreview-makita asreview-wordcloud + + +# Second stage +FROM python:3.11-slim + +# arguments +ARG EMAIL_PASSWORD +ARG BACKEND_INTERNAL_PORT_ARG +ARG WORKERS +ARG SQLALCHEMY_DATABASE_URI +ARG CREATE_TABLES + +# install necessary libs +RUN apt-get update && apt-get install -y libpq-dev + +WORKDIR /app +COPY --from=builder /root/.local /root/.local + +# copy config TOML file to Image +COPY ./Docker/auth_verified/flask_config.toml ${WORKDIR} + +# the TOML file needs to be configured with the database parameters +# and email password, we use the sed command to search and insert (replace) +# necessary parameters +RUN sed -i "s|--SQLALCHEMY_DATABASE_URI--|${SQLALCHEMY_DATABASE_URI}|g" ./flask_config.toml +RUN sed -i "s|--EMAIL_PASSWORD--|${EMAIL_PASSWORD}|g" ./flask_config.toml + +# set env variables, this is how the TOML config is communicated +# to the app via Gunicorn +ENV PATH=/root/.local/bin:$PATH +ENV ASREVIEW_PATH=/app/project_folder +ENV FLASK_CONFIGFILE=/app/flask_config.toml + +# set the working directory to the app +WORKDIR /root/.local/lib/python3.11/site-packages/asreview/webapp +# copy the module that allows Gunicorn to run the app +COPY ./Docker/auth_verified/wsgi.py ${WORKDIR} + +# create Gunicorn config file +RUN echo "preload_app = True" > gunicorn.conf.py +RUN echo "bind = \"0.0.0.0:${BACKEND_INTERNAL_PORT_ARG}\"" >> gunicorn.conf.py +RUN echo "workers = ${WORKERS}" >> gunicorn.conf.py + +# create start script to ensure creating all necessary tables and +# runs the Gunicorn command +RUN echo "#!/bin/bash" > start.sh +RUN echo "${CREATE_TABLES}" >> start.sh +RUN echo "gunicorn -c gunicorn.conf.py wsgi:app" >> start.sh +RUN ["chmod", "+x", "start.sh"] + +ENTRYPOINT [ "/root/.local/lib/python3.11/site-packages/asreview/webapp/start.sh" ] diff --git a/Docker/auth_verified/Dockerfile_frontend b/Docker/auth_verified/Dockerfile_frontend new file mode 100644 index 000000000..9545dc622 --- /dev/null +++ b/Docker/auth_verified/Dockerfile_frontend @@ -0,0 +1,26 @@ +# pull official base image +FROM node:latest AS builder +# set working directory +WORKDIR /app +# add `/app/node_modules/.bin` to $PATH +ENV PATH /app/node_modules/.bin:$PATH +# install app dependencies +COPY ./asreview/webapp/package.json ./ +COPY ./asreview/webapp/package-lock.json ./ +# Silent clean install of npm +RUN npm ci --silent +# add app folders +COPY ./asreview/webapp/src/ ./src/ +COPY ./asreview/webapp/public/ ./public/ +# create an .env file with backend-url in it +ARG API_URL +# Build for production +RUN REACT_APP_API_URL=${API_URL} \ + npm run build + +# second stage: create nginx container with front-end +# in it +FROM nginx:alpine +ARG API_URL +COPY --from=builder /app/build /usr/share/nginx/html +COPY ./Docker/auth_verified/asreview.conf /etc/nginx/conf.d/default.conf diff --git a/Docker/auth_verified/asreview.conf b/Docker/auth_verified/asreview.conf new file mode 100644 index 000000000..1c3682204 --- /dev/null +++ b/Docker/auth_verified/asreview.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + error_page 500 502 503 504 /50x.html; + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } + + location /static { + expires 1y; + add_header Cache-Control "public"; + } +} \ No newline at end of file diff --git a/Docker/auth_verified/docker-compose.yml b/Docker/auth_verified/docker-compose.yml new file mode 100644 index 000000000..13ab4ad11 --- /dev/null +++ b/Docker/auth_verified/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.9' +services: + + asreview_database: + container_name: asreview_database + image: postgres + restart: always + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_DB=${POSTGRES_DB} + ports: + - "${POSTGRES_EXTERNAL_PORT}:${POSTGRES_INTERNAL_PORT}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - auth_verified_database:/var/lib/postgresql/data + + backend: + build: + context: ../../ + dockerfile: ./Docker/auth_verified/Dockerfile_backend + args: + BACKEND_INTERNAL_PORT_ARG: ${BACKEND_INTERNAL_PORT} + EMAIL_PASSWORD: ${EMAIL_PASSWORD} + WORKERS: ${WORKERS} + SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@asreview_database:5432/${POSTGRES_DB}" + CREATE_TABLES: "asreview auth-tool create-db postgresql -u ${POSTGRES_USER} -p ${POSTGRES_PASSWORD} -n ${POSTGRES_DB} -H asreview_database" + ports: + - "${BACKEND_EXTERNAL_PORT}:${BACKEND_INTERNAL_PORT}" + depends_on: + asreview_database: + condition: service_healthy + volumes: + - auth_verified_project_folder:/app/project_folder + + frontend: + build: + context: ../../ + dockerfile: ./Docker/auth_verified/Dockerfile_frontend + args: + API_URL: http://localhost:${BACKEND_EXTERNAL_PORT}/ + ports: + - "${FRONTEND_EXTERNAL_PORT}:${FRONTEND_INTERNAL_PORT}" + +volumes: + auth_verified_project_folder: + auth_verified_database: diff --git a/Docker/auth_verified/flask_config.toml b/Docker/auth_verified/flask_config.toml new file mode 100644 index 000000000..81827e440 --- /dev/null +++ b/Docker/auth_verified/flask_config.toml @@ -0,0 +1,24 @@ +DEBUG = true +HOST = "0.0.0.0" +PORT = 5000 +AUTHENTICATION_ENABLED = true +ALLOWED_ORIGINS = ["http://localhost:8080", "http://127.0.0.1:8080"] +SECRET_KEY = "my_very_secret_key" +SECURITY_PASSWORD_SALT = "abCDefGH" +SESSION_COOKIE_SECURE = true +REMEMBER_COOKIE_SECURE = true +SESSION_COOKIE_SAMESITE = "Lax" +SQLALCHEMY_TRACK_MODIFICATIONS = true +ALLOW_ACCOUNT_CREATION = true +ALLOW_TEAMS = false +EMAIL_VERIFICATION = false +SQLALCHEMY_DATABASE_URI = "--SQLALCHEMY_DATABASE_URI--" + +[EMAIL_CONFIG] +SERVER = "smtp.sendgrid.net" +PORT = 465 +USERNAME = "apikey" +PASSWORD = "--EMAIL_PASSWORD--" +USE_TLS = false +USE_SSL = true +REPLY_ADDRESS = "casper@compunist.nl" diff --git a/Docker/auth_verified/wsgi.py b/Docker/auth_verified/wsgi.py new file mode 100644 index 000000000..99566d205 --- /dev/null +++ b/Docker/auth_verified/wsgi.py @@ -0,0 +1,3 @@ +from asreview.webapp.start_flask import create_app + +app = create_app() diff --git a/Dockerfile b/Docker/simple/Dockerfile similarity index 72% rename from Dockerfile rename to Docker/simple/Dockerfile index 60dbf5e96..f06e99b41 100644 --- a/Dockerfile +++ b/Docker/simple/Dockerfile @@ -1,26 +1,29 @@ # First stage -FROM python:3.8-slim AS builder +FROM python:3.11-slim AS builder WORKDIR /app # Copy and build asreview # git is used by versioneer to define the project version COPY . /app RUN apt-get update \ - && apt-get install -y git npm \ + && apt-get install -y git npm libpq-dev\ && pip3 install --upgrade pip setuptools \ && python3 setup.py compile_assets \ && pip3 install --user . \ && pip3 install --user asreview-datatools asreview-insights asreview-makita asreview-wordcloud # Second stage -FROM python:3.8-slim +FROM python:3.11-slim + +VOLUME /project_folder + WORKDIR /app COPY --from=builder /root/.local /root/.local ENV ASREVIEW_HOST=0.0.0.0 ENV PATH=/root/.local/bin:$PATH -ENV ASREVIEW_PATH=/app/project_folder +ENV ASREVIEW_PATH=/project_folder EXPOSE 5000 -ENTRYPOINT ["asreview"] +ENTRYPOINT ["asreview", "lab"] diff --git a/asreview/entry_points/auth_tool.py b/asreview/entry_points/auth_tool.py index 1d624ca78..5ea466727 100644 --- a/asreview/entry_points/auth_tool.py +++ b/asreview/entry_points/auth_tool.py @@ -7,29 +7,97 @@ from sqlalchemy import create_engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker +from sqlalchemy_utils import create_database +from sqlalchemy_utils import database_exists from asreview.entry_points.base import BaseEntryPoint from asreview.project import ASReviewProject from asreview.utils import asreview_path from asreview.webapp.authentication.models import Project from asreview.webapp.authentication.models import User +from asreview.webapp.authentication.models import create_database_and_tables def auth_parser(): parser = argparse.ArgumentParser( - prog="auth_converter", - description="""ASReview Authentication Conversion - convert your app to handle multiple users.""", # noqa + prog="auth-tool", + description=""" +Tool to create and fill a database to authenticate the ASReview application. +The tool can be used to convert existing project data to be used in an +authenticated setup. + """, formatter_class=RawTextHelpFormatter, epilog="Use -h or --help on all subcommands to view the available options.", ) sub_parser = parser.add_subparsers(help="The following options are available:") + create_db_par = sub_parser.add_parser( + "create-db", + help="Create the database necessary to authenticate the ASReview app." + ) + + subparser = create_db_par.add_subparsers(dest="db_type", required=True) + + sqlite = subparser.add_parser("sqlite3") + sqlite.add_argument( + "-p", + "--db-path", + type=str, + help="Absolute path of folder where Sqlite3 database must be created", + required=True + ) + sqlite.add_argument( + "-n", + "--db-name", + type=str, + default="asreview.sqlite3", + help="Name of the Sqlite3 database (used as filename)", + required=True + ) + + postgres = subparser.add_parser("postgresql") + postgres.add_argument( + "-n", + "--db-name", + type=str, + help="Name of the PostgreSQL database", + default="asreview", + required=True, + ) + postgres.add_argument( + "-u", + "--username", + type=str, + default="root", + help="User name if PostgreSQL requires authentication", + ) + postgres.add_argument( + "-p", + "--password", + type=str, + help="Password if PostgreSQL requires authentication", + ) + postgres.add_argument( + "-H", + "--host", + type=str, + help="Host URL of database", + default="127.0.0.1", + ) + postgres.add_argument( + "-P", + "--port", + type=int, + help="Port of database", + default=5432, + ) + user_par = sub_parser.add_parser("add-users", help="Add users into the database.") user_par.add_argument( "-d", - "--db-path", + "--db-uri", type=str, help="Absolute path to authentication sqlite3 database.", required=True, @@ -49,7 +117,7 @@ def auth_parser(): list_users_par.add_argument( "-d", - "--db-path", + "--db-uri", type=str, help="Absolute path to authentication sqlite3 database.", required=True, @@ -79,7 +147,7 @@ def auth_parser(): link_par.add_argument( "-d", - "--db-path", + "--db-uri", type=str, help="Absolute path to authentication sqlite3 database.", required=True, @@ -154,13 +222,15 @@ def execute(self, argv): self.argv = argv # create a conn object for the database - if hasattr(self.args, "db_path") and self.args.db_path is not None: + if hasattr(self.args, "db_uri") and self.args.db_uri is not None: Session = sessionmaker() - engine = create_engine(f"sqlite:///{self.args.db_path}") + engine = create_engine(self.args.db_uri) Session.configure(bind=engine) self.session = Session() - if "add-users" in argv: + if "create-db" in argv: + self.create_database() + elif "add-users" in argv: self.add_users() elif "list-users" in argv: self.list_users() @@ -169,6 +239,27 @@ def execute(self, argv): elif "link-projects" in argv: self.link_projects() + def create_database(self): + if self.args.db_type == "sqlite3": + uri = f"sqlite:///{self.args.db_path}/{self.args.db_name}" + elif self.args.db_type == "postgresql": + username = self.args.username + host = self.args.host + port = self.args.port + db_name = self.args.db_name + password = f":{self.args.password}" if self.args.password else "" + uri = f"postgresql+psycopg2://{username}{password}@{host}:{port}/{db_name}" + + # create the database if necessary + if not database_exists(uri): + create_database(uri) + else: + # open for more database types + uri = "" + engine = create_engine(uri) + create_database_and_tables(engine) + print(f"...Database created, URI: {uri}") + def add_users(self): if self.args.json is not None: entries = json.loads(self.args.json) diff --git a/asreview/webapp/authentication/models.py b/asreview/webapp/authentication/models.py index 0cffdee1f..0698493e9 100644 --- a/asreview/webapp/authentication/models.py +++ b/asreview/webapp/authentication/models.py @@ -49,10 +49,10 @@ class User(UserMixin, DB.Model): email = Column(String(100), unique=True) name = Column(String(100)) affiliation = Column(String(100)) - hashed_password = Column(String(100)) + hashed_password = Column(String(150)) confirmed = Column(Boolean) public = Column(Boolean) - token = Column(String(50)) + token = Column(String(150)) token_created_at = Column(DateTime) projects = relationship("Project", back_populates="owner", cascade="all, delete") @@ -221,7 +221,13 @@ class Collaboration(DB.Model): nullable=False ) # make sure we have unique records in this table - __table_args__ = (UniqueConstraint("project_id", "user_id", name="unique_records"),) + __table_args__ = ( + UniqueConstraint( + "project_id", + "user_id", + name="unique_records_collaboration" + ), + ) def __repr__(self): return f"" @@ -277,9 +283,20 @@ class CollaborationInvitation(DB.Model): nullable=False ) # make sure we have unique records in this table - __table_args__ = (UniqueConstraint("project_id", "user_id", name="unique_records"),) + __table_args__ = ( + UniqueConstraint( + "project_id", + "user_id", + name="unique_records_invitations" + ), + ) def __repr__(self): pid = self.project_id uid = self.user_id return f"" + + +def create_database_and_tables(engine): + """Creating database and tables with engine""" + DB.Model.metadata.create_all(engine) diff --git a/asreview/webapp/src/App.js b/asreview/webapp/src/App.js index 82469ec5a..f37d6ad85 100644 --- a/asreview/webapp/src/App.js +++ b/asreview/webapp/src/App.js @@ -58,6 +58,7 @@ const App = (props) => { const allowAccountCreation = useSelector( (state) => state.allow_account_creation ); + const emailConfig = useSelector((state) => state.email_config); const emailVerification = useSelector((state) => state.email_verification); // Snackbar Notification (taking care of self closing @@ -150,26 +151,34 @@ const App = (props) => { path="/oauth_callback" element={} /> - - } - /> - } /> - } - /> - {emailVerification && ( + {emailConfig && emailVerification && ( } + element={} /> )} + {emailConfig && ( + <> + + } + /> + + } + /> + + )} ); }; diff --git a/asreview/webapp/src/Components/ConfirmAccount.js b/asreview/webapp/src/Components/ConfirmAccount.js index 3aace3043..16fca3cce 100644 --- a/asreview/webapp/src/Components/ConfirmAccount.js +++ b/asreview/webapp/src/Components/ConfirmAccount.js @@ -3,31 +3,30 @@ import { useSearchParams, useNavigate } from "react-router-dom"; import { InlineErrorHandler } from "."; import { AuthAPI } from "../api/index.js"; -const ConfirmAccount = () => { +const ConfirmAccount = ({ showNotification }) => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [errorMessage, setErrorMessage] = React.useState(false); + const [errorMessage] = React.useState(false); // This effect does a boot request to gather information // from the backend React.useEffect(() => { let userId = searchParams.get("user_id"); let token = searchParams.get("token"); - console.log(userId, token); AuthAPI.confirmAccount({ userId: userId, token: token, }) .then((response) => { + showNotification("Your account has been confirmed. Please sign in."); navigate("/signin"); }) .catch((err) => { - // I'd like to have a flash! + showNotification("Your account could not be confirmed!", "error"); console.log(err); - setErrorMessage("Could not confirm account: " + err.message); }); - }, [navigate, searchParams]); + }, [navigate, searchParams, showNotification]); return (
diff --git a/asreview/webapp/src/Components/ResetPassword.js b/asreview/webapp/src/Components/ResetPassword.js index f3e8caaea..5b999dc84 100644 --- a/asreview/webapp/src/Components/ResetPassword.js +++ b/asreview/webapp/src/Components/ResetPassword.js @@ -71,7 +71,7 @@ const SignupSchema = Yup.object().shape({ password: Yup.string() .matches( /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/, - "Use 8 or more characters with a mix of letters, numbers & symbols", + "Use 8 or more characters with a mix of letters, numbers & symbols" ) .required("Password is required"), confirmPassword: Yup.string() @@ -107,10 +107,17 @@ const ResetPassword = (props) => { }, onSuccess: (data) => { formik.setValues(initialValues, false); - navigate("/sigin"); + props.showNotification( + "Your password has been reset. Please sign in again." + ); + navigate("/signin"); }, onError: (data) => { setErrorMessage(data.message); + props.showNotification( + "Your password has not been reset! PLease contact your administrator.", + "error" + ); console.error("Reset password error", data); }, }); @@ -120,7 +127,6 @@ const ResetPassword = (props) => { let userId = searchParams.get("user_id"); let token = searchParams.get("token"); let password = formik.values.password; - console.log(userId, token, password); mutate({ userId, token, password }); //reset(); }; diff --git a/asreview/webapp/src/Components/SignIn.js b/asreview/webapp/src/Components/SignIn.js index 82007e716..1ea6d428c 100644 --- a/asreview/webapp/src/Components/SignIn.js +++ b/asreview/webapp/src/Components/SignIn.js @@ -55,6 +55,8 @@ const SignIn = () => { const oAuthData = useSelector((state) => state.oAuthData); const allowAccountCreation = useSelector((state) => state.allow_account_creation) || false; + const emailConfig = + useSelector((state) => state.email_config) || false; return ( @@ -74,6 +76,7 @@ const SignIn = () => { {Object.keys(oAuthData.services).length > 0 && ( diff --git a/asreview/webapp/src/Components/SignInForm.js b/asreview/webapp/src/Components/SignInForm.js index a54bac5b0..a697ef017 100644 --- a/asreview/webapp/src/Components/SignInForm.js +++ b/asreview/webapp/src/Components/SignInForm.js @@ -19,6 +19,7 @@ import { useToggle } from "../hooks/useToggle"; const SignInForm = (props) => { const classes = props.classes; const allowAccountCreation = props.allowAccountCreation; + const hasEmailConfig = props.emailConfig; const queryClient = useQueryClient(); const location = useLocation(); @@ -137,9 +138,11 @@ const SignInForm = (props) => { )} - + {hasEmailConfig && ( + + )} { - console.log(result); resolve(result["data"]); }) .catch((error) => { diff --git a/asreview/webapp/src/globals.js b/asreview/webapp/src/globals.js index c373a640a..070142f7d 100644 --- a/asreview/webapp/src/globals.js +++ b/asreview/webapp/src/globals.js @@ -7,7 +7,7 @@ import ASReviewLAB_white from "./images/asreview_sub_logo_lab_white_transparent. // URL of backend is configured in an .env file. By default it's // the same as the front-end URL. let b_url = "/"; -if ((process.env.NODE_ENV !== 'production') && Boolean(process.env.REACT_APP_API_URL)) { +if (Boolean(process.env.REACT_APP_API_URL)) { b_url = process.env.REACT_APP_API_URL; } export const base_url = b_url; @@ -25,7 +25,7 @@ export const feedbackURL = export const discussionsURL = "https://github.com/asreview/asreview/discussions"; -export const getDesignTokens = (mode: PaletteMode) => ({ +export const getDesignTokens = (mode) => ({ palette: { mode, ...(mode === "light" diff --git a/asreview/webapp/start_flask.py b/asreview/webapp/start_flask.py index 15e8a1eaa..b51c41de5 100644 --- a/asreview/webapp/start_flask.py +++ b/asreview/webapp/start_flask.py @@ -168,6 +168,13 @@ def _lab_parser(): help="Enable authentication.", ) + parser.add_argument( + "--auth-database-uri", + default=None, + type=str, + help="URI of authentication database.", + ) + parser.add_argument( "--secret-key", default=None, @@ -263,14 +270,21 @@ def create_app(**kwargs): app.config["HOST"] = kwargs.get("host") # Read config parameters if possible, this overrides - # the previous assignments. - config_file_path = kwargs.get("flask_configfile", "").strip() + # the previous assignments. Flask config parameters may come + # as an environment var or from an argument. Argument + # takes precedence. + config_from_env = os.environ.get("FLASK_CONFIGFILE", "").strip() + config_from_arg = kwargs.get("flask_configfile", "").strip() + config_file_path = config_from_arg or config_from_env + # Use absolute path, because otherwise it is relative to the config root. if config_file_path != "": config_file_path = Path(config_file_path) if config_file_path.suffix == ".toml": app.config.from_file( - config_file_path.absolute(), load=tomllib.load, text=False + config_file_path.absolute(), + load=tomllib.load, + text=False ) else: raise ValueError("'flask_configfile' should have a .toml extension") @@ -349,21 +363,32 @@ def load_user(user_id): app.config["MAIL_USE_SSL"] = conf.get("USE_SSL", False) app.config["MAIL_REPLY_ADDRESS"] = conf.get("REPLY_ADDRESS") - # We must be sure we have a database URI + # We must be sure we have a SQLAlchemy database URI. At this + # stage the TOML file has been read. See if we haven't found + # such a URI. if not app.config.get("SQLALCHEMY_DATABASE_URI", False): - # create default path - uri = os.path.join(asreview_path(), f"asreview.{env}.sqlite") - app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{uri}" + # there is no configuration, check CLI parameters + cli_database_uri = (kwargs.get("auth_database_uri") or "").strip() + + # if we still haven't found a database URI, create a sqlite3 database + if cli_database_uri != "": + app.config["SQLALCHEMY_DATABASE_URI"] = cli_database_uri + else: + # create default path + uri = os.path.join(asreview_path(), f"asreview.{env}.sqlite") + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{uri}" + + # initialize app for SQLAlchemy + DB.init_app(app) + + with app.app_context(): + # create tables in case they don't exist + DB.create_all() # store oauth config in oauth handler if bool(app.config.get("OAUTH", False)): app.config["OAUTH"] = OAuthHandler(app.config["OAUTH"]) - # create the database plus table(s) - DB.init_app(app) - with app.app_context(): - DB.create_all() - # Ensure the instance folder exists. try: os.makedirs(app.instance_path) diff --git a/asreview/webapp/tests/README.md b/asreview/webapp/tests/README.md index 9641a7267..c9bada545 100644 --- a/asreview/webapp/tests/README.md +++ b/asreview/webapp/tests/README.md @@ -2,7 +2,7 @@ This folder contains the test suite of the ASReview app. It is organized in the following folders: -- **config**: Contains data to create user accounts and a number of json files to start the ASReview app in different modes (e.g. authenticated, authentication with verification, etc). +- **config**: Contains data to create user accounts and a number of TOML files to start the ASReview app in different modes (e.g. authenticated, authentication with verification, etc). - **data**: Contains project data used in tests. @@ -58,3 +58,7 @@ If you are in the middle of writing your tests, and your module contains many te ``` pytest --random-order -s -v ./asreview/webapp/tests/test_api/test_projects.py -k current ``` + +## Database + +A database is needed to run the tests for authenticated versions of the app. In the `config` file a number of TOML configuration files exist that are used to create different versions of the app. The configuration files that create an authenticated version of the app lack the `SQLALCHEMY_DATABASE_URI` parameter! That parameter tells the backend where it can find the database. If that parameter is missing, the app will create and initialize a sqlite3 database automatically. After the test suite has been executed, this database will be removed. In case you would like to run all tests in a specific database, you need to provide the URI of the database yourself. diff --git a/setup.py b/setup.py index a9e34ab31..9c991a2bf 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ def get_long_description(): "flask_cors", "flask-login", "flask-mail", + "Werkzeug==2.3.0", "openpyxl", "jsonschema", "filelock", @@ -63,7 +64,9 @@ def get_long_description(): "tqdm", "gevent>=20", "datahugger>=0.2", - "synergy_dataset" + "synergy_dataset", + "psycopg2", + "sqlalchemy-utils", ] if sys.version_info < (3, 11): @@ -154,6 +157,7 @@ def get_cmdclass(): "asreview": [ "webapp/build/*", "webapp/build/static/*/*", + "webapp/templates/emails/*", ] }, python_requires="~=3.8",