├── LICENSE ├── README.md ├── backend ├── .env ├── .gitignore ├── Dockerfile ├── app │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── items.py │ │ ├── login.py │ │ ├── users.py │ │ └── utils.py │ ├── auth.py │ ├── config.py │ ├── crud │ │ ├── __init__.py │ │ ├── item.py │ │ └── user.py │ ├── db.py │ ├── email-templates │ │ ├── build │ │ │ ├── new_account.html │ │ │ ├── reset_password.html │ │ │ └── test_email.html │ │ └── src │ │ │ ├── new_account.mjml │ │ │ ├── reset_password.mjml │ │ │ └── test_email.mjml │ ├── initial_data.py │ ├── main.py │ ├── schemas.py │ ├── security.py │ └── utils.py ├── dbschema │ └── database.esdl ├── mypy.ini ├── pyproject.toml ├── requirements.txt └── scripts │ ├── format.sh │ └── lint.sh ├── docker-compose.yml └── frontend ├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── babel.config.js ├── nginx-backend-not-found.conf ├── package.json ├── public ├── favicon.ico ├── img │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── msapplication-icon-144x144.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.vue ├── api.ts ├── assets │ └── logo.png ├── component-hooks.ts ├── components │ ├── NotificationsManager.vue │ ├── RouterComponent.vue │ └── UploadButton.vue ├── env.ts ├── interfaces │ └── index.ts ├── main.ts ├── plugins │ ├── vee-validate.ts │ └── vuetify.ts ├── registerServiceWorker.ts ├── router.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts ├── store │ ├── admin │ │ ├── actions.ts │ │ ├── getters.ts │ │ ├── index.ts │ │ ├── mutations.ts │ │ └── state.ts │ ├── index.ts │ ├── main │ │ ├── actions.ts │ │ ├── getters.ts │ │ ├── index.ts │ │ ├── mutations.ts │ │ └── state.ts │ └── state.ts ├── utils.ts └── views │ ├── Login.vue │ ├── PasswordRecovery.vue │ ├── ResetPassword.vue │ └── main │ ├── Dashboard.vue │ ├── Main.vue │ ├── Start.vue │ ├── admin │ ├── Admin.vue │ ├── AdminUsers.vue │ ├── CreateUser.vue │ └── EditUser.vue │ └── profile │ ├── UserProfile.vue │ ├── UserProfileEdit.vue │ └── UserProfileEditPassword.vue ├── tests └── unit │ └── upload-button.spec.ts ├── tsconfig.json ├── tslint.json └── vue.config.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kurt Rottmann 4 | Copyright (c) 2019 Sebastián Ramírez 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simple-stack-fastapi-edgedb 2 | 3 | This is an alternative version of [full-stack-fastapi-postgresql](https://github.com/tiangolo/full-stack-fastapi-postgresql) but using [EdgeDB](https://github.com/edgedb/edgedb). 4 | 5 | SQLALchemy ORM was replaced by async queries using the EdgeDB Python driver. 6 | 7 | I tried to simplify the backend folder structure and also removed Cookiecutter, Traefik, Celery, PGAdmin and Sentry related stuff. 8 | 9 | The frontend is the same as the original project except for a little change to work with UUIDs. 10 | 11 | ## Instructions 12 | 13 | Clone the repository: 14 | 15 | ```bash 16 | git clone https://github.com/kurtrottmann/simple-stack-fastapi-edgedb.git 17 | cd simple-stack-fastapi-edgedb 18 | ``` 19 | 20 | Start the development stack with Docker Compose: 21 | 22 | ```bash 23 | docker-compose up -d 24 | ``` 25 | 26 | To check if the backend and database is up: 27 | 28 | ```bash 29 | docker-compose logs backend 30 | ``` 31 | 32 | Then you can access to: 33 | 34 | - Interactive API Documentation - Swagger: http://localhost:8000/docs 35 | - Alternative API Documentation - ReDoc: http://localhost:8000/redoc 36 | - Frontend: http://localhost:8080 37 | 38 | Default credentials: 39 | 40 | - Username: admin@example.com 41 | - Password: changethis 42 | 43 | If you have [EdgeDB CLI](https://www.edgedb.com/download) installed, you can access the containerized DB with: 44 | ```bash 45 | edgedb -u edgedb 46 | ``` 47 | 48 | ### Pagination 49 | 50 | For pagination use the `offset` and `limit` query parameters. Example: 51 | 52 | ```bash 53 | http://localhost:8000/api/v1/users/?offset=20&limit=1000 54 | ``` 55 | 56 | ### Ordering 57 | 58 | The query parameter is `ordering` and the allowed order fields are defined in `schemas.py`. You can specify multiple order fields separated by commas. For reverse ordering, prefix the field name with '-'. Example: 59 | 60 | ```bash 61 | http://localhost:8000/api/v1/users/?ordering=email,-num_items 62 | ``` 63 | 64 | ### Filtering 65 | 66 | The allowed filter fields are defined in `schemas.py`. You can specify nested filtering fields using the '__' separator. Example: 67 | 68 | ```bash 69 | http://localhost:8000/api/v1/items/?owner__email=admin@example.com 70 | ``` 71 | 72 | ## Changelog 73 | 74 | ### 0.2 75 | 76 | - Add Docker Compose. 77 | - Add REST API ordering. 78 | - Add REST API filtering. 79 | - Update to EdgeDB 1.0 Alpha 6. 80 | 81 | ### 0.1 82 | 83 | - Initial Release 84 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | # Backend 2 | BACKEND_CORS_ORIGINS=["http://localhost", "http://localhost:8080", "https://localhost", "https://localhost:8080", "http://dev.example.com", "https://stag.example.com", "https://example.com"] 3 | PROJECT_NAME=Example 4 | SECRET_KEY=changethis 5 | FIRST_SUPERUSER=admin@example.com 6 | FIRST_SUPERUSER_PASSWORD=changethis 7 | SMTP_TLS=True 8 | SMTP_PORT=587 9 | SMTP_HOST= 10 | SMTP_USER= 11 | SMTP_PASSWORD= 12 | EMAILS_FROM_EMAIL=info@example.com 13 | EMAILS_SERVER_HOST=https://localhost 14 | 15 | USERS_OPEN_REGISTRATION=False 16 | 17 | # EdgeDB 18 | EDGEDB_HOST=db 19 | EDGEDB_USER=edgedb 20 | EDGEDB_PASSWORD= 21 | EDGEDB_DB=edgedb 22 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .coverage 3 | .mypy_cache 4 | .pytest_cache 5 | htmlcov 6 | poetry.lock 7 | venv 8 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | WORKDIR /app 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | COPY . /app 9 | 10 | RUN pip install --no-cache-dir -U pip 11 | RUN pip install --no-cache-dir -r /app/requirements.txt 12 | -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/backend/app/__init__.py -------------------------------------------------------------------------------- /backend/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from . import items, login, users, utils 4 | 5 | api_router = APIRouter() 6 | api_router.include_router(login.router, tags=["login"]) 7 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 8 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) 9 | api_router.include_router(items.router, prefix="/items", tags=["items"]) 10 | -------------------------------------------------------------------------------- /backend/app/api/items.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from uuid import UUID 3 | 4 | from edgedb import AsyncIOConnection 5 | from fastapi import APIRouter, Depends, HTTPException 6 | 7 | from app import auth, crud, db, schemas 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/", response_model=schemas.PaginatedItems) 13 | async def read_items( 14 | con: AsyncIOConnection = Depends(db.get_con), 15 | filtering: schemas.ItemFilterParams = Depends(), 16 | commons: schemas.CommonQueryParams = Depends(), 17 | current_user: schemas.User = Depends(auth.get_current_active_user), 18 | ) -> Any: 19 | """ 20 | Retrieve items. 21 | """ 22 | if not current_user.is_superuser: 23 | filtering.owner__id = current_user.id 24 | items = await crud.item.get_multi( 25 | con, 26 | filtering=filtering.dict_exclude_unset(), 27 | ordering=commons.ordering, 28 | offset=commons.offset, 29 | limit=commons.limit, 30 | ) 31 | return items 32 | 33 | 34 | @router.post("/", response_model=schemas.Item, status_code=201) 35 | async def create_item( 36 | *, 37 | con: AsyncIOConnection = Depends(db.get_con), 38 | item_in: schemas.ItemCreate, 39 | current_user: schemas.User = Depends(auth.get_current_active_user), 40 | ) -> Any: 41 | """ 42 | Create new item. 43 | """ 44 | item = await crud.item.create(con, obj_in=item_in, owner_id=current_user.id) 45 | return item 46 | 47 | 48 | @router.put("/{item_id}", response_model=schemas.Item) 49 | async def update_item( 50 | *, 51 | con: AsyncIOConnection = Depends(db.get_con), 52 | item_id: UUID, 53 | item_in: schemas.ItemUpdate, 54 | current_user: schemas.User = Depends(auth.get_current_active_user), 55 | ) -> Any: 56 | """ 57 | Update an item. 58 | """ 59 | item = await crud.item.get(con, id=item_id) 60 | if not item: 61 | raise HTTPException(status_code=404, detail="Item not found") 62 | if not current_user.is_superuser and (item.owner.id != current_user.id): 63 | raise HTTPException(status_code=400, detail="Not enough permissions") 64 | item = await crud.item.update(con, id=item_id, obj_in=item_in) 65 | return item 66 | 67 | 68 | @router.get("/{item_id}", response_model=schemas.Item) 69 | async def read_item( 70 | *, 71 | con: AsyncIOConnection = Depends(db.get_con), 72 | item_id: UUID, 73 | current_user: schemas.User = Depends(auth.get_current_active_user), 74 | ) -> Any: 75 | """ 76 | Get item by id. 77 | """ 78 | item = await crud.item.get(con, id=item_id) 79 | if not item: 80 | raise HTTPException(status_code=404, detail="Item not found") 81 | if not current_user.is_superuser and (item.owner.id != current_user.id): 82 | raise HTTPException(status_code=400, detail="Not enough permissions") 83 | return item 84 | 85 | 86 | @router.delete("/{item_id}", response_model=schemas.Item) 87 | async def delete_item( 88 | *, 89 | con: AsyncIOConnection = Depends(db.get_con), 90 | item_id: UUID, 91 | current_user: schemas.User = Depends(auth.get_current_active_user), 92 | ) -> Any: 93 | """ 94 | Delete an item. 95 | """ 96 | item = await crud.item.get(con, id=item_id) 97 | if not item: 98 | raise HTTPException(status_code=404, detail="Item not found") 99 | if not current_user.is_superuser and (item.owner.id != current_user.id): 100 | raise HTTPException(status_code=400, detail="Not enough permissions") 101 | item = await crud.item.remove(con, id=item_id) 102 | return item 103 | -------------------------------------------------------------------------------- /backend/app/api/login.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Any 3 | 4 | from edgedb import AsyncIOConnection 5 | from fastapi import APIRouter, Body, Depends, HTTPException 6 | from fastapi.security import OAuth2PasswordRequestForm 7 | 8 | from app import auth, crud, db, schemas 9 | from app.config import settings 10 | from app.security import ( 11 | create_access_token, 12 | generate_password_reset_token, 13 | get_password_hash, 14 | verify_password_reset_token, 15 | ) 16 | from app.utils import send_reset_password_email 17 | 18 | router = APIRouter() 19 | 20 | 21 | @router.post("/login/access-token", response_model=schemas.Token) 22 | async def login_access_token( 23 | con: AsyncIOConnection = Depends(db.get_con), 24 | form_data: OAuth2PasswordRequestForm = Depends(), 25 | ) -> Any: 26 | """ 27 | OAuth2 compatible token login, get an access token for future requests 28 | """ 29 | user = await crud.user.authenticate( 30 | con, email=form_data.username, password=form_data.password 31 | ) 32 | if not user: 33 | raise HTTPException(status_code=400, detail="Incorrect email or password") 34 | elif not user.is_active: 35 | raise HTTPException(status_code=400, detail="Inactive user") 36 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 37 | token = schemas.Token( 38 | access_token=create_access_token(user.id, expires_delta=access_token_expires), 39 | token_type="bearer", 40 | ) 41 | return token 42 | 43 | 44 | @router.post("/login/test-token", response_model=schemas.User) 45 | async def test_token( 46 | current_user: schemas.User = Depends(auth.get_current_user), 47 | ) -> Any: 48 | """ 49 | Test access token 50 | """ 51 | return current_user 52 | 53 | 54 | @router.post("/password-recovery/{email}", response_model=schemas.Msg) 55 | async def recover_password( 56 | email: str, con: AsyncIOConnection = Depends(db.get_con) 57 | ) -> Any: 58 | """ 59 | Password Recovery 60 | """ 61 | user = await crud.user.get_by_email(con, email=email) 62 | if not user: 63 | raise HTTPException( 64 | status_code=404, 65 | detail="The user with this username does not exist in the system.", 66 | ) 67 | password_reset_token = generate_password_reset_token(email=email) 68 | send_reset_password_email( 69 | email_to=user.email, email=email, token=password_reset_token 70 | ) 71 | msg = schemas.Msg(msg="Password recovery email sent") 72 | return msg 73 | 74 | 75 | @router.post("/reset-password/", response_model=schemas.Msg) 76 | async def reset_password( 77 | token: str = Body(...), 78 | new_password: str = Body(...), 79 | con: AsyncIOConnection = Depends(db.get_con), 80 | ) -> Any: 81 | """ 82 | Reset password 83 | """ 84 | email = verify_password_reset_token(token) 85 | if not email: 86 | raise HTTPException(status_code=400, detail="Invalid token") 87 | user = await crud.user.get_by_email(con, email=email) 88 | if not user: 89 | raise HTTPException( 90 | status_code=404, 91 | detail="The user with this username does not exist in the system.", 92 | ) 93 | elif not user.is_active: 94 | raise HTTPException(status_code=400, detail="Inactive user") 95 | hashed_password = get_password_hash(new_password) 96 | user_in = schemas.UserUpdate(hashed_password=hashed_password) 97 | await crud.user.update(con, id=user.id, obj_in=user_in) 98 | msg = schemas.Msg(msg="Password updated successfully") 99 | return msg 100 | -------------------------------------------------------------------------------- /backend/app/api/users.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from uuid import UUID 3 | 4 | from edgedb import AsyncIOConnection 5 | from fastapi import APIRouter, Body, Depends, HTTPException 6 | from fastapi.encoders import jsonable_encoder 7 | from pydantic.networks import EmailStr 8 | 9 | from app import auth, crud, db, schemas 10 | from app.config import settings 11 | from app.utils import send_new_account_email 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get("/", response_model=schemas.PaginatedUsers) 17 | async def read_users( 18 | con: AsyncIOConnection = Depends(db.get_con), 19 | filtering: schemas.UserFilterParams = Depends(), 20 | commons: schemas.CommonQueryParams = Depends(), 21 | current_user: schemas.User = Depends(auth.get_current_active_superuser), 22 | ) -> Any: 23 | """ 24 | Retrieve users. 25 | """ 26 | paginated_users = await crud.user.get_multi( 27 | con, 28 | filtering=filtering.dict_exclude_unset(), 29 | ordering=commons.ordering, 30 | offset=commons.offset, 31 | limit=commons.limit, 32 | ) 33 | return paginated_users 34 | 35 | 36 | @router.post("/", response_model=schemas.User, status_code=201) 37 | async def create_user( 38 | *, 39 | con: AsyncIOConnection = Depends(db.get_con), 40 | user_in: schemas.UserCreate, 41 | current_user: schemas.User = Depends(auth.get_current_active_superuser), 42 | ) -> Any: 43 | """ 44 | Create new user. 45 | """ 46 | user = await crud.user.get_by_email(con, email=user_in.email) 47 | if user: 48 | raise HTTPException( 49 | status_code=400, 50 | detail="The user with this username already exists in the system.", 51 | ) 52 | user = await crud.user.create(con, obj_in=user_in) 53 | if settings.EMAILS_ENABLED and user_in.email: 54 | send_new_account_email( 55 | email_to=user_in.email, username=user_in.email, password=user_in.password 56 | ) 57 | return user 58 | 59 | 60 | @router.put("/me", response_model=schemas.User) 61 | async def update_user_me( 62 | *, 63 | con: AsyncIOConnection = Depends(db.get_con), 64 | password: str = Body(None), 65 | full_name: str = Body(None), 66 | email: EmailStr = Body(None), 67 | current_user: schemas.User = Depends(auth.get_current_active_user), 68 | ) -> Any: 69 | """ 70 | Update own user. 71 | """ 72 | current_user_data = jsonable_encoder(current_user) 73 | user_in = schemas.UserUpdate(**current_user_data) 74 | if password is not None: 75 | user_in.password = password 76 | if full_name is not None: 77 | user_in.full_name = full_name 78 | if email is not None: 79 | user_in.email = email 80 | user = await crud.user.update(con, id=current_user.id, obj_in=user_in) 81 | return user 82 | 83 | 84 | @router.get("/me", response_model=schemas.User) 85 | async def read_user_me( 86 | con: AsyncIOConnection = Depends(db.get_con), 87 | current_user: schemas.User = Depends(auth.get_current_active_user), 88 | ) -> Any: 89 | """ 90 | Get current user. 91 | """ 92 | return current_user 93 | 94 | 95 | @router.post("/open", response_model=schemas.User, status_code=201) 96 | async def create_user_open( 97 | *, 98 | con: AsyncIOConnection = Depends(db.get_con), 99 | password: str = Body(...), 100 | email: EmailStr = Body(...), 101 | full_name: str = Body(None), 102 | ) -> Any: 103 | """ 104 | Create new user without the need to be logged in. 105 | """ 106 | if not settings.USERS_OPEN_REGISTRATION: 107 | raise HTTPException( 108 | status_code=403, 109 | detail="Open user registration is forbidden on this server", 110 | ) 111 | user = await crud.user.get_by_email(con, email=email) 112 | if user: 113 | raise HTTPException( 114 | status_code=400, 115 | detail="The user with this username already exists in the system", 116 | ) 117 | user_in = schemas.UserCreate(password=password, email=email, full_name=full_name) 118 | user = await crud.user.create(con, obj_in=user_in) 119 | return user 120 | 121 | 122 | @router.get("/{user_id}", response_model=schemas.User) 123 | async def read_user( 124 | *, 125 | con: AsyncIOConnection = Depends(db.get_con), 126 | user_id: UUID, 127 | current_user: schemas.User = Depends(auth.get_current_active_user), 128 | ) -> Any: 129 | """ 130 | Get user by id. 131 | """ 132 | user = await crud.user.get(con, id=user_id) 133 | if not user: 134 | raise HTTPException(status_code=404, detail="User not found") 135 | if user == current_user: 136 | return user 137 | if not current_user.is_superuser: 138 | raise HTTPException( 139 | status_code=400, detail="The user doesn't have enough privileges" 140 | ) 141 | return user 142 | 143 | 144 | @router.put("/{user_id}", response_model=schemas.User) 145 | async def update_user( 146 | *, 147 | con: AsyncIOConnection = Depends(db.get_con), 148 | user_id: UUID, 149 | user_in: schemas.UserUpdate, 150 | current_user: schemas.User = Depends(auth.get_current_active_user), 151 | ) -> Any: 152 | """ 153 | Update a user. 154 | """ 155 | user = await crud.user.get(con, id=user_id) 156 | if not user: 157 | raise HTTPException( 158 | status_code=404, 159 | detail="The user with this username does not exist in the system", 160 | ) 161 | user = await crud.user.update(con, id=user_id, obj_in=user_in) 162 | return user 163 | 164 | 165 | @router.delete("/{user_id}", response_model=schemas.User) 166 | async def delete_user( 167 | *, 168 | con: AsyncIOConnection = Depends(db.get_con), 169 | user_id: UUID, 170 | current_user: schemas.User = Depends(auth.get_current_active_user), 171 | ) -> Any: 172 | """ 173 | Delete a user. 174 | """ 175 | user = await crud.user.get(con, id=user_id) 176 | if not user: 177 | raise HTTPException(status_code=404, detail="User not found") 178 | if not current_user.is_superuser and (user.id != current_user.id): 179 | raise HTTPException(status_code=400, detail="Not enough permissions") 180 | user = await crud.user.remove(con, id=user_id) 181 | return user 182 | -------------------------------------------------------------------------------- /backend/app/api/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter, Depends 4 | from pydantic.networks import EmailStr 5 | 6 | from app import auth, schemas 7 | from app.utils import send_test_email 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.post("/test-email/", response_model=schemas.Msg, status_code=201) 13 | def test_email( 14 | email_to: EmailStr, 15 | current_user: schemas.User = Depends(auth.get_current_active_superuser), 16 | ) -> Any: 17 | """ 18 | Test emails. 19 | """ 20 | send_test_email(email_to=email_to) 21 | msg = schemas.Msg(msg="Test email sent") 22 | return msg 23 | -------------------------------------------------------------------------------- /backend/app/auth.py: -------------------------------------------------------------------------------- 1 | from edgedb import AsyncIOConnection 2 | from fastapi import Depends, HTTPException, status 3 | from fastapi.security import OAuth2PasswordBearer 4 | from jose import jwt 5 | from pydantic import ValidationError 6 | 7 | from . import crud, db, schemas, security 8 | from .config import settings 9 | 10 | reusable_oauth2 = OAuth2PasswordBearer( 11 | tokenUrl=f"{settings.API_V1_STR}/login/access-token" 12 | ) 13 | 14 | 15 | async def get_current_user( 16 | con: AsyncIOConnection = Depends(db.get_con), token: str = Depends(reusable_oauth2) 17 | ) -> schemas.User: 18 | try: 19 | payload = jwt.decode( 20 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] 21 | ) 22 | token_data = schemas.TokenPayload(**payload) 23 | except (jwt.JWTError, ValidationError): 24 | raise HTTPException( 25 | status_code=status.HTTP_403_FORBIDDEN, 26 | detail="Could not validate credentials", 27 | ) 28 | user = await crud.user.get(con, id=token_data.sub) 29 | if not user: 30 | raise HTTPException(status_code=404, detail="User not found") 31 | return user 32 | 33 | 34 | async def get_current_active_user( 35 | current_user: schemas.User = Depends(get_current_user), 36 | ) -> schemas.User: 37 | if not current_user.is_active: 38 | raise HTTPException(status_code=400, detail="Inactive user") 39 | return current_user 40 | 41 | 42 | async def get_current_active_superuser( 43 | current_user: schemas.User = Depends(get_current_active_user), 44 | ) -> schemas.User: 45 | if not current_user.is_superuser: 46 | raise HTTPException( 47 | status_code=400, detail="The user doesn't have enough privileges" 48 | ) 49 | return current_user 50 | -------------------------------------------------------------------------------- /backend/app/config.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Any, Dict, List, Optional, Union 3 | 4 | from pydantic import AnyHttpUrl, BaseSettings, EmailStr, validator 5 | 6 | 7 | class Settings(BaseSettings): 8 | API_V1_STR: str = "/api/v1" 9 | SECRET_KEY: str = secrets.token_urlsafe(32) 10 | # 60 minutes * 24 hours * 8 days = 8 days 11 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 12 | # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins 13 | # e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000"]' 14 | BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] 15 | 16 | @validator("BACKEND_CORS_ORIGINS", pre=True) 17 | def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: 18 | if isinstance(v, str) and not v.startswith("["): 19 | return [i.strip() for i in v.split(",")] 20 | elif isinstance(v, (list, str)): 21 | return v 22 | raise ValueError(v) 23 | 24 | PROJECT_NAME: str 25 | 26 | EDGEDB_HOST: str 27 | EDGEDB_USER: str 28 | EDGEDB_PASSWORD: str 29 | EDGEDB_DB: str 30 | 31 | SMTP_TLS: bool = True 32 | SMTP_PORT: Optional[int] = None 33 | SMTP_HOST: Optional[str] = None 34 | SMTP_USER: Optional[str] = None 35 | SMTP_PASSWORD: Optional[str] = None 36 | EMAILS_FROM_EMAIL: Optional[EmailStr] = None 37 | EMAILS_FROM_NAME: Optional[str] = None 38 | EMAILS_SERVER_HOST: AnyHttpUrl 39 | 40 | @validator("EMAILS_FROM_NAME") 41 | def get_project_name(cls, v: Optional[str], values: Dict[str, Any]) -> str: 42 | if not v: 43 | return values["PROJECT_NAME"] 44 | return v 45 | 46 | EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 47 | EMAIL_TEMPLATES_DIR: str = "app/email-templates/build" 48 | EMAILS_ENABLED: bool = False 49 | 50 | @validator("EMAILS_ENABLED", pre=True) 51 | def get_emails_enabled(cls, v: bool, values: Dict[str, Any]) -> bool: 52 | return bool( 53 | values.get("SMTP_HOST") 54 | and values.get("SMTP_PORT") 55 | and values.get("EMAILS_FROM_EMAIL") 56 | ) 57 | 58 | EMAIL_TEST_USER: EmailStr = EmailStr("test@example.com") 59 | FIRST_SUPERUSER: EmailStr 60 | FIRST_SUPERUSER_PASSWORD: str 61 | USERS_OPEN_REGISTRATION: bool = False 62 | 63 | class Config: 64 | case_sensitive = True 65 | env_file = ".env" 66 | 67 | 68 | settings = Settings() 69 | -------------------------------------------------------------------------------- /backend/app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from . import item, user 2 | -------------------------------------------------------------------------------- /backend/app/crud/item.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | from uuid import UUID 3 | 4 | from edgedb import AsyncIOConnection, NoDataError 5 | from fastapi import HTTPException 6 | 7 | from app import utils 8 | from app.schemas import ( 9 | Item, 10 | ItemCreate, 11 | ItemUpdate, 12 | PaginatedItems, 13 | item_ordering_fields, 14 | ) 15 | 16 | 17 | async def get(con: AsyncIOConnection, *, id: UUID) -> Optional[Item]: 18 | try: 19 | result = await con.query_one_json( 20 | """SELECT Item { 21 | id, 22 | title, 23 | description, 24 | owner: { 25 | id, 26 | email, 27 | full_name 28 | } 29 | } 30 | FILTER .id = $id""", 31 | id=id, 32 | ) 33 | except NoDataError: 34 | return None 35 | except Exception as e: 36 | raise HTTPException(status_code=400, detail=f"{e}") 37 | item = Item.parse_raw(result) 38 | return item 39 | 40 | 41 | async def get_multi( 42 | con: AsyncIOConnection, 43 | *, 44 | filtering: Dict[str, Any] = {}, 45 | ordering: str = None, 46 | offset: int = 0, 47 | limit: int = 100, 48 | ) -> PaginatedItems: 49 | filter_expr = None 50 | order_expr = None 51 | if filtering: 52 | filter_expr = utils.get_filter(filtering) 53 | if ordering: 54 | order_expr = utils.get_order(ordering, item_ordering_fields) 55 | try: 56 | result = await con.query_one_json( 57 | f"""WITH items := ( 58 | SELECT Item 59 | FILTER {filter_expr or 'true'} 60 | ) 61 | SELECT ( 62 | count:= count(items), 63 | data := array_agg(( 64 | SELECT items {{ 65 | id, 66 | title, 67 | description, 68 | owner: {{ 69 | id, 70 | email, 71 | full_name 72 | }} 73 | }} 74 | ORDER BY {order_expr or '{}'} 75 | OFFSET $offset 76 | LIMIT $limit 77 | )) 78 | )""", 79 | **filtering, 80 | offset=offset, 81 | limit=limit, 82 | ) 83 | except Exception as e: 84 | raise HTTPException(status_code=400, detail=f"{e}") 85 | paginated_items = PaginatedItems.parse_raw(result) 86 | return paginated_items 87 | 88 | 89 | async def create(con: AsyncIOConnection, *, obj_in: ItemCreate, owner_id: UUID) -> Item: 90 | data_in = obj_in.dict(exclude_unset=True) 91 | shape_expr = utils.get_shape(data_in) 92 | try: 93 | result = await con.query_one_json( 94 | f"""SELECT ( 95 | INSERT Item {{ 96 | {shape_expr}, 97 | owner := ( 98 | SELECT User FILTER .id = $owner_id 99 | ) 100 | }} 101 | ) {{ 102 | id, 103 | title, 104 | description, 105 | owner: {{ 106 | id, 107 | email, 108 | full_name 109 | }} 110 | }}""", 111 | **data_in, 112 | owner_id=owner_id, 113 | ) 114 | except Exception as e: 115 | raise HTTPException(status_code=400, detail=f"{e}") 116 | item = Item.parse_raw(result) 117 | return item 118 | 119 | 120 | async def update( 121 | con: AsyncIOConnection, *, id: UUID, obj_in: ItemUpdate 122 | ) -> Optional[Item]: 123 | data_in = obj_in.dict(exclude_unset=True) 124 | if not data_in: 125 | item = await get(con, id=id) 126 | return item 127 | shape_expr = utils.get_shape(data_in) 128 | try: 129 | result = await con.query_one_json( 130 | f"""SELECT ( 131 | UPDATE Item 132 | FILTER .id = $id 133 | SET {{ 134 | {shape_expr} 135 | }} 136 | ) {{ 137 | id, 138 | title, 139 | description, 140 | owner: {{ 141 | id, 142 | email, 143 | full_name 144 | }} 145 | }}""", 146 | id=id, 147 | **data_in, 148 | ) 149 | except Exception as e: 150 | raise HTTPException(status_code=400, detail=f"{e}") 151 | item = Item.parse_raw(result) 152 | return item 153 | 154 | 155 | async def remove(con: AsyncIOConnection, *, id: UUID) -> Item: 156 | try: 157 | result = await con.query_one_json( 158 | """SELECT ( 159 | DELETE Item 160 | FILTER .id = $id 161 | ) { 162 | id, 163 | title, 164 | description, 165 | owner: { 166 | id, 167 | email, 168 | full_name 169 | } 170 | }""", 171 | id=id, 172 | ) 173 | except Exception as e: 174 | raise HTTPException(status_code=400, detail=f"{e}") 175 | item = Item.parse_raw(result) 176 | return item 177 | -------------------------------------------------------------------------------- /backend/app/crud/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | from uuid import UUID 3 | 4 | from edgedb import AsyncIOConnection, NoDataError 5 | from fastapi import HTTPException 6 | 7 | from app import utils 8 | from app.schemas import ( 9 | PaginatedUsers, 10 | User, 11 | UserCreate, 12 | UserInDB, 13 | UserUpdate, 14 | user_ordering_fields, 15 | ) 16 | from app.security import get_password_hash, verify_password 17 | 18 | 19 | async def get(con: AsyncIOConnection, *, id: UUID) -> Optional[User]: 20 | try: 21 | result = await con.query_one_json( 22 | """SELECT User { 23 | id, 24 | email, 25 | full_name, 26 | is_superuser, 27 | is_active, 28 | num_items, 29 | items: { 30 | id, 31 | title 32 | } 33 | } 34 | FILTER .id = $id""", 35 | id=id, 36 | ) 37 | except NoDataError: 38 | return None 39 | except Exception as e: 40 | raise HTTPException(status_code=400, detail=f"{e}") 41 | user = User.parse_raw(result) 42 | return user 43 | 44 | 45 | async def get_by_email(con: AsyncIOConnection, *, email: str) -> Optional[User]: 46 | try: 47 | result = await con.query_one_json( 48 | """SELECT User { 49 | id, 50 | email, 51 | full_name, 52 | is_superuser, 53 | is_active, 54 | num_items, 55 | items: { 56 | id, 57 | title 58 | } 59 | } 60 | FILTER .email = $email""", 61 | email=email, 62 | ) 63 | except NoDataError: 64 | return None 65 | except Exception as e: 66 | raise HTTPException(status_code=400, detail=f"{e}") 67 | user = User.parse_raw(result) 68 | return user 69 | 70 | 71 | async def get_multi( 72 | con: AsyncIOConnection, 73 | *, 74 | filtering: Dict[str, Any] = {}, 75 | ordering: str = None, 76 | offset: int = 0, 77 | limit: int = 100, 78 | ) -> PaginatedUsers: 79 | filter_expr = None 80 | order_expr = None 81 | if filtering: 82 | filter_expr = utils.get_filter(filtering) 83 | if ordering: 84 | order_expr = utils.get_order(ordering, user_ordering_fields) 85 | try: 86 | result = await con.query_one_json( 87 | f"""WITH users := ( 88 | SELECT User 89 | FILTER {filter_expr or 'true'} 90 | ) 91 | SELECT ( 92 | count:= count(users), 93 | data := array_agg(( 94 | SELECT users {{ 95 | id, 96 | email, 97 | full_name, 98 | is_superuser, 99 | is_active, 100 | num_items, 101 | items: {{ 102 | id, 103 | title 104 | }} 105 | }} 106 | ORDER BY {order_expr or '{}'} 107 | OFFSET $offset 108 | LIMIT $limit 109 | )) 110 | )""", 111 | **filtering, 112 | offset=offset, 113 | limit=limit, 114 | ) 115 | except Exception as e: 116 | raise HTTPException(status_code=400, detail=f"{e}") 117 | paginated_users = PaginatedUsers.parse_raw(result) 118 | return paginated_users 119 | 120 | 121 | async def create(con: AsyncIOConnection, *, obj_in: UserCreate) -> User: 122 | data_in = obj_in.dict(exclude_unset=True) 123 | if data_in.get("password"): 124 | data_in["hashed_password"] = get_password_hash(obj_in.password) 125 | del data_in["password"] 126 | shape_expr = utils.get_shape(data_in) 127 | try: 128 | result = await con.query_one_json( 129 | f"""SELECT ( 130 | INSERT User {{ 131 | {shape_expr} 132 | }} 133 | ) {{ 134 | id, 135 | email, 136 | full_name, 137 | is_superuser, 138 | is_active, 139 | num_items, 140 | items: {{ 141 | id, 142 | title 143 | }} 144 | }}""", 145 | **data_in, 146 | ) 147 | except Exception as e: 148 | raise HTTPException(status_code=400, detail=f"{e}") 149 | user = User.parse_raw(result) 150 | return user 151 | 152 | 153 | async def update( 154 | con: AsyncIOConnection, *, id: UUID, obj_in: UserUpdate 155 | ) -> Optional[User]: 156 | data_in = obj_in.dict(exclude_unset=True) 157 | if not data_in: 158 | user = await get(con, id=id) 159 | return user 160 | if data_in.get("password"): 161 | data_in["hashed_password"] = get_password_hash(obj_in.password) # type: ignore 162 | del data_in["password"] 163 | shape_expr = utils.get_shape(data_in) 164 | try: 165 | result = await con.query_one_json( 166 | f"""SELECT ( 167 | UPDATE User 168 | FILTER .id = $id 169 | SET {{ 170 | {shape_expr} 171 | }} 172 | ) {{ 173 | id, 174 | email, 175 | full_name, 176 | is_superuser, 177 | is_active, 178 | num_items, 179 | items: {{ 180 | id, 181 | title 182 | }} 183 | }}""", 184 | id=id, 185 | **data_in, 186 | ) 187 | except Exception as e: 188 | raise HTTPException(status_code=400, detail=f"{e}") 189 | user = User.parse_raw(result) 190 | return user 191 | 192 | 193 | async def remove(con: AsyncIOConnection, *, id: UUID) -> User: 194 | try: 195 | result = await con.query_one_json( 196 | """SELECT ( 197 | DELETE User 198 | FILTER .id = $id 199 | ) { 200 | id, 201 | email, 202 | full_name, 203 | is_superuser, 204 | is_active, 205 | num_items, 206 | items: { 207 | id, 208 | title 209 | } 210 | }""", 211 | id=id, 212 | ) 213 | except Exception as e: 214 | raise HTTPException(status_code=400, detail=f"{e}") 215 | user = User.parse_raw(result) 216 | return user 217 | 218 | 219 | async def authenticate( 220 | con: AsyncIOConnection, *, email: str, password: str 221 | ) -> Optional[UserInDB]: 222 | try: 223 | result = await con.query_one_json( 224 | """SELECT User { 225 | id, 226 | hashed_password, 227 | is_active 228 | } 229 | FILTER .email = $email""", 230 | email=email, 231 | ) 232 | except NoDataError: 233 | return None 234 | except Exception as e: 235 | raise HTTPException(status_code=400, detail=f"{e}") 236 | user = UserInDB.parse_raw(result) 237 | if not verify_password(password, user.hashed_password): 238 | return None 239 | return user 240 | -------------------------------------------------------------------------------- /backend/app/db.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from edgedb import AsyncIOConnection, AsyncIOPool, create_async_pool 4 | 5 | from .config import settings 6 | 7 | pool: AsyncIOPool 8 | 9 | 10 | async def create_pool() -> None: 11 | global pool 12 | pool = await create_async_pool( 13 | host=settings.EDGEDB_HOST, 14 | database=settings.EDGEDB_DB, 15 | user=settings.EDGEDB_USER, 16 | ) 17 | 18 | 19 | async def close_pool() -> None: 20 | await pool.aclose() 21 | 22 | 23 | async def get_con() -> AsyncGenerator[AsyncIOConnection, None]: 24 | try: 25 | con = await pool.acquire() 26 | yield con 27 | finally: 28 | await pool.release(con) 29 | -------------------------------------------------------------------------------- /backend/app/email-templates/build/new_account.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }} - New Account
You have a new account:
Username: {{ username }}
Password: {{ password }}
Go to Dashboard

-------------------------------------------------------------------------------- /backend/app/email-templates/build/reset_password.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }} - Password Recovery
We received a request to recover the password for user {{ username }} with email {{ email }}
Reset your password by clicking the button below:
Reset Password
Or open the following link:

The reset password link / button will expire in {{ valid_hours }} hours.
If you didn't request a password recovery you can disregard this email.
-------------------------------------------------------------------------------- /backend/app/email-templates/build/test_email.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }}
Test email for: {{ email }}
-------------------------------------------------------------------------------- /backend/app/email-templates/src/new_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - New Account 7 | You have a new account: 8 | Username: {{ username }} 9 | Password: {{ password }} 10 | Go to Dashboard 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /backend/app/email-templates/src/reset_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - Password Recovery 7 | We received a request to recover the password for user {{ username }} 8 | with email {{ email }} 9 | Reset your password by clicking the button below: 10 | Reset Password 11 | Or open the following link: 12 | {{ link }} 13 | 14 | The reset password link / button will expire in {{ valid_hours }} hours. 15 | If you didn't request a password recovery you can disregard this email. 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /backend/app/email-templates/src/test_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} 7 | Test email for: {{ email }} 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /backend/app/initial_data.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from edgedb import AsyncIOConnection, async_connect 7 | 8 | from app import crud, schemas 9 | from app.config import settings 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | max_tries = 60 * 5 # 5 minutes 15 | wait_seconds = 1 16 | 17 | 18 | async def check_db() -> Optional[AsyncIOConnection]: 19 | for attempt in range(max_tries): 20 | try: 21 | con = await async_connect( 22 | host=settings.EDGEDB_HOST, 23 | database=settings.EDGEDB_DB, 24 | user=settings.EDGEDB_USER, 25 | ) 26 | # Try to create session to check if DB is awake 27 | await con.execute("SELECT 1") 28 | return con 29 | except Exception as e: 30 | if attempt < max_tries - 1: 31 | logger.error( 32 | f"""{e} 33 | Attempt {attempt + 1}/{max_tries} to connect to database, waiting {wait_seconds}s.""" 34 | ) 35 | await asyncio.sleep(wait_seconds) 36 | else: 37 | raise e 38 | return None 39 | 40 | 41 | async def init_db(con: AsyncIOConnection) -> None: 42 | with open(Path("./dbschema/database.esdl")) as f: 43 | schema = f.read() 44 | async with con.transaction(): 45 | await con.execute(f"""START MIGRATION TO {{ {schema} }}""") 46 | await con.execute("""POPULATE MIGRATION""") 47 | await con.execute("""COMMIT MIGRATION""") 48 | user = await crud.user.get_by_email(con, email=settings.FIRST_SUPERUSER) 49 | if not user: 50 | user_in = schemas.UserCreate( 51 | email=settings.FIRST_SUPERUSER, 52 | password=settings.FIRST_SUPERUSER_PASSWORD, 53 | is_superuser=True, 54 | ) 55 | await crud.user.create(con, obj_in=user_in) 56 | 57 | 58 | async def main() -> None: 59 | logger.info("Initializing service") 60 | con = await check_db() 61 | logger.info("Service finished initializing") 62 | if con: 63 | logger.info("Creating initial data") 64 | await init_db(con) 65 | logger.info("Initial data created") 66 | 67 | 68 | if __name__ == "__main__": 69 | asyncio.run(main()) 70 | -------------------------------------------------------------------------------- /backend/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | 4 | from app.api import api_router 5 | from app.config import settings 6 | from app.db import close_pool, create_pool 7 | 8 | app = FastAPI( 9 | title=settings.PROJECT_NAME, 10 | openapi_url=f"{settings.API_V1_STR}/openapi.json", 11 | on_startup=[create_pool], 12 | on_shutdown=[close_pool], 13 | ) 14 | 15 | # Set all CORS enabled origins 16 | if settings.BACKEND_CORS_ORIGINS: 17 | app.add_middleware( 18 | CORSMiddleware, 19 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 20 | allow_credentials=True, 21 | allow_methods=["*"], 22 | allow_headers=["*"], 23 | ) 24 | 25 | app.include_router(api_router, prefix=settings.API_V1_STR) 26 | -------------------------------------------------------------------------------- /backend/app/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel, EmailStr 5 | 6 | user_ordering_fields = [ 7 | "id", 8 | "full_name", 9 | "email", 10 | "is_active", 11 | "is_superuser", 12 | "num_items", 13 | ] 14 | 15 | item_ordering_fields = [ 16 | "id", 17 | "title", 18 | "description", 19 | "owner__full_name", 20 | "owner__email", 21 | ] 22 | 23 | 24 | class CommonQueryParams(BaseModel): 25 | ordering: Optional[str] = None 26 | offset: int = 0 27 | limit: int = 100 28 | 29 | 30 | class FilterQueryParams(BaseModel): 31 | def dict_exclude_unset(self) -> Dict[str, Any]: 32 | return {k: v for k, v in self.dict().items() if v is not None} 33 | 34 | 35 | class UserFilterParams(FilterQueryParams): 36 | full_name: Optional[str] = None 37 | email: Optional[EmailStr] = None 38 | is_active: Optional[bool] = None 39 | is_superuser: Optional[bool] = None 40 | num_items: Optional[int] = None 41 | 42 | 43 | class ItemFilterParams(FilterQueryParams): 44 | title: Optional[str] = None 45 | description: Optional[str] = None 46 | owner__id: Optional[UUID] = None 47 | owner__full_name: Optional[str] = None 48 | owner__email: Optional[EmailStr] = None 49 | 50 | 51 | class Msg(BaseModel): 52 | msg: str 53 | 54 | 55 | class Token(BaseModel): 56 | access_token: str 57 | token_type: str 58 | 59 | 60 | class TokenPayload(BaseModel): 61 | sub: UUID 62 | 63 | 64 | class NestedUser(BaseModel): 65 | id: UUID 66 | email: EmailStr 67 | full_name: Optional[str] = None 68 | 69 | 70 | class NestedItem(BaseModel): 71 | id: UUID 72 | title: str 73 | 74 | 75 | class UserBase(BaseModel): 76 | email: Optional[EmailStr] = None 77 | is_active: Optional[bool] = True 78 | is_superuser: Optional[bool] = False 79 | full_name: Optional[str] = None 80 | 81 | 82 | class UserCreate(UserBase): 83 | email: EmailStr 84 | password: str 85 | 86 | 87 | class UserUpdate(UserBase): 88 | password: Optional[str] = None 89 | 90 | 91 | class User(UserBase): 92 | id: UUID 93 | email: EmailStr 94 | num_items: int 95 | items: List[NestedItem] 96 | 97 | 98 | class UserInDB(UserBase): 99 | id: UUID 100 | hashed_password: str 101 | 102 | 103 | class ItemBase(BaseModel): 104 | title: Optional[str] = None 105 | description: Optional[str] = None 106 | 107 | 108 | class ItemCreate(ItemBase): 109 | title: str 110 | 111 | 112 | class ItemUpdate(ItemBase): 113 | pass 114 | 115 | 116 | class Item(ItemBase): 117 | id: UUID 118 | owner: NestedUser 119 | 120 | 121 | class PaginatedUsers(BaseModel): 122 | count: int 123 | data: List[User] 124 | 125 | 126 | class PaginatedItems(BaseModel): 127 | count: int 128 | data: List[Item] 129 | -------------------------------------------------------------------------------- /backend/app/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Optional, Union 3 | 4 | from jose import jwt 5 | from passlib.context import CryptContext 6 | 7 | from .config import settings 8 | 9 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 10 | 11 | 12 | ALGORITHM = "HS256" 13 | 14 | 15 | def create_access_token( 16 | subject: Union[str, Any], expires_delta: timedelta = None 17 | ) -> str: 18 | if expires_delta: 19 | expire = datetime.utcnow() + expires_delta 20 | else: 21 | expire = datetime.utcnow() + timedelta( 22 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES 23 | ) 24 | to_encode = {"exp": expire, "sub": str(subject)} 25 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 26 | return encoded_jwt 27 | 28 | 29 | def verify_password(plain_password: str, hashed_password: str) -> bool: 30 | return pwd_context.verify(plain_password, hashed_password) 31 | 32 | 33 | def get_password_hash(password: str) -> str: 34 | return pwd_context.hash(password) 35 | 36 | 37 | def generate_password_reset_token(email: str) -> str: 38 | delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) 39 | now = datetime.utcnow() 40 | expires = now + delta 41 | exp = expires.timestamp() 42 | encoded_jwt = jwt.encode( 43 | {"exp": exp, "nbf": now, "sub": email}, 44 | settings.SECRET_KEY, 45 | algorithm=ALGORITHM, 46 | ) 47 | return encoded_jwt 48 | 49 | 50 | def verify_password_reset_token(token: str) -> Optional[str]: 51 | try: 52 | decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) 53 | return decoded_token["email"] 54 | except jwt.JWTError: 55 | return None 56 | -------------------------------------------------------------------------------- /backend/app/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Any, Dict, List 4 | from uuid import UUID 5 | 6 | import emails 7 | from emails.template import JinjaTemplate 8 | from fastapi import HTTPException 9 | from pydantic import EmailStr 10 | 11 | from .config import settings 12 | 13 | ALGORITHM = "HS256" 14 | 15 | 16 | def send_email( 17 | email_to: str, 18 | subject_template: str = "", 19 | html_template: str = "", 20 | environment: Dict[str, Any] = {}, 21 | ) -> None: 22 | assert settings.EMAILS_ENABLED, "no provided configuration for email variables" 23 | message = emails.Message( 24 | subject=JinjaTemplate(subject_template), 25 | html=JinjaTemplate(html_template), 26 | mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), 27 | ) 28 | smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} 29 | if settings.SMTP_TLS: 30 | smtp_options["tls"] = True 31 | if settings.SMTP_USER: 32 | smtp_options["user"] = settings.SMTP_USER 33 | if settings.SMTP_PASSWORD: 34 | smtp_options["password"] = settings.SMTP_PASSWORD 35 | response = message.send(to=email_to, render=environment, smtp=smtp_options) 36 | logging.info(f"send email result: {response}") 37 | 38 | 39 | def send_test_email(email_to: str) -> None: 40 | project_name = settings.PROJECT_NAME 41 | subject = f"{project_name} - Test email" 42 | with open(Path(settings.EMAIL_TEMPLATES_DIR) / "test_email.html") as f: 43 | template_str = f.read() 44 | send_email( 45 | email_to=email_to, 46 | subject_template=subject, 47 | html_template=template_str, 48 | environment={"project_name": settings.PROJECT_NAME, "email": email_to}, 49 | ) 50 | 51 | 52 | def send_reset_password_email(email_to: EmailStr, email: str, token: str) -> None: 53 | project_name = settings.PROJECT_NAME 54 | subject = f"{project_name} - Password recovery for user {email}" 55 | with open(Path(settings.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f: 56 | template_str = f.read() 57 | server_host = settings.EMAILS_SERVER_HOST 58 | link = f"{server_host}/reset-password?token={token}" 59 | send_email( 60 | email_to=email_to, 61 | subject_template=subject, 62 | html_template=template_str, 63 | environment={ 64 | "project_name": settings.PROJECT_NAME, 65 | "username": email, 66 | "email": email_to, 67 | "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, 68 | "link": link, 69 | }, 70 | ) 71 | 72 | 73 | def send_new_account_email(email_to: str, username: str, password: str) -> None: 74 | project_name = settings.PROJECT_NAME 75 | subject = f"{project_name} - New account for user {username}" 76 | with open(Path(settings.EMAIL_TEMPLATES_DIR) / "new_account.html") as f: 77 | template_str = f.read() 78 | link = settings.EMAILS_SERVER_HOST 79 | send_email( 80 | email_to=email_to, 81 | subject_template=subject, 82 | html_template=template_str, 83 | environment={ 84 | "project_name": settings.PROJECT_NAME, 85 | "username": username, 86 | "password": password, 87 | "email": email_to, 88 | "link": link, 89 | }, 90 | ) 91 | 92 | 93 | def get_type(value: Any) -> str: 94 | if type(value) == bool: 95 | return "" 96 | elif type(value) == str: 97 | return "" 98 | elif type(value) == int: 99 | return "" 100 | elif type(value) == UUID: 101 | return "" 102 | else: 103 | raise ValueError("Type not found.") 104 | 105 | 106 | def get_shape(data: Dict[str, Any]) -> str: 107 | shape_list = [f"{k} := {get_type(v)}${k}" for k, v in data.items()] 108 | shape_expr = ", ".join(shape_list) 109 | return shape_expr 110 | 111 | 112 | def get_filter(filtering: Dict[str, Any]) -> str: 113 | filter_list = [ 114 | f".{f.replace('__','.')} = {get_type(v)}${f}" for f, v in filtering.items() 115 | ] 116 | filter_expr = " AND ".join(filter_list) 117 | return filter_expr 118 | 119 | 120 | def get_order(ordering: str, ordering_fields: List) -> str: 121 | order_list = [] 122 | fields = ordering.split(",") 123 | for f in fields: 124 | if f.startswith("-"): 125 | f = f[1:] 126 | direction = " DESC" 127 | else: 128 | direction = "" 129 | if f in ordering_fields: 130 | order_list.append(f".{f.replace('__','.')}{direction}") 131 | else: 132 | raise HTTPException( 133 | status_code=400, detail=f"Ordering field '{f}' not allowed." 134 | ) 135 | order_expr = " THEN ".join(order_list) 136 | return order_expr 137 | -------------------------------------------------------------------------------- /backend/dbschema/database.esdl: -------------------------------------------------------------------------------- 1 | module default { 2 | type User { 3 | required property email -> str { 4 | constraint exclusive; 5 | }; 6 | required property hashed_password -> str; 7 | property full_name -> str; 8 | property is_superuser -> bool { 9 | default := false; 10 | } 11 | property is_active -> bool { 12 | default := true; 13 | } 14 | property num_items := count(. str; 20 | property description -> str; 21 | required link owner -> User; 22 | index on (.title); 23 | index on (.description); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy 3 | disallow_untyped_defs = True 4 | ignore_missing_imports = True 5 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Admin "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | uvicorn = "^0.12.1" 10 | fastapi = "^0.61.1" 11 | python-multipart = "^0.0.5" 12 | email-validator = "^1.1.1" 13 | passlib = {extras = ["bcrypt"], version = "^1.7.4"} 14 | emails = "^0.6" 15 | jinja2 = "^2.11.2" 16 | python-jose = {extras = ["cryptography"], version = "^3.2.0"} 17 | python-dotenv = "^0.14.0" 18 | edgedb = "^0.11.0" 19 | 20 | [tool.poetry.dev-dependencies] 21 | mypy = "^0.790" 22 | black = "^20.8b1" 23 | isort = "^5.6.4" 24 | autoflake = "^1.4" 25 | flake8 = "^3.8.4" 26 | pytest = "^6.1.1" 27 | pytest-cov = "^2.10.1" 28 | 29 | [tool.isort] 30 | multi_line_output = 3 31 | include_trailing_comma = true 32 | force_grid_wrap = 0 33 | line_length = 88 34 | 35 | [build-system] 36 | requires = ["poetry>=0.12"] 37 | build-backend = "poetry.masonry.api" 38 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | bcrypt==3.2.0 2 | cachetools==4.1.1 3 | certifi==2020.6.20 4 | cffi==1.14.3 5 | chardet==3.0.4 6 | click==7.1.2 7 | cryptography==3.1.1 8 | cssselect==1.1.0 9 | cssutils==1.0.2 10 | dnspython==2.0.0 11 | ecdsa==0.14.1 12 | edgedb==0.11.0 13 | email-validator==1.1.1 14 | emails==0.6 15 | fastapi==0.61.1 16 | h11==0.11.0 17 | idna==2.10 18 | jinja2==2.11.2 19 | lxml==4.5.2 20 | markupsafe==1.1.1 21 | passlib==1.7.4 22 | premailer==3.7.0 23 | pyasn1==0.4.8 24 | pycparser==2.20 25 | pydantic==1.6.1 26 | python-dateutil==2.8.1 27 | python-dotenv==0.14.0 28 | python-jose==3.2.0 29 | python-multipart==0.0.5 30 | requests==2.24.0 31 | rsa==4.6 32 | six==1.15.0 33 | starlette==0.13.6 34 | typing-extensions==3.7.4.3 35 | urllib3==1.25.10 36 | uvicorn==0.12.1 37 | -------------------------------------------------------------------------------- /backend/scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Sort imports one per line, so autoflake can remove unused imports 4 | isort --force-single-line-imports app 5 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py 6 | black app 7 | isort app 8 | -------------------------------------------------------------------------------- /backend/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mypy app 4 | black app --check 5 | isort --check-only app 6 | flake8 --max-line-length 88 --exclude .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache,venv 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | db: 5 | image: edgedb/edgedb:1-alpha6 6 | volumes: 7 | - edgedb-data:/var/lib/edgedb/data 8 | ports: 9 | - "5656:5656" 10 | backend: 11 | depends_on: 12 | - db 13 | build: ./backend 14 | env_file: 15 | - ./backend/.env 16 | volumes: 17 | - ./backend:/app 18 | command: sh -c "PYTHONPATH=. python app/initial_data.py && 19 | uvicorn --host 0.0.0.0 --reload app.main:app" 20 | ports: 21 | - "8000:8000" 22 | frontend: 23 | ports: 24 | - "8080:80" 25 | build: 26 | context: ./frontend 27 | args: 28 | FRONTEND_ENV: dev 29 | 30 | volumes: 31 | edgedb-data: 32 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | VUE_APP_DOMAIN_DEV=localhost:8000 2 | # VUE_APP_DOMAIN_DEV=dev.example.com 3 | VUE_APP_DOMAIN_STAG=stag.example.com 4 | VUE_APP_DOMAIN_PROD=example.com 5 | VUE_APP_NAME=Example 6 | VUE_APP_ENV=development 7 | # VUE_APP_ENV=staging 8 | # VUE_APP_ENV=production 9 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend 2 | FROM tiangolo/node-frontend:10 as build-stage 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json /app/ 7 | 8 | RUN npm install 9 | 10 | COPY ./ /app/ 11 | 12 | ARG FRONTEND_ENV=production 13 | 14 | ENV VUE_APP_ENV=${FRONTEND_ENV} 15 | 16 | # Comment out the next line to disable tests 17 | RUN npm run test:unit 18 | 19 | RUN npm run build 20 | 21 | 22 | # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx 23 | FROM nginx:1.15 24 | 25 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html 26 | 27 | COPY --from=build-stage /nginx.conf /etc/nginx/conf.d/default.conf 28 | COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf 29 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | npm run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Run your unit tests 29 | ``` 30 | npm run test:unit 31 | ``` 32 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | [ 4 | "@vue/cli-plugin-babel/preset", 5 | { 6 | "useBuiltIns": "entry" 7 | } 8 | ] 9 | ] 10 | } -------------------------------------------------------------------------------- /frontend/nginx-backend-not-found.conf: -------------------------------------------------------------------------------- 1 | location /api { 2 | return 404; 3 | } 4 | location /docs { 5 | return 404; 6 | } 7 | location /redoc { 8 | return 404; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "lint": "vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "@babel/polyfill": "^7.2.5", 13 | "axios": "^0.18.0", 14 | "core-js": "^3.4.3", 15 | "register-service-worker": "^1.0.0", 16 | "typesafe-vuex": "^3.1.1", 17 | "vee-validate": "^2.1.7", 18 | "vue": "^2.5.22", 19 | "vue-class-component": "^6.0.0", 20 | "vue-property-decorator": "^7.3.0", 21 | "vue-router": "^3.0.2", 22 | "vuetify": "^1.4.4", 23 | "vuex": "^3.1.0" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^23.3.13", 27 | "@vue/cli-plugin-babel": "^4.1.1", 28 | "@vue/cli-plugin-pwa": "^4.1.1", 29 | "@vue/cli-plugin-typescript": "^4.1.1", 30 | "@vue/cli-plugin-unit-jest": "^4.1.1", 31 | "@vue/cli-service": "^4.1.1", 32 | "@vue/test-utils": "^1.0.0-beta.28", 33 | "babel-core": "7.0.0-bridge.0", 34 | "ts-jest": "^23.10.5", 35 | "typescript": "^3.2.4", 36 | "vue-cli-plugin-vuetify": "^2.0.2", 37 | "vue-template-compiler": "^2.5.22" 38 | }, 39 | "postcss": { 40 | "plugins": { 41 | "autoprefixer": {} 42 | } 43 | }, 44 | "browserslist": [ 45 | "> 1%", 46 | "last 2 versions", 47 | "not ie <= 10" 48 | ], 49 | "jest": { 50 | "moduleFileExtensions": [ 51 | "js", 52 | "jsx", 53 | "json", 54 | "vue", 55 | "ts", 56 | "tsx" 57 | ], 58 | "transform": { 59 | "^.+\\.vue$": "vue-jest", 60 | ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub", 61 | "^.+\\.tsx?$": "ts-jest" 62 | }, 63 | "moduleNameMapper": { 64 | "^@/(.*)$": "/src/$1" 65 | }, 66 | "snapshotSerializers": [ 67 | "jest-serializer-vue" 68 | ], 69 | "testMatch": [ 70 | "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)" 71 | ], 72 | "testURL": "http://localhost/" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /frontend/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= VUE_APP_NAME %> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "short_name": "frontend", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 44 | -------------------------------------------------------------------------------- /frontend/src/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { IUserProfile, IUserProfiles, IUserProfileUpdate, IUserProfileCreate } from './interfaces'; 4 | 5 | function authHeaders(token: string) { 6 | return { 7 | headers: { 8 | Authorization: `Bearer ${token}`, 9 | }, 10 | }; 11 | } 12 | 13 | export const api = { 14 | async logInGetToken(username: string, password: string) { 15 | const params = new URLSearchParams(); 16 | params.append('username', username); 17 | params.append('password', password); 18 | 19 | return axios.post(`${apiUrl}/api/v1/login/access-token`, params); 20 | }, 21 | async getMe(token: string) { 22 | return axios.get(`${apiUrl}/api/v1/users/me`, authHeaders(token)); 23 | }, 24 | async updateMe(token: string, data: IUserProfileUpdate) { 25 | return axios.put(`${apiUrl}/api/v1/users/me`, data, authHeaders(token)); 26 | }, 27 | async getUsers(token: string) { 28 | return axios.get(`${apiUrl}/api/v1/users/`, authHeaders(token)); 29 | }, 30 | async updateUser(token: string, userId: string, data: IUserProfileUpdate) { 31 | return axios.put(`${apiUrl}/api/v1/users/${userId}`, data, authHeaders(token)); 32 | }, 33 | async createUser(token: string, data: IUserProfileCreate) { 34 | return axios.post(`${apiUrl}/api/v1/users/`, data, authHeaders(token)); 35 | }, 36 | async passwordRecovery(email: string) { 37 | return axios.post(`${apiUrl}/api/v1/password-recovery/${email}`); 38 | }, 39 | async resetPassword(password: string, token: string) { 40 | return axios.post(`${apiUrl}/api/v1/reset-password/`, { 41 | new_password: password, 42 | token, 43 | }); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kurtrottmann/simple-stack-fastapi-edgedb/26babae6ae36460155806a47972e4af8b99083eb/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/component-hooks.ts: -------------------------------------------------------------------------------- 1 | import Component from 'vue-class-component'; 2 | 3 | // Register the router hooks with their names 4 | Component.registerHooks([ 5 | 'beforeRouteEnter', 6 | 'beforeRouteLeave', 7 | 'beforeRouteUpdate', // for vue-router 2.2+ 8 | ]); 9 | -------------------------------------------------------------------------------- /frontend/src/components/NotificationsManager.vue: -------------------------------------------------------------------------------- 1 | 9 | 78 | -------------------------------------------------------------------------------- /frontend/src/components/RouterComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /frontend/src/components/UploadButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /frontend/src/env.ts: -------------------------------------------------------------------------------- 1 | const env = process.env.VUE_APP_ENV; 2 | 3 | let envApiUrl = ''; 4 | 5 | if (env === 'production') { 6 | envApiUrl = `https://${process.env.VUE_APP_DOMAIN_PROD}`; 7 | } else if (env === 'staging') { 8 | envApiUrl = `https://${process.env.VUE_APP_DOMAIN_STAG}`; 9 | } else { 10 | envApiUrl = `http://${process.env.VUE_APP_DOMAIN_DEV}`; 11 | } 12 | 13 | export const apiUrl = envApiUrl; 14 | export const appName = process.env.VUE_APP_NAME; 15 | -------------------------------------------------------------------------------- /frontend/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export interface IUserProfile { 2 | email: string; 3 | is_active: boolean; 4 | is_superuser: boolean; 5 | full_name: string; 6 | id: string; 7 | } 8 | 9 | export interface IUserProfiles { 10 | count: number; 11 | data: IUserProfile[]; 12 | } 13 | 14 | export interface IUserProfileUpdate { 15 | email?: string; 16 | full_name?: string; 17 | password?: string; 18 | is_active?: boolean; 19 | is_superuser?: boolean; 20 | } 21 | 22 | export interface IUserProfileCreate { 23 | email: string; 24 | full_name?: string; 25 | password?: string; 26 | is_active?: boolean; 27 | is_superuser?: boolean; 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | // Import Component hooks before component definitions 3 | import './component-hooks'; 4 | import Vue from 'vue'; 5 | import './plugins/vuetify'; 6 | import './plugins/vee-validate'; 7 | import App from './App.vue'; 8 | import router from './router'; 9 | import store from '@/store'; 10 | import './registerServiceWorker'; 11 | import 'vuetify/dist/vuetify.min.css'; 12 | 13 | Vue.config.productionTip = false; 14 | 15 | new Vue({ 16 | router, 17 | store, 18 | render: (h) => h(App), 19 | }).$mount('#app'); 20 | -------------------------------------------------------------------------------- /frontend/src/plugins/vee-validate.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VeeValidate from 'vee-validate'; 3 | 4 | Vue.use(VeeValidate); 5 | -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify'; 3 | 4 | Vue.use(Vuetify, { 5 | iconfont: 'md', 6 | }); 7 | -------------------------------------------------------------------------------- /frontend/src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB', 11 | ); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updated() { 17 | console.log('New content is available; please refresh.'); 18 | }, 19 | offline() { 20 | console.log('No internet connection found. App is running in offline mode.'); 21 | }, 22 | error(error) { 23 | console.error('Error during service worker registration:', error); 24 | }, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/router.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | 4 | import RouterComponent from './components/RouterComponent.vue'; 5 | 6 | Vue.use(Router); 7 | 8 | export default new Router({ 9 | mode: 'history', 10 | base: process.env.BASE_URL, 11 | routes: [ 12 | { 13 | path: '/', 14 | component: () => import(/* webpackChunkName: "start" */ './views/main/Start.vue'), 15 | children: [ 16 | { 17 | path: 'login', 18 | // route level code-splitting 19 | // this generates a separate chunk (about.[hash].js) for this route 20 | // which is lazy-loaded when the route is visited. 21 | component: () => import(/* webpackChunkName: "login" */ './views/Login.vue'), 22 | }, 23 | { 24 | path: 'recover-password', 25 | component: () => import(/* webpackChunkName: "recover-password" */ './views/PasswordRecovery.vue'), 26 | }, 27 | { 28 | path: 'reset-password', 29 | component: () => import(/* webpackChunkName: "reset-password" */ './views/ResetPassword.vue'), 30 | }, 31 | { 32 | path: 'main', 33 | component: () => import(/* webpackChunkName: "main" */ './views/main/Main.vue'), 34 | children: [ 35 | { 36 | path: 'dashboard', 37 | component: () => import(/* webpackChunkName: "main-dashboard" */ './views/main/Dashboard.vue'), 38 | }, 39 | { 40 | path: 'profile', 41 | component: RouterComponent, 42 | redirect: 'profile/view', 43 | children: [ 44 | { 45 | path: 'view', 46 | component: () => import( 47 | /* webpackChunkName: "main-profile" */ './views/main/profile/UserProfile.vue'), 48 | }, 49 | { 50 | path: 'edit', 51 | component: () => import( 52 | /* webpackChunkName: "main-profile-edit" */ './views/main/profile/UserProfileEdit.vue'), 53 | }, 54 | { 55 | path: 'password', 56 | component: () => import( 57 | /* webpackChunkName: "main-profile-password" */ './views/main/profile/UserProfileEditPassword.vue'), 58 | }, 59 | ], 60 | }, 61 | { 62 | path: 'admin', 63 | component: () => import(/* webpackChunkName: "main-admin" */ './views/main/admin/Admin.vue'), 64 | redirect: 'admin/users/all', 65 | children: [ 66 | { 67 | path: 'users', 68 | redirect: 'users/all', 69 | }, 70 | { 71 | path: 'users/all', 72 | component: () => import( 73 | /* webpackChunkName: "main-admin-users" */ './views/main/admin/AdminUsers.vue'), 74 | }, 75 | { 76 | path: 'users/edit/:id', 77 | name: 'main-admin-users-edit', 78 | component: () => import( 79 | /* webpackChunkName: "main-admin-users-edit" */ './views/main/admin/EditUser.vue'), 80 | }, 81 | { 82 | path: 'users/create', 83 | name: 'main-admin-users-create', 84 | component: () => import( 85 | /* webpackChunkName: "main-admin-users-create" */ './views/main/admin/CreateUser.vue'), 86 | }, 87 | ], 88 | }, 89 | ], 90 | }, 91 | ], 92 | }, 93 | { 94 | path: '/*', redirect: '/', 95 | }, 96 | ], 97 | }); 98 | -------------------------------------------------------------------------------- /frontend/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/store/admin/actions.ts: -------------------------------------------------------------------------------- 1 | import { api } from '@/api'; 2 | import { ActionContext } from 'vuex'; 3 | import { IUserProfileCreate, IUserProfileUpdate } from '@/interfaces'; 4 | import { State } from '../state'; 5 | import { AdminState } from './state'; 6 | import { getStoreAccessors } from 'typesafe-vuex'; 7 | import { commitSetUsers, commitSetUser } from './mutations'; 8 | import { dispatchCheckApiError } from '../main/actions'; 9 | import { commitAddNotification, commitRemoveNotification } from '../main/mutations'; 10 | 11 | type MainContext = ActionContext; 12 | 13 | export const actions = { 14 | async actionGetUsers(context: MainContext) { 15 | try { 16 | const response = await api.getUsers(context.rootState.main.token); 17 | if (response) { 18 | commitSetUsers(context, response.data.data); 19 | } 20 | } catch (error) { 21 | await dispatchCheckApiError(context, error); 22 | } 23 | }, 24 | async actionUpdateUser(context: MainContext, payload: { id: string, user: IUserProfileUpdate }) { 25 | try { 26 | const loadingNotification = { content: 'saving', showProgress: true }; 27 | commitAddNotification(context, loadingNotification); 28 | const response = (await Promise.all([ 29 | api.updateUser(context.rootState.main.token, payload.id, payload.user), 30 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)), 31 | ]))[0]; 32 | commitSetUser(context, response.data); 33 | commitRemoveNotification(context, loadingNotification); 34 | commitAddNotification(context, { content: 'User successfully updated', color: 'success' }); 35 | } catch (error) { 36 | await dispatchCheckApiError(context, error); 37 | } 38 | }, 39 | async actionCreateUser(context: MainContext, payload: IUserProfileCreate) { 40 | try { 41 | const loadingNotification = { content: 'saving', showProgress: true }; 42 | commitAddNotification(context, loadingNotification); 43 | const response = (await Promise.all([ 44 | api.createUser(context.rootState.main.token, payload), 45 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)), 46 | ]))[0]; 47 | commitSetUser(context, response.data); 48 | commitRemoveNotification(context, loadingNotification); 49 | commitAddNotification(context, { content: 'User successfully created', color: 'success' }); 50 | } catch (error) { 51 | await dispatchCheckApiError(context, error); 52 | } 53 | }, 54 | }; 55 | 56 | const { dispatch } = getStoreAccessors(''); 57 | 58 | export const dispatchCreateUser = dispatch(actions.actionCreateUser); 59 | export const dispatchGetUsers = dispatch(actions.actionGetUsers); 60 | export const dispatchUpdateUser = dispatch(actions.actionUpdateUser); 61 | -------------------------------------------------------------------------------- /frontend/src/store/admin/getters.ts: -------------------------------------------------------------------------------- 1 | import { AdminState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | adminUsers: (state: AdminState) => state.users, 7 | adminOneUser: (state: AdminState) => (userId: string) => { 8 | const filteredUsers = state.users.filter((user) => user.id === userId); 9 | if (filteredUsers.length > 0) { 10 | return { ...filteredUsers[0] }; 11 | } 12 | }, 13 | }; 14 | 15 | const { read } = getStoreAccessors(''); 16 | 17 | export const readAdminOneUser = read(getters.adminOneUser); 18 | export const readAdminUsers = read(getters.adminUsers); 19 | -------------------------------------------------------------------------------- /frontend/src/store/admin/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { AdminState } from './state'; 5 | 6 | const defaultState: AdminState = { 7 | users: [], 8 | }; 9 | 10 | export const adminModule = { 11 | state: defaultState, 12 | mutations, 13 | actions, 14 | getters, 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/store/admin/mutations.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | import { AdminState } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | export const mutations = { 7 | setUsers(state: AdminState, payload: IUserProfile[]) { 8 | state.users = payload; 9 | }, 10 | setUser(state: AdminState, payload: IUserProfile) { 11 | const users = state.users.filter((user: IUserProfile) => user.id !== payload.id); 12 | users.push(payload); 13 | state.users = users; 14 | }, 15 | }; 16 | 17 | const { commit } = getStoreAccessors(''); 18 | 19 | export const commitSetUser = commit(mutations.setUser); 20 | export const commitSetUsers = commit(mutations.setUsers); 21 | -------------------------------------------------------------------------------- /frontend/src/store/admin/state.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | 3 | export interface AdminState { 4 | users: IUserProfile[]; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { StoreOptions } from 'vuex'; 3 | 4 | import { mainModule } from './main'; 5 | import { State } from './state'; 6 | import { adminModule } from './admin'; 7 | 8 | Vue.use(Vuex); 9 | 10 | const storeOptions: StoreOptions = { 11 | modules: { 12 | main: mainModule, 13 | admin: adminModule, 14 | }, 15 | }; 16 | 17 | export const store = new Vuex.Store(storeOptions); 18 | 19 | export default store; 20 | -------------------------------------------------------------------------------- /frontend/src/store/main/actions.ts: -------------------------------------------------------------------------------- 1 | import { api } from '@/api'; 2 | import router from '@/router'; 3 | import { getLocalToken, removeLocalToken, saveLocalToken } from '@/utils'; 4 | import { AxiosError } from 'axios'; 5 | import { getStoreAccessors } from 'typesafe-vuex'; 6 | import { ActionContext } from 'vuex'; 7 | import { State } from '../state'; 8 | import { 9 | commitAddNotification, 10 | commitRemoveNotification, 11 | commitSetLoggedIn, 12 | commitSetLogInError, 13 | commitSetToken, 14 | commitSetUserProfile, 15 | } from './mutations'; 16 | import { AppNotification, MainState } from './state'; 17 | 18 | type MainContext = ActionContext; 19 | 20 | export const actions = { 21 | async actionLogIn(context: MainContext, payload: { username: string; password: string }) { 22 | try { 23 | const response = await api.logInGetToken(payload.username, payload.password); 24 | const token = response.data.access_token; 25 | if (token) { 26 | saveLocalToken(token); 27 | commitSetToken(context, token); 28 | commitSetLoggedIn(context, true); 29 | commitSetLogInError(context, false); 30 | await dispatchGetUserProfile(context); 31 | await dispatchRouteLoggedIn(context); 32 | commitAddNotification(context, { content: 'Logged in', color: 'success' }); 33 | } else { 34 | await dispatchLogOut(context); 35 | } 36 | } catch (err) { 37 | commitSetLogInError(context, true); 38 | await dispatchLogOut(context); 39 | } 40 | }, 41 | async actionGetUserProfile(context: MainContext) { 42 | try { 43 | const response = await api.getMe(context.state.token); 44 | if (response.data) { 45 | commitSetUserProfile(context, response.data); 46 | } 47 | } catch (error) { 48 | await dispatchCheckApiError(context, error); 49 | } 50 | }, 51 | async actionUpdateUserProfile(context: MainContext, payload) { 52 | try { 53 | const loadingNotification = { content: 'saving', showProgress: true }; 54 | commitAddNotification(context, loadingNotification); 55 | const response = (await Promise.all([ 56 | api.updateMe(context.state.token, payload), 57 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)), 58 | ]))[0]; 59 | commitSetUserProfile(context, response.data); 60 | commitRemoveNotification(context, loadingNotification); 61 | commitAddNotification(context, { content: 'Profile successfully updated', color: 'success' }); 62 | } catch (error) { 63 | await dispatchCheckApiError(context, error); 64 | } 65 | }, 66 | async actionCheckLoggedIn(context: MainContext) { 67 | if (!context.state.isLoggedIn) { 68 | let token = context.state.token; 69 | if (!token) { 70 | const localToken = getLocalToken(); 71 | if (localToken) { 72 | commitSetToken(context, localToken); 73 | token = localToken; 74 | } 75 | } 76 | if (token) { 77 | try { 78 | const response = await api.getMe(token); 79 | commitSetLoggedIn(context, true); 80 | commitSetUserProfile(context, response.data); 81 | } catch (error) { 82 | await dispatchRemoveLogIn(context); 83 | } 84 | } else { 85 | await dispatchRemoveLogIn(context); 86 | } 87 | } 88 | }, 89 | async actionRemoveLogIn(context: MainContext) { 90 | removeLocalToken(); 91 | commitSetToken(context, ''); 92 | commitSetLoggedIn(context, false); 93 | }, 94 | async actionLogOut(context: MainContext) { 95 | await dispatchRemoveLogIn(context); 96 | await dispatchRouteLogOut(context); 97 | }, 98 | async actionUserLogOut(context: MainContext) { 99 | await dispatchLogOut(context); 100 | commitAddNotification(context, { content: 'Logged out', color: 'success' }); 101 | }, 102 | actionRouteLogOut(context: MainContext) { 103 | if (router.currentRoute.path !== '/login') { 104 | router.push('/login'); 105 | } 106 | }, 107 | async actionCheckApiError(context: MainContext, payload: AxiosError) { 108 | if (payload.response!.status === 401) { 109 | await dispatchLogOut(context); 110 | } 111 | }, 112 | actionRouteLoggedIn(context: MainContext) { 113 | if (router.currentRoute.path === '/login' || router.currentRoute.path === '/') { 114 | router.push('/main'); 115 | } 116 | }, 117 | async removeNotification(context: MainContext, payload: { notification: AppNotification, timeout: number }) { 118 | return new Promise((resolve, reject) => { 119 | setTimeout(() => { 120 | commitRemoveNotification(context, payload.notification); 121 | resolve(true); 122 | }, payload.timeout); 123 | }); 124 | }, 125 | async passwordRecovery(context: MainContext, payload: { username: string }) { 126 | const loadingNotification = { content: 'Sending password recovery email', showProgress: true }; 127 | try { 128 | commitAddNotification(context, loadingNotification); 129 | const response = (await Promise.all([ 130 | api.passwordRecovery(payload.username), 131 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)), 132 | ]))[0]; 133 | commitRemoveNotification(context, loadingNotification); 134 | commitAddNotification(context, { content: 'Password recovery email sent', color: 'success' }); 135 | await dispatchLogOut(context); 136 | } catch (error) { 137 | commitRemoveNotification(context, loadingNotification); 138 | commitAddNotification(context, { color: 'error', content: 'Incorrect username' }); 139 | } 140 | }, 141 | async resetPassword(context: MainContext, payload: { password: string, token: string }) { 142 | const loadingNotification = { content: 'Resetting password', showProgress: true }; 143 | try { 144 | commitAddNotification(context, loadingNotification); 145 | const response = (await Promise.all([ 146 | api.resetPassword(payload.password, payload.token), 147 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)), 148 | ]))[0]; 149 | commitRemoveNotification(context, loadingNotification); 150 | commitAddNotification(context, { content: 'Password successfully reset', color: 'success' }); 151 | await dispatchLogOut(context); 152 | } catch (error) { 153 | commitRemoveNotification(context, loadingNotification); 154 | commitAddNotification(context, { color: 'error', content: 'Error resetting password' }); 155 | } 156 | }, 157 | }; 158 | 159 | const { dispatch } = getStoreAccessors(''); 160 | 161 | export const dispatchCheckApiError = dispatch(actions.actionCheckApiError); 162 | export const dispatchCheckLoggedIn = dispatch(actions.actionCheckLoggedIn); 163 | export const dispatchGetUserProfile = dispatch(actions.actionGetUserProfile); 164 | export const dispatchLogIn = dispatch(actions.actionLogIn); 165 | export const dispatchLogOut = dispatch(actions.actionLogOut); 166 | export const dispatchUserLogOut = dispatch(actions.actionUserLogOut); 167 | export const dispatchRemoveLogIn = dispatch(actions.actionRemoveLogIn); 168 | export const dispatchRouteLoggedIn = dispatch(actions.actionRouteLoggedIn); 169 | export const dispatchRouteLogOut = dispatch(actions.actionRouteLogOut); 170 | export const dispatchUpdateUserProfile = dispatch(actions.actionUpdateUserProfile); 171 | export const dispatchRemoveNotification = dispatch(actions.removeNotification); 172 | export const dispatchPasswordRecovery = dispatch(actions.passwordRecovery); 173 | export const dispatchResetPassword = dispatch(actions.resetPassword); 174 | -------------------------------------------------------------------------------- /frontend/src/store/main/getters.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | hasAdminAccess: (state: MainState) => { 7 | return ( 8 | state.userProfile && 9 | state.userProfile.is_superuser && state.userProfile.is_active); 10 | }, 11 | loginError: (state: MainState) => state.logInError, 12 | dashboardShowDrawer: (state: MainState) => state.dashboardShowDrawer, 13 | dashboardMiniDrawer: (state: MainState) => state.dashboardMiniDrawer, 14 | userProfile: (state: MainState) => state.userProfile, 15 | token: (state: MainState) => state.token, 16 | isLoggedIn: (state: MainState) => state.isLoggedIn, 17 | firstNotification: (state: MainState) => state.notifications.length > 0 && state.notifications[0], 18 | }; 19 | 20 | const {read} = getStoreAccessors(''); 21 | 22 | export const readDashboardMiniDrawer = read(getters.dashboardMiniDrawer); 23 | export const readDashboardShowDrawer = read(getters.dashboardShowDrawer); 24 | export const readHasAdminAccess = read(getters.hasAdminAccess); 25 | export const readIsLoggedIn = read(getters.isLoggedIn); 26 | export const readLoginError = read(getters.loginError); 27 | export const readToken = read(getters.token); 28 | export const readUserProfile = read(getters.userProfile); 29 | export const readFirstNotification = read(getters.firstNotification); 30 | -------------------------------------------------------------------------------- /frontend/src/store/main/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { MainState } from './state'; 5 | 6 | const defaultState: MainState = { 7 | isLoggedIn: null, 8 | token: '', 9 | logInError: false, 10 | userProfile: null, 11 | dashboardMiniDrawer: false, 12 | dashboardShowDrawer: true, 13 | notifications: [], 14 | }; 15 | 16 | export const mainModule = { 17 | state: defaultState, 18 | mutations, 19 | actions, 20 | getters, 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/store/main/mutations.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | import { MainState, AppNotification } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | 7 | export const mutations = { 8 | setToken(state: MainState, payload: string) { 9 | state.token = payload; 10 | }, 11 | setLoggedIn(state: MainState, payload: boolean) { 12 | state.isLoggedIn = payload; 13 | }, 14 | setLogInError(state: MainState, payload: boolean) { 15 | state.logInError = payload; 16 | }, 17 | setUserProfile(state: MainState, payload: IUserProfile) { 18 | state.userProfile = payload; 19 | }, 20 | setDashboardMiniDrawer(state: MainState, payload: boolean) { 21 | state.dashboardMiniDrawer = payload; 22 | }, 23 | setDashboardShowDrawer(state: MainState, payload: boolean) { 24 | state.dashboardShowDrawer = payload; 25 | }, 26 | addNotification(state: MainState, payload: AppNotification) { 27 | state.notifications.push(payload); 28 | }, 29 | removeNotification(state: MainState, payload: AppNotification) { 30 | state.notifications = state.notifications.filter((notification) => notification !== payload); 31 | }, 32 | }; 33 | 34 | const {commit} = getStoreAccessors(''); 35 | 36 | export const commitSetDashboardMiniDrawer = commit(mutations.setDashboardMiniDrawer); 37 | export const commitSetDashboardShowDrawer = commit(mutations.setDashboardShowDrawer); 38 | export const commitSetLoggedIn = commit(mutations.setLoggedIn); 39 | export const commitSetLogInError = commit(mutations.setLogInError); 40 | export const commitSetToken = commit(mutations.setToken); 41 | export const commitSetUserProfile = commit(mutations.setUserProfile); 42 | export const commitAddNotification = commit(mutations.addNotification); 43 | export const commitRemoveNotification = commit(mutations.removeNotification); 44 | -------------------------------------------------------------------------------- /frontend/src/store/main/state.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | 3 | export interface AppNotification { 4 | content: string; 5 | color?: string; 6 | showProgress?: boolean; 7 | } 8 | 9 | export interface MainState { 10 | token: string; 11 | isLoggedIn: boolean | null; 12 | logInError: boolean; 13 | userProfile: IUserProfile | null; 14 | dashboardMiniDrawer: boolean; 15 | dashboardShowDrawer: boolean; 16 | notifications: AppNotification[]; 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/store/state.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './main/state'; 2 | 3 | export interface State { 4 | main: MainState; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const getLocalToken = () => localStorage.getItem('token'); 2 | 3 | export const saveLocalToken = (token: string) => localStorage.setItem('token', token); 4 | 5 | export const removeLocalToken = () => localStorage.removeItem('token'); 6 | -------------------------------------------------------------------------------- /frontend/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 56 | 57 | 59 | -------------------------------------------------------------------------------- /frontend/src/views/PasswordRecovery.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 50 | 51 | 53 | -------------------------------------------------------------------------------- /frontend/src/views/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 85 | -------------------------------------------------------------------------------- /frontend/src/views/main/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /frontend/src/views/main/Main.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 183 | -------------------------------------------------------------------------------- /frontend/src/views/main/Start.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /frontend/src/views/main/admin/Admin.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /frontend/src/views/main/admin/AdminUsers.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 84 | -------------------------------------------------------------------------------- /frontend/src/views/main/admin/CreateUser.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 98 | -------------------------------------------------------------------------------- /frontend/src/views/main/admin/EditUser.vue: -------------------------------------------------------------------------------- 1 | 100 | 101 | 164 | -------------------------------------------------------------------------------- /frontend/src/views/main/profile/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | -------------------------------------------------------------------------------- /frontend/src/views/main/profile/UserProfileEdit.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 98 | -------------------------------------------------------------------------------- /frontend/src/views/main/profile/UserProfileEditPassword.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 87 | -------------------------------------------------------------------------------- /frontend/tests/unit/upload-button.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import UploadButton from '@/components/UploadButton.vue'; 3 | import '@/plugins/vuetify'; 4 | 5 | describe('UploadButton.vue', () => { 6 | it('renders props.title when passed', () => { 7 | const title = 'upload a file'; 8 | const wrapper = shallowMount(UploadButton, { 9 | slots: { 10 | default: title, 11 | }, 12 | }); 13 | expect(wrapper.text()).toMatch(title); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env", 17 | "jest" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**" 9 | ] 10 | }, 11 | "rules": { 12 | "quotemark": [true, "single"], 13 | "indent": [true, "spaces", 2], 14 | "interface-name": false, 15 | "ordered-imports": false, 16 | "object-literal-sort-keys": false, 17 | "no-consecutive-blank-lines": false 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Fix Vuex-typescript in prod: https://github.com/istrib/vuex-typescript/issues/13#issuecomment-409869231 3 | configureWebpack: (config) => { 4 | if (process.env.NODE_ENV === 'production') { 5 | config.optimization.minimizer[0].options.terserOptions = Object.assign( 6 | {}, 7 | config.optimization.minimizer[0].options.terserOptions, 8 | { 9 | ecma: 5, 10 | compress: { 11 | keep_fnames: true, 12 | }, 13 | warnings: false, 14 | mangle: { 15 | keep_fnames: true, 16 | }, 17 | }, 18 | ); 19 | } 20 | }, 21 | chainWebpack: config => { 22 | config.module 23 | .rule('vue') 24 | .use('vue-loader') 25 | .loader('vue-loader') 26 | .tap(options => Object.assign(options, { 27 | transformAssetUrls: { 28 | 'v-img': ['src', 'lazy-src'], 29 | 'v-card': 'src', 30 | 'v-card-media': 'src', 31 | 'v-responsive': 'src', 32 | } 33 | })); 34 | }, 35 | } 36 | --------------------------------------------------------------------------------