🚚 Move new-frontend to frontend (#652)

This commit is contained in:
Alejandra
2024-03-08 19:23:54 +01:00
committed by GitHub
parent 3b44537361
commit 9d703df254
97 changed files with 8 additions and 8 deletions

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

1
frontend/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost

18
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
frontend/.prettierignore Normal file
View File

@@ -0,0 +1 @@
src/client/

8
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"bracketSpacing": true,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}

21
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Stage 0, "build-stage", based on Node.js, to build and compile the frontend
FROM node:20 as build-stage
WORKDIR /app
COPY package*.json /app/
RUN npm install
COPY ./ /app/
RUN npm run build
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
FROM nginx:1
COPY --from=build-stage /app/dist/ /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf

52
frontend/README.md Normal file
View File

@@ -0,0 +1,52 @@
# FastAPI Project - Frontend
## Frontend development
* Enter the `frontend` directory, install the NPM packages and start the live server using the `npm` scripts:
```bash
cd frontend
npm install
npm run dev
```
Then open your browser at http://localhost:5173/.
Notice that this live server is not running inside Docker, it is for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But compiling the image at every change will not be as productive as running the local development server with live reload.
Check the file `package.json` to see other available options.
### Removing the frontend
If you are developing an API-only app and want to remove the frontend, you can do it easily:
* Remove the `./frontend` directory.
* In the `docker-compose.yml` file, remove the whole service / section `frontend`.
* In the `docker-compose.override.yml` file, remove the whole service / section `frontend`.
Done, you have a frontend-less (api-only) app. 🤓
---
If you want, you can also remove the `FRONTEND` environment variables from:
* `.env`
* `./scripts/*.sh`
But it would be only to clean them up, leaving them won't really have any effect either way.
## Generate Client
* Start the Docker Compose stack.
* Download the OpenAPI JSON file from `http://localhost/api/v1/openapi.json` and copy it to a new file `openapi.json` next to the `package.json` file.
* To simplify the names in the generated frontend client code, modifying the `openapi.json` file, run:
```bash
node modify-openapi-operationids.js
```
* To generate or update the frontend client, run:
```bash
npm run generate-client
```

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Full Stack Project Generator</title>
<link rel="icon" type="image/x-icon" href="./src/assets/images/favicon.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
import * as fs from "fs";
const filePath = "./openapi.json";
fs.readFile(filePath, (err, data) => {
const openapiContent = JSON.parse(data);
if (err) throw err;
const paths = openapiContent.paths;
Object.keys(paths).forEach((pathKey) => {
const pathData = paths[pathKey];
Object.keys(pathData).forEach((method) => {
const operation = pathData[method];
if (operation.tags && operation.tags.length > 0) {
const tag = operation.tags[0];
const operationId = operation.operationId;
const toRemove = `${tag}-`;
if (operationId.startsWith(toRemove)) {
const newOperationId = operationId.substring(toRemove.length);
operation.operationId = newOperationId;
}
}
});
});
fs.writeFile(filePath, JSON.stringify(openapiContent, null, 2), (err) => {
if (err) throw err;
});
});

View File

@@ -0,0 +1,9 @@
location /api {
return 404;
}
location /docs {
return 404;
}
location /redoc {
return 404;
}

11
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html =404;
}
include /etc/nginx/extra-conf.d/*.conf;
}

8866
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
frontend/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint ./src --ext .ts,.tsx --fix",
"format": "prettier --write ./src/**/*.{ts,tsx,js,jsx,json,md} --ignore-path .prettierignore",
"preview": "vite preview",
"generate-client": "openapi --input ./openapi.json --useOptions --useUnionTypes --output ./src/client --client axios --exportSchemas true"
},
"dependencies": {
"@chakra-ui/icons": "2.1.1",
"@chakra-ui/react": "2.8.2",
"@emotion/react": "11.11.3",
"@emotion/styled": "11.11.0",
"@tanstack/react-router": "1.19.1",
"axios": "1.6.2",
"form-data": "4.0.0",
"framer-motion": "10.16.16",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "7.49.3",
"react-icons": "5.0.1",
"react-query": "3.39.3",
"zustand": "4.5.0"
},
"devDependencies": {
"@tanstack/router-devtools": "1.19.1",
"@tanstack/router-vite-plugin": "1.19.0",
"@types/node": "20.10.5",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"openapi-typescript-codegen": "0.25.0",
"prettier": "3.2.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="svg8"
version="1.1"
viewBox="0 0 346.52395 63.977134"
height="63.977139mm"
width="346.52396mm"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g2106"
transform="matrix(0.96564264,0,0,0.96251987,-899.3295,194.86874)">
<circle
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.141404;stop-color:#000000"
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
cx="964.56165"
cy="-169.22266"
r="33.234192" />
<path
id="rect1249-6-3-4-4-3-6-6-1-2"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.146895;stop-color:#000000"
d="m 962.2685,-187.40837 -6.64403,14.80375 -3.03599,6.76393 -6.64456,14.80375 30.59142,-21.56768 h -14.35312 l 20.99715,-14.80375 z" />
</g>
<path
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#009688;stroke-width:1.99288"
d="M 89.523017,59.410606 V 4.1680399 H 122.84393 V 10.784393 H 97.255382 V 27.44485 h 22.718808 v 6.536638 H 97.255382 v 25.429118 z m 52.292963,-5.340912 q 2.6306,0 4.62348,-0.07972 2.07259,-0.15943 3.42774,-0.47829 V 41.155848 q -0.79715,-0.398576 -2.63059,-0.637721 -1.75374,-0.31886 -4.30462,-0.31886 -1.67402,0 -3.58718,0.239145 -1.83345,0.239145 -3.42775,1.036296 -1.51459,0.717436 -2.55088,2.072593 -1.0363,1.275442 -1.0363,3.427749 0,3.985755 2.55089,5.580058 2.55088,1.514586 6.93521,1.514586 z m -0.63772,-37.147238 q 4.46404,0 7.49322,1.195727 3.10889,1.116011 4.94233,3.268319 1.91317,2.072593 2.71032,5.022052 0.79715,2.869743 0.79715,6.377208 V 58.69317 q -0.95658,0.159431 -2.71031,0.478291 -1.67402,0.239145 -3.82633,0.478291 -2.15231,0.239145 -4.70319,0.398575 -2.47117,0.239146 -4.94234,0.239146 -3.50746,0 -6.45692,-0.717436 -2.94946,-0.717436 -5.10177,-2.232023 -2.1523,-1.594302 -3.34803,-4.145186 -1.19573,-2.550883 -1.19573,-6.138063 0,-3.427749 1.35516,-5.898917 1.43487,-2.471168 3.82632,-3.985755 2.39146,-1.514587 5.58006,-2.232023 3.18861,-0.717436 6.69607,-0.717436 1.11601,0 2.31174,0.15943 1.19572,0.07972 2.23202,0.31886 1.11601,0.159431 1.91316,0.318861 0.79715,0.15943 1.11601,0.239145 v -2.072593 q 0,-1.833447 -0.39857,-3.587179 -0.39858,-1.833448 -1.43487,-3.188604 -1.0363,-1.434872 -2.86975,-2.232023 -1.75373,-0.876866 -4.62347,-0.876866 -3.6669,0 -6.45693,0.558005 -2.71031,0.478291 -4.06547,1.036297 l -0.87686,-6.138063 q 1.43487,-0.637721 4.7829,-1.195727 3.34804,-0.637721 7.25408,-0.637721 z m 37.86462,37.147238 q 4.54377,0 6.69607,-1.195726 2.23203,-1.195727 2.23203,-3.826325 0,-2.710314 -2.15231,-4.304616 -2.15231,-1.594302 -7.09465,-3.587179 -2.39145,-0.956581 -4.62347,-1.913163 -2.15231,-1.036296 -3.74661,-2.391453 -1.5943,-1.355157 -2.55088,-3.268319 -0.95659,-1.913163 -0.95659,-4.703191 0,-5.500342 4.06547,-8.688946 4.06547,-3.26832 11.0804,-3.26832 1.75374,0 3.50747,0.239146 1.75373,0.15943 3.26832,0.47829 1.51458,0.239146 2.6306,0.558006 1.19572,0.31886 1.83344,0.558006 l -1.35515,6.377208 q -1.19573,-0.637721 -3.74661,-1.275442 -2.55089,-0.717436 -6.13807,-0.717436 -3.10889,0 -5.42062,1.275442 -2.31174,1.195727 -2.31174,3.826325 0,1.355157 0.47829,2.391453 0.55801,1.036296 1.5943,1.913163 1.11601,0.797151 2.71031,1.514587 1.59431,0.717436 3.82633,1.514587 2.94946,1.116011 5.2612,2.232022 2.31173,1.036297 3.90604,2.471169 1.67401,1.434871 2.55088,3.507464 0.87687,1.992878 0.87687,4.942337 0,5.739487 -4.30462,8.688946 -4.2249,2.949459 -12.1167,2.949459 -5.50034,0 -8.60923,-0.956582 -3.10889,-0.876866 -4.2249,-1.355156 l 1.35516,-6.377209 q 1.27544,0.478291 4.06547,1.434872 2.79003,0.956581 7.4135,0.956581 z m 32.84256,-36.110941 h 15.70387 v 6.217778 h -15.70387 v 19.131625 q 0,3.108889 0.47829,5.181481 0.47829,1.992878 1.43487,3.188604 0.95658,1.116012 2.39145,1.594302 1.43487,0.478291 3.34804,0.478291 3.34803,0 5.34091,-0.717436 2.07259,-0.797151 2.86974,-1.116011 l 1.43487,6.138063 q -1.11601,0.558005 -3.90604,1.355156 -2.79003,0.876867 -6.37721,0.876867 -4.2249,0 -7.01492,-1.036297 -2.71032,-1.116011 -4.38434,-3.268319 -1.67401,-2.152308 -2.39145,-5.261197 -0.63772,-3.188604 -0.63772,-7.333789 V 6.4000628 l 7.41351,-1.2754417 z m 62.49652,41.451853 q -1.35516,-3.587179 -2.55088,-7.014929 -1.19573,-3.507464 -2.47117,-7.094644 h -25.03054 l -5.02205,14.109573 h -8.05123 q 3.18861,-8.768661 5.97863,-16.182166 2.79003,-7.493219 5.42063,-14.189288 2.71031,-6.696069 5.34091,-12.754416 2.6306,-6.138063 5.50034,-12.1166961 h 7.09465 q 2.86974,5.9786331 5.50034,12.1166961 2.6306,6.058347 5.2612,12.754416 2.71031,6.696069 5.50034,14.189288 2.79003,7.413505 5.97863,16.182166 z m -7.25407,-20.486781 q -2.55089,-6.935214 -5.10177,-13.392137 -2.47117,-6.536639 -5.18148,-12.515272 -2.79003,5.978633 -5.34091,12.515272 -2.47117,6.456923 -4.94234,13.392137 z M 304.99242,3.6100342 q 11.6384,0 17.85618,4.4640458 6.29749,4.384331 6.29749,13.152992 0,4.782906 -1.75373,8.210656 -1.67402,3.348034 -4.94234,5.500342 -3.1886,2.072592 -7.81208,3.029174 -4.62347,0.956581 -10.44268,0.956581 h -6.13806 v 20.486781 h -7.73236 V 4.9651909 q 3.26832,-0.797151 7.25407,-1.0362963 4.06547,-0.3188604 7.41351,-0.3188604 z m 0.63772,6.7757838 q -4.94234,0 -7.57294,0.239145 v 21.682508 h 5.8192 q 3.98576,0 7.17436,-0.47829 3.18861,-0.558006 5.34092,-1.753733 2.23202,-1.275441 3.42774,-3.427749 1.19573,-2.152308 1.19573,-5.500342 0,-3.188604 -1.27544,-5.261197 -1.19573,-2.072593 -3.34803,-3.268319 -2.0726,-1.275442 -4.86263,-1.753732 -2.79002,-0.478291 -5.89891,-0.478291 z M 338.7916,4.1680399 h 7.73237 V 59.410606 h -7.73237 z"
id="text979"
aria-label="FastAPI" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,25 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
export class ApiError extends Error {
public readonly url: string;
public readonly status: number;
public readonly statusText: string;
public readonly body: any;
public readonly request: ApiRequestOptions;
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
super(message);
this.name = 'ApiError';
this.url = response.url;
this.status = response.status;
this.statusText = response.statusText;
this.body = response.body;
this.request = request;
}
}

View File

@@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiRequestOptions = {
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
readonly url: string;
readonly path?: Record<string, any>;
readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>;
readonly query?: Record<string, any>;
readonly formData?: Record<string, any>;
readonly body?: any;
readonly mediaType?: string;
readonly responseHeader?: string;
readonly errors?: Record<number, string>;
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiResult = {
readonly url: string;
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly body: any;
};

View File

@@ -0,0 +1,131 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancelError';
}
public get isCancelled(): boolean {
return true;
}
}
export interface OnCancel {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;
(cancelHandler: () => void): void;
}
export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void,
onCancel: OnCancel
) => void
) {
this.#isResolved = false;
this.#isRejected = false;
this.#isCancelled = false;
this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isResolved = true;
this.#resolve?.(value);
};
const onReject = (reason?: any): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
this.#reject?.(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return "Cancellable Promise";
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
this.#reject?.(new CancelError('Request aborted'));
}
public get isCancelled(): boolean {
return this.#isCancelled;
}
}

View File

@@ -0,0 +1,32 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers = Record<string, string>;
export type OpenAPIConfig = {
BASE: string;
VERSION: string;
WITH_CREDENTIALS: boolean;
CREDENTIALS: 'include' | 'omit' | 'same-origin';
TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined;
PASSWORD?: string | Resolver<string> | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined;
ENCODE_PATH?: ((path: string) => string) | undefined;
};
export const OpenAPI: OpenAPIConfig = {
BASE: '',
VERSION: '0.1.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};

View File

@@ -0,0 +1,319 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
import FormData from 'form-data';
import { ApiError } from './ApiError';
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
import { CancelablePromise } from './CancelablePromise';
import type { OnCancel } from './CancelablePromise';
import type { OpenAPIConfig } from './OpenAPI';
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null;
};
export const isString = (value: any): value is string => {
return typeof value === 'string';
};
export const isStringWithValue = (value: any): value is string => {
return isString(value) && value !== '';
};
export const isBlob = (value: any): value is Blob => {
return (
typeof value === 'object' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
typeof value.arrayBuffer === 'function' &&
typeof value.constructor === 'function' &&
typeof value.constructor.name === 'string' &&
/^(Blob|File)$/.test(value.constructor.name) &&
/^(Blob|File)$/.test(value[Symbol.toStringTag])
);
};
export const isFormData = (value: any): value is FormData => {
return value instanceof FormData;
};
export const isSuccess = (status: number): boolean => {
return status >= 200 && status < 300;
};
export const base64 = (str: string): string => {
try {
return btoa(str);
} catch (err) {
// @ts-ignore
return Buffer.from(str).toString('base64');
}
};
export const getQueryString = (params: Record<string, any>): string => {
const qs: string[] = [];
const append = (key: string, value: any) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
};
const process = (key: string, value: any) => {
if (isDefined(value)) {
if (Array.isArray(value)) {
value.forEach(v => {
process(key, v);
});
} else if (typeof value === 'object') {
Object.entries(value).forEach(([k, v]) => {
process(`${key}[${k}]`, v);
});
} else {
append(key, value);
}
}
};
Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
}
return '';
};
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url
.replace('{api-version}', config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group]));
}
return substring;
});
const url = `${config.BASE}${path}`;
if (options.query) {
return `${url}${getQueryString(options.query)}`;
}
return url;
};
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
if (options.formData) {
const formData = new FormData();
const process = (key: string, value: any) => {
if (isString(value) || isBlob(value)) {
formData.append(key, value);
} else {
formData.append(key, JSON.stringify(value));
}
};
Object.entries(options.formData)
.filter(([_, value]) => isDefined(value))
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => process(key, v));
} else {
process(key, value);
}
});
return formData;
}
return undefined;
};
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => {
if (typeof resolver === 'function') {
return (resolver as Resolver<T>)(options);
}
return resolver;
};
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => {
const token = await resolve(options, config.TOKEN);
const username = await resolve(options, config.USERNAME);
const password = await resolve(options, config.PASSWORD);
const additionalHeaders = await resolve(options, config.HEADERS);
const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {}
const headers = Object.entries({
Accept: 'application/json',
...additionalHeaders,
...options.headers,
...formHeaders,
})
.filter(([_, value]) => isDefined(value))
.reduce((headers, [key, value]) => ({
...headers,
[key]: String(value),
}), {} as Record<string, string>);
if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
}
}
return headers;
};
export const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body) {
return options.body;
}
return undefined;
};
export const sendRequest = async <T>(
config: OpenAPIConfig,
options: ApiRequestOptions,
url: string,
body: any,
formData: FormData | undefined,
headers: Record<string, string>,
onCancel: OnCancel,
axiosClient: AxiosInstance
): Promise<AxiosResponse<T>> => {
const source = axios.CancelToken.source();
const requestConfig: AxiosRequestConfig = {
url,
headers,
data: body ?? formData,
method: options.method,
withCredentials: config.WITH_CREDENTIALS,
cancelToken: source.token,
};
onCancel(() => source.cancel('The user aborted a request.'));
try {
return await axiosClient.request(requestConfig);
} catch (error) {
const axiosError = error as AxiosError<T>;
if (axiosError.response) {
return axiosError.response;
}
throw error;
}
};
export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => {
if (responseHeader) {
const content = response.headers[responseHeader];
if (isString(content)) {
return content;
}
}
return undefined;
};
export const getResponseBody = (response: AxiosResponse<any>): any => {
if (response.status !== 204) {
return response.data;
}
return undefined;
};
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
const errors: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
...options.errors,
}
const error = errors[result.status];
if (error) {
throw new ApiError(options, result, error);
}
if (!result.ok) {
const errorStatus = result.status ?? 'unknown';
const errorStatusText = result.statusText ?? 'unknown';
const errorBody = (() => {
try {
return JSON.stringify(result.body, null, 2);
} catch (e) {
return undefined;
}
})();
throw new ApiError(options, result,
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
);
}
};
/**
* Request method
* @param config The OpenAPI configuration object
* @param options The request options from the service
* @param axiosClient The axios client instance to use
* @returns CancelablePromise<T>
* @throws ApiError
*/
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => {
return new CancelablePromise(async (resolve, reject, onCancel) => {
try {
const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options, formData);
if (!onCancel.isCancelled) {
const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient);
const responseBody = getResponseBody(response);
const responseHeader = getResponseHeader(response, options.responseHeader);
const result: ApiResult = {
url,
ok: isSuccess(response.status),
status: response.status,
statusText: response.statusText,
body: responseHeader ?? responseBody,
};
catchErrorCodes(options, result);
resolve(result.body);
}
} catch (error) {
reject(error);
}
});
};

View File

@@ -0,0 +1,49 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export { ApiError } from './core/ApiError';
export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { Body_login_login_access_token } from './models/Body_login_login_access_token';
export type { HTTPValidationError } from './models/HTTPValidationError';
export type { ItemCreate } from './models/ItemCreate';
export type { ItemOut } from './models/ItemOut';
export type { ItemsOut } from './models/ItemsOut';
export type { ItemUpdate } from './models/ItemUpdate';
export type { Message } from './models/Message';
export type { NewPassword } from './models/NewPassword';
export type { Token } from './models/Token';
export type { UpdatePassword } from './models/UpdatePassword';
export type { UserCreate } from './models/UserCreate';
export type { UserCreateOpen } from './models/UserCreateOpen';
export type { UserOut } from './models/UserOut';
export type { UsersOut } from './models/UsersOut';
export type { UserUpdate } from './models/UserUpdate';
export type { UserUpdateMe } from './models/UserUpdateMe';
export type { ValidationError } from './models/ValidationError';
export { $Body_login_login_access_token } from './schemas/$Body_login_login_access_token';
export { $HTTPValidationError } from './schemas/$HTTPValidationError';
export { $ItemCreate } from './schemas/$ItemCreate';
export { $ItemOut } from './schemas/$ItemOut';
export { $ItemsOut } from './schemas/$ItemsOut';
export { $ItemUpdate } from './schemas/$ItemUpdate';
export { $Message } from './schemas/$Message';
export { $NewPassword } from './schemas/$NewPassword';
export { $Token } from './schemas/$Token';
export { $UpdatePassword } from './schemas/$UpdatePassword';
export { $UserCreate } from './schemas/$UserCreate';
export { $UserCreateOpen } from './schemas/$UserCreateOpen';
export { $UserOut } from './schemas/$UserOut';
export { $UsersOut } from './schemas/$UsersOut';
export { $UserUpdate } from './schemas/$UserUpdate';
export { $UserUpdateMe } from './schemas/$UserUpdateMe';
export { $ValidationError } from './schemas/$ValidationError';
export { ItemsService } from './services/ItemsService';
export { LoginService } from './services/LoginService';
export { UsersService } from './services/UsersService';
export { UtilsService } from './services/UtilsService';

View File

@@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Body_login_login_access_token = {
grant_type?: (string | null);
username: string;
password: string;
scope?: string;
client_id?: (string | null);
client_secret?: (string | null);
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ValidationError } from './ValidationError';
export type HTTPValidationError = {
detail?: Array<ValidationError>;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ItemCreate = {
title: string;
description?: (string | null);
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ItemOut = {
title: string;
description?: (string | null);
id: number;
owner_id: number;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ItemUpdate = {
title?: (string | null);
description?: (string | null);
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ItemOut } from './ItemOut';
export type ItemsOut = {
data: Array<ItemOut>;
count: number;
};

View File

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Message = {
message: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type NewPassword = {
token: string;
new_password: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Token = {
access_token: string;
token_type?: string;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdatePassword = {
current_password: string;
new_password: string;
};

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UserCreate = {
email: string;
is_active?: boolean;
is_superuser?: boolean;
full_name?: (string | null);
password: string;
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UserCreateOpen = {
email: string;
password: string;
full_name?: (string | null);
};

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UserOut = {
email: string;
is_active?: boolean;
is_superuser?: boolean;
full_name?: (string | null);
id: number;
};

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UserUpdate = {
email?: (string | null);
is_active?: boolean;
is_superuser?: boolean;
full_name?: (string | null);
password?: (string | null);
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UserUpdateMe = {
full_name?: (string | null);
email?: (string | null);
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { UserOut } from './UserOut';
export type UsersOut = {
data: Array<UserOut>;
count: number;
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ValidationError = {
loc: Array<(string | number)>;
msg: string;
type: string;
};

View File

@@ -0,0 +1,44 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $Body_login_login_access_token = {
properties: {
grant_type: {
type: 'any-of',
contains: [{
type: 'string',
pattern: 'password',
}, {
type: 'null',
}],
},
username: {
type: 'string',
isRequired: true,
},
password: {
type: 'string',
isRequired: true,
},
scope: {
type: 'string',
},
client_id: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
client_secret: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
},
} as const;

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $HTTPValidationError = {
properties: {
detail: {
type: 'array',
contains: {
type: 'ValidationError',
},
},
},
} as const;

View File

@@ -0,0 +1,20 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ItemCreate = {
properties: {
title: {
type: 'string',
isRequired: true,
},
description: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
},
} as const;

View File

@@ -0,0 +1,28 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ItemOut = {
properties: {
title: {
type: 'string',
isRequired: true,
},
description: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
id: {
type: 'number',
isRequired: true,
},
owner_id: {
type: 'number',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,24 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ItemUpdate = {
properties: {
title: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
description: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
},
} as const;

View File

@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ItemsOut = {
properties: {
data: {
type: 'array',
contains: {
type: 'ItemOut',
},
isRequired: true,
},
count: {
type: 'number',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $Message = {
properties: {
message: {
type: 'string',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,16 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $NewPassword = {
properties: {
token: {
type: 'string',
isRequired: true,
},
new_password: {
type: 'string',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $Token = {
properties: {
access_token: {
type: 'string',
isRequired: true,
},
token_type: {
type: 'string',
},
},
} as const;

View File

@@ -0,0 +1,16 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UpdatePassword = {
properties: {
current_password: {
type: 'string',
isRequired: true,
},
new_password: {
type: 'string',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,30 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UserCreate = {
properties: {
email: {
type: 'string',
isRequired: true,
},
is_active: {
type: 'boolean',
},
is_superuser: {
type: 'boolean',
},
full_name: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
password: {
type: 'string',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,24 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UserCreateOpen = {
properties: {
email: {
type: 'string',
isRequired: true,
},
password: {
type: 'string',
isRequired: true,
},
full_name: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
},
} as const;

View File

@@ -0,0 +1,30 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UserOut = {
properties: {
email: {
type: 'string',
isRequired: true,
},
is_active: {
type: 'boolean',
},
is_superuser: {
type: 'boolean',
},
full_name: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
id: {
type: 'number',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,38 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UserUpdate = {
properties: {
email: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
is_active: {
type: 'boolean',
},
is_superuser: {
type: 'boolean',
},
full_name: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
password: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
},
} as const;

View File

@@ -0,0 +1,24 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UserUpdateMe = {
properties: {
full_name: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
email: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
},
} as const;

View File

@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UsersOut = {
properties: {
data: {
type: 'array',
contains: {
type: 'UserOut',
},
isRequired: true,
},
count: {
type: 'number',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,28 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ValidationError = {
properties: {
loc: {
type: 'array',
contains: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'number',
}],
},
isRequired: true,
},
msg: {
type: 'string',
isRequired: true,
},
type: {
type: 'string',
isRequired: true,
},
},
} as const;

View File

@@ -0,0 +1,138 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ItemCreate } from '../models/ItemCreate';
import type { ItemOut } from '../models/ItemOut';
import type { ItemsOut } from '../models/ItemsOut';
import type { ItemUpdate } from '../models/ItemUpdate';
import type { Message } from '../models/Message';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class ItemsService {
/**
* Read Items
* Retrieve items.
* @returns ItemsOut Successful Response
* @throws ApiError
*/
public static readItems({
skip,
limit = 100,
}: {
skip?: number,
limit?: number,
}): CancelablePromise<ItemsOut> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/items/',
query: {
'skip': skip,
'limit': limit,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Create Item
* Create new item.
* @returns ItemOut Successful Response
* @throws ApiError
*/
public static createItem({
requestBody,
}: {
requestBody: ItemCreate,
}): CancelablePromise<ItemOut> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/items/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Read Item
* Get item by ID.
* @returns ItemOut Successful Response
* @throws ApiError
*/
public static readItem({
id,
}: {
id: number,
}): CancelablePromise<ItemOut> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/items/{id}',
path: {
'id': id,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Update Item
* Update an item.
* @returns ItemOut Successful Response
* @throws ApiError
*/
public static updateItem({
id,
requestBody,
}: {
id: number,
requestBody: ItemUpdate,
}): CancelablePromise<ItemOut> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/v1/items/{id}',
path: {
'id': id,
},
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Item
* Delete an item.
* @returns Message Successful Response
* @throws ApiError
*/
public static deleteItem({
id,
}: {
id: number,
}): CancelablePromise<Message> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/items/{id}',
path: {
'id': id,
},
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -0,0 +1,97 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Body_login_login_access_token } from '../models/Body_login_login_access_token';
import type { Message } from '../models/Message';
import type { NewPassword } from '../models/NewPassword';
import type { Token } from '../models/Token';
import type { UserOut } from '../models/UserOut';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class LoginService {
/**
* Login Access Token
* OAuth2 compatible token login, get an access token for future requests
* @returns Token Successful Response
* @throws ApiError
*/
public static loginAccessToken({
formData,
}: {
formData: Body_login_login_access_token,
}): CancelablePromise<Token> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/login/access-token',
formData: formData,
mediaType: 'application/x-www-form-urlencoded',
errors: {
422: `Validation Error`,
},
});
}
/**
* Test Token
* Test access token
* @returns UserOut Successful Response
* @throws ApiError
*/
public static testToken(): CancelablePromise<UserOut> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/login/test-token',
});
}
/**
* Recover Password
* Password Recovery
* @returns Message Successful Response
* @throws ApiError
*/
public static recoverPassword({
email,
}: {
email: string,
}): CancelablePromise<Message> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/password-recovery/{email}',
path: {
'email': email,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Reset Password
* Reset password
* @returns Message Successful Response
* @throws ApiError
*/
public static resetPassword({
requestBody,
}: {
requestBody: NewPassword,
}): CancelablePromise<Message> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/reset-password/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -0,0 +1,220 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Message } from '../models/Message';
import type { UpdatePassword } from '../models/UpdatePassword';
import type { UserCreate } from '../models/UserCreate';
import type { UserCreateOpen } from '../models/UserCreateOpen';
import type { UserOut } from '../models/UserOut';
import type { UsersOut } from '../models/UsersOut';
import type { UserUpdate } from '../models/UserUpdate';
import type { UserUpdateMe } from '../models/UserUpdateMe';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class UsersService {
/**
* Read Users
* Retrieve users.
* @returns UsersOut Successful Response
* @throws ApiError
*/
public static readUsers({
skip,
limit = 100,
}: {
skip?: number,
limit?: number,
}): CancelablePromise<UsersOut> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/users/',
query: {
'skip': skip,
'limit': limit,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Create User
* Create new user.
* @returns UserOut Successful Response
* @throws ApiError
*/
public static createUser({
requestBody,
}: {
requestBody: UserCreate,
}): CancelablePromise<UserOut> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/users/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Read User Me
* Get current user.
* @returns UserOut Successful Response
* @throws ApiError
*/
public static readUserMe(): CancelablePromise<UserOut> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/users/me',
});
}
/**
* Update User Me
* Update own user.
* @returns UserOut Successful Response
* @throws ApiError
*/
public static updateUserMe({
requestBody,
}: {
requestBody: UserUpdateMe,
}): CancelablePromise<UserOut> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/api/v1/users/me',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Update Password Me
* Update own password.
* @returns Message Successful Response
* @throws ApiError
*/
public static updatePasswordMe({
requestBody,
}: {
requestBody: UpdatePassword,
}): CancelablePromise<Message> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/api/v1/users/me/password',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Create User Open
* Create new user without the need to be logged in.
* @returns UserOut Successful Response
* @throws ApiError
*/
public static createUserOpen({
requestBody,
}: {
requestBody: UserCreateOpen,
}): CancelablePromise<UserOut> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/users/open',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Read User By Id
* Get a specific user by id.
* @returns UserOut Successful Response
* @throws ApiError
*/
public static readUserById({
userId,
}: {
userId: number,
}): CancelablePromise<UserOut> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/users/{user_id}',
path: {
'user_id': userId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Update User
* Update a user.
* @returns UserOut Successful Response
* @throws ApiError
*/
public static updateUser({
userId,
requestBody,
}: {
userId: number,
requestBody: UserUpdate,
}): CancelablePromise<UserOut> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/api/v1/users/{user_id}',
path: {
'user_id': userId,
},
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete User
* Delete a user.
* @returns Message Successful Response
* @throws ApiError
*/
public static deleteUser({
userId,
}: {
userId: number,
}): CancelablePromise<Message> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/users/{user_id}',
path: {
'user_id': userId,
},
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -0,0 +1,58 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Message } from '../models/Message';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class UtilsService {
/**
* Test Celery
* Test Celery worker.
* @returns Message Successful Response
* @throws ApiError
*/
public static testCelery({
requestBody,
}: {
requestBody: Message,
}): CancelablePromise<Message> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/utils/test-celery/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Test Email
* Test emails.
* @returns Message Successful Response
* @throws ApiError
*/
public static testEmail({
emailTo,
}: {
emailTo: string,
}): CancelablePromise<Message> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/utils/test-email/',
query: {
'email_to': emailTo,
},
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -0,0 +1,194 @@
import React from 'react'
import {
Button,
Checkbox,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from 'react-query'
import { UserCreate, UsersService } from '../../client'
import { ApiError } from '../../client/core/ApiError'
import useCustomToast from '../../hooks/useCustomToast'
interface AddUserProps {
isOpen: boolean
onClose: () => void
}
interface UserCreateForm extends UserCreate {
confirm_password: string
}
const AddUser: React.FC<AddUserProps> = ({ isOpen, onClose }) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const {
register,
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting },
} = useForm<UserCreateForm>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: {
email: '',
full_name: '',
password: '',
confirm_password: '',
is_superuser: false,
is_active: false,
},
})
const addUser = async (data: UserCreate) => {
await UsersService.createUser({ requestBody: data })
}
const mutation = useMutation(addUser, {
onSuccess: () => {
showToast('Success!', 'User created successfully.', 'success')
reset()
onClose()
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
onSettled: () => {
queryClient.invalidateQueries('users')
},
})
const onSubmit: SubmitHandler<UserCreateForm> = (data) => {
mutation.mutate(data)
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: 'sm', md: 'md' }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Add User</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isRequired isInvalid={!!errors.email}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i,
message: 'Invalid email address',
},
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.full_name}>
<FormLabel htmlFor="name">Full name</FormLabel>
<Input
id="name"
{...register('full_name')}
placeholder="Full name"
type="text"
/>
{errors.full_name && (
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
placeholder="Password"
type="password"
/>
{errors.password && (
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl
mt={4}
isRequired
isInvalid={!!errors.confirm_password}
>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register('confirm_password', {
required: 'Please confirm your password',
validate: (value) =>
value === getValues().password ||
'The passwords do not match',
})}
placeholder="Password"
type="password"
/>
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Flex mt={4}>
<FormControl>
<Checkbox {...register('is_superuser')} colorScheme="teal">
Is superuser?
</Checkbox>
</FormControl>
<FormControl>
<Checkbox {...register('is_active')} colorScheme="teal">
Is active?
</Checkbox>
</FormControl>
</Flex>
</ModalBody>
<ModalFooter gap={3}>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
>
Save
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
export default AddUser

View File

@@ -0,0 +1,183 @@
import React from 'react'
import {
Button,
Checkbox,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from 'react-query'
import { ApiError, UserOut, UserUpdate, UsersService } from '../../client'
import useCustomToast from '../../hooks/useCustomToast'
interface EditUserProps {
user: UserOut
isOpen: boolean
onClose: () => void
}
interface UserUpdateForm extends UserUpdate {
confirm_password: string
}
const EditUser: React.FC<EditUserProps> = ({ user, isOpen, onClose }) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const {
register,
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting, isDirty },
} = useForm<UserUpdateForm>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: user,
})
const updateUser = async (data: UserUpdateForm) => {
await UsersService.updateUser({ userId: user.id, requestBody: data })
}
const mutation = useMutation(updateUser, {
onSuccess: () => {
showToast('Success!', 'User updated successfully.', 'success')
onClose()
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
onSettled: () => {
queryClient.invalidateQueries('users')
},
})
const onSubmit: SubmitHandler<UserUpdateForm> = async (data) => {
if (data.password === '') {
delete data.password
}
mutation.mutate(data)
}
const onCancel = () => {
reset()
onClose()
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: 'sm', md: 'md' }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Edit User</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isInvalid={!!errors.email}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i,
message: 'Invalid email address',
},
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4}>
<FormLabel htmlFor="name">Full name</FormLabel>
<Input id="name" {...register('full_name')} type="text" />
</FormControl>
<FormControl mt={4} isInvalid={!!errors.password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register('password', {
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
placeholder="Password"
type="password"
/>
{errors.password && (
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register('confirm_password', {
validate: (value) =>
value === getValues().password ||
'The passwords do not match',
})}
placeholder="Password"
type="password"
/>
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Flex>
<FormControl mt={4}>
<Checkbox {...register('is_superuser')} colorScheme="teal">
Is superuser?
</Checkbox>
</FormControl>
<FormControl mt={4}>
<Checkbox {...register('is_active')} colorScheme="teal">
Is active?
</Checkbox>
</FormControl>
</Flex>
</ModalBody>
<ModalFooter gap={3}>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
isDisabled={!isDirty}
>
Save
</Button>
<Button onClick={onCancel}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
export default EditUser

View File

@@ -0,0 +1,76 @@
import React from 'react'
import {
Button,
Menu,
MenuButton,
MenuItem,
MenuList,
useDisclosure,
} from '@chakra-ui/react'
import { BsThreeDotsVertical } from 'react-icons/bs'
import { FiEdit, FiTrash } from 'react-icons/fi'
import EditUser from '../Admin/EditUser'
import EditItem from '../Items/EditItem'
import Delete from './DeleteAlert'
import { ItemOut, UserOut } from '../../client'
interface ActionsMenuProps {
type: string
value: ItemOut | UserOut
disabled?: boolean
}
const ActionsMenu: React.FC<ActionsMenuProps> = ({ type, value, disabled }) => {
const editUserModal = useDisclosure()
const deleteModal = useDisclosure()
return (
<>
<Menu>
<MenuButton
isDisabled={disabled}
as={Button}
rightIcon={<BsThreeDotsVertical />}
variant="unstyled"
></MenuButton>
<MenuList>
<MenuItem
onClick={editUserModal.onOpen}
icon={<FiEdit fontSize="16px" />}
>
Edit {type}
</MenuItem>
<MenuItem
onClick={deleteModal.onOpen}
icon={<FiTrash fontSize="16px" />}
color="ui.danger"
>
Delete {type}
</MenuItem>
</MenuList>
{type === 'User' ? (
<EditUser
user={value as UserOut}
isOpen={editUserModal.isOpen}
onClose={editUserModal.onClose}
/>
) : (
<EditItem
item={value as ItemOut}
isOpen={editUserModal.isOpen}
onClose={editUserModal.onClose}
/>
)}
<Delete
type={type}
id={value.id}
isOpen={deleteModal.isOpen}
onClose={deleteModal.onClose}
/>
</Menu>
</>
)
}
export default ActionsMenu

View File

@@ -0,0 +1,116 @@
import React from 'react'
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from '@chakra-ui/react'
import { useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from 'react-query'
import { ItemsService, UsersService } from '../../client'
import useCustomToast from '../../hooks/useCustomToast'
interface DeleteProps {
type: string
id: number
isOpen: boolean
onClose: () => void
}
const Delete: React.FC<DeleteProps> = ({ type, id, isOpen, onClose }) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteEntity = async (id: number) => {
if (type === 'Item') {
await ItemsService.deleteItem({ id: id })
} else if (type === 'User') {
await UsersService.deleteUser({ userId: id })
} else {
throw new Error(`Unexpected type: ${type}`)
}
}
const mutation = useMutation(deleteEntity, {
onSuccess: () => {
showToast(
'Success',
`The ${type.toLowerCase()} was deleted successfully.`,
'success',
)
onClose()
},
onError: () => {
showToast(
'An error occurred.',
`An error occurred while deleting the ${type.toLowerCase()}.`,
'error',
)
},
onSettled: () => {
queryClient.invalidateQueries(type === 'Item' ? 'items' : 'users')
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
return (
<>
<AlertDialog
isOpen={isOpen}
onClose={onClose}
leastDestructiveRef={cancelRef}
size={{ base: 'sm', md: 'md' }}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
<AlertDialogHeader>Delete {type}</AlertDialogHeader>
<AlertDialogBody>
{type === 'User' && (
<span>
All items associated with this user will also be{' '}
<strong>permantly deleted. </strong>
</span>
)}
Are you sure? You will not be able to undo this action.
</AlertDialogBody>
<AlertDialogFooter gap={3}>
<Button
bg="ui.danger"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
>
Delete
</Button>
<Button
ref={cancelRef}
onClick={onClose}
isDisabled={isSubmitting}
>
Cancel
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
)
}
export default Delete

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { Button, Flex, Icon, useDisclosure } from '@chakra-ui/react'
import { FaPlus } from 'react-icons/fa'
import AddUser from '../Admin/AddUser'
import AddItem from '../Items/AddItem'
interface NavbarProps {
type: string
}
const Navbar: React.FC<NavbarProps> = ({ type }) => {
const addUserModal = useDisclosure()
const addItemModal = useDisclosure()
return (
<>
<Flex py={8} gap={4}>
{/* TODO: Complete search functionality */}
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
<InputLeftElement pointerEvents='none'>
<Icon as={FaSearch} color='gray.400' />
</InputLeftElement>
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
</InputGroup> */}
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
gap={1}
fontSize={{ base: 'sm', md: 'inherit' }}
onClick={type === 'User' ? addUserModal.onOpen : addItemModal.onOpen}
>
<Icon as={FaPlus} /> Add {type}
</Button>
<AddUser isOpen={addUserModal.isOpen} onClose={addUserModal.onClose} />
<AddItem isOpen={addItemModal.isOpen} onClose={addItemModal.onClose} />
</Flex>
</>
)
}
export default Navbar

View File

@@ -0,0 +1,42 @@
import React from 'react'
import { Button, Container, Text } from '@chakra-ui/react'
import { Link } from '@tanstack/react-router'
const NotFound: React.FC = () => {
return (
<>
<Container
h="100vh"
alignItems="stretch"
justifyContent="center"
textAlign="center"
maxW="sm"
centerContent
>
<Text
fontSize="8xl"
color="ui.main"
fontWeight="bold"
lineHeight="1"
mb={4}
>
404
</Text>
<Text fontSize="md">Oops!</Text>
<Text fontSize="md">Page not found.</Text>
<Button
as={Link}
to="/"
color="ui.main"
borderColor="ui.main"
variant="outline"
mt={4}
>
Go back
</Button>
</Container>
</>
)
}
export default NotFound

View File

@@ -0,0 +1,117 @@
import React from 'react'
import {
Box,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerOverlay,
Flex,
IconButton,
Image,
Text,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react'
import { FiLogOut, FiMenu } from 'react-icons/fi'
import { useQueryClient } from 'react-query'
import Logo from '../../assets/images/fastapi-logo.svg'
import { UserOut } from '../../client'
import useAuth from '../../hooks/useAuth'
import SidebarItems from './SidebarItems'
const Sidebar: React.FC = () => {
const queryClient = useQueryClient()
const bgColor = useColorModeValue('white', '#1a202c')
const textColor = useColorModeValue('gray', 'white')
const secBgColor = useColorModeValue('ui.secondary', '#252d3d')
const currentUser = queryClient.getQueryData<UserOut>('currentUser')
const { isOpen, onOpen, onClose } = useDisclosure()
const { logout } = useAuth()
const handleLogout = async () => {
logout()
}
return (
<>
{/* Mobile */}
<IconButton
onClick={onOpen}
display={{ base: 'flex', md: 'none' }}
aria-label="Open Menu"
position="absolute"
fontSize="20px"
m={4}
icon={<FiMenu />}
/>
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent maxW="250px">
<DrawerCloseButton />
<DrawerBody py={8}>
<Flex flexDir="column" justify="space-between">
<Box>
<Image src={Logo} alt="logo" p={6} />
<SidebarItems onClose={onClose} />
<Flex
as="button"
onClick={handleLogout}
p={2}
color="ui.danger"
fontWeight="bold"
alignItems="center"
>
<FiLogOut />
<Text ml={2}>Log out</Text>
</Flex>
</Box>
{currentUser?.email && (
<Text color={textColor} noOfLines={2} fontSize="sm" p={2}>
Logged in as: {currentUser.email}
</Text>
)}
</Flex>
</DrawerBody>
</DrawerContent>
</Drawer>
{/* Desktop */}
<Box
bg={bgColor}
p={3}
h="100vh"
position="sticky"
top="0"
display={{ base: 'none', md: 'flex' }}
>
<Flex
flexDir="column"
justify="space-between"
bg={secBgColor}
p={4}
borderRadius={12}
>
<Box>
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
<SidebarItems />
</Box>
{currentUser?.email && (
<Text
color={textColor}
noOfLines={2}
fontSize="sm"
p={2}
maxW="180px"
>
Logged in as: {currentUser.email}
</Text>
)}
</Flex>
</Box>
</>
)
}
export default Sidebar

View File

@@ -0,0 +1,57 @@
import React from 'react'
import { Box, Flex, Icon, Text, useColorModeValue } from '@chakra-ui/react'
import { FiBriefcase, FiHome, FiSettings, FiUsers } from 'react-icons/fi'
import { Link } from '@tanstack/react-router'
import { useQueryClient } from 'react-query'
import { UserOut } from '../../client'
const items = [
{ icon: FiHome, title: 'Dashboard', path: '/' },
{ icon: FiBriefcase, title: 'Items', path: '/items' },
{ icon: FiSettings, title: 'User Settings', path: '/settings' },
]
interface SidebarItemsProps {
onClose?: () => void
}
const SidebarItems: React.FC<SidebarItemsProps> = ({ onClose }) => {
const queryClient = useQueryClient()
const textColor = useColorModeValue('ui.main', '#E2E8F0')
const bgActive = useColorModeValue('#E2E8F0', '#4A5568')
const currentUser = queryClient.getQueryData<UserOut>('currentUser')
const finalItems = currentUser?.is_superuser
? [...items, { icon: FiUsers, title: 'Admin', path: '/admin' }]
: items
const listItems = finalItems.map((item) => (
<Flex
as={Link}
to={item.path}
w="100%"
p={2}
key={item.title}
activeProps={{
style: {
background: bgActive,
borderRadius: '12px',
},
}}
color={textColor}
onClick={onClose}
>
<Icon as={item.icon} alignSelf="center" />
<Text ml={2}>{item.title}</Text>
</Flex>
))
return (
<>
<Box>{listItems}</Box>
</>
)
}
export default SidebarItems

View File

@@ -0,0 +1,59 @@
import React from 'react'
import {
Box,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
} from '@chakra-ui/react'
import { FaUserAstronaut } from 'react-icons/fa'
import { FiLogOut, FiUser } from 'react-icons/fi'
import useAuth from '../../hooks/useAuth'
import { Link } from '@tanstack/react-router'
const UserMenu: React.FC = () => {
const { logout } = useAuth()
const handleLogout = async () => {
logout()
}
return (
<>
{/* Desktop */}
<Box
display={{ base: 'none', md: 'block' }}
position="fixed"
top={4}
right={4}
>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<FaUserAstronaut color="white" fontSize="18px" />}
bg="ui.main"
isRound
/>
<MenuList>
<MenuItem icon={<FiUser fontSize="18px" />} as={Link} to="settings">
My profile
</MenuItem>
<MenuItem
icon={<FiLogOut fontSize="18px" />}
onClick={handleLogout}
color="ui.danger"
fontWeight="bold"
>
Log out
</MenuItem>
</MenuList>
</Menu>
</Box>
</>
)
}
export default UserMenu

View File

@@ -0,0 +1,123 @@
import React from 'react'
import {
Button,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from 'react-query'
import { ApiError, ItemCreate, ItemsService } from '../../client'
import useCustomToast from '../../hooks/useCustomToast'
interface AddItemProps {
isOpen: boolean
onClose: () => void
}
const AddItem: React.FC<AddItemProps> = ({ isOpen, onClose }) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ItemCreate>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: {
title: '',
description: '',
},
})
const addItem = async (data: ItemCreate) => {
await ItemsService.createItem({ requestBody: data })
}
const mutation = useMutation(addItem, {
onSuccess: () => {
showToast('Success!', 'Item created successfully.', 'success')
reset()
onClose()
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
onSettled: () => {
queryClient.invalidateQueries('items')
},
})
const onSubmit: SubmitHandler<ItemCreate> = (data) => {
mutation.mutate(data)
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: 'sm', md: 'md' }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Add Item</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isRequired isInvalid={!!errors.title}>
<FormLabel htmlFor="title">Title</FormLabel>
<Input
id="title"
{...register('title', {
required: 'Title is required.',
})}
placeholder="Title"
type="text"
/>
{errors.title && (
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4}>
<FormLabel htmlFor="description">Description</FormLabel>
<Input
id="description"
{...register('description')}
placeholder="Description"
type="text"
/>
</FormControl>
</ModalBody>
<ModalFooter gap={3}>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
>
Save
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
export default AddItem

View File

@@ -0,0 +1,124 @@
import React from 'react'
import {
Button,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from 'react-query'
import { ApiError, ItemOut, ItemUpdate, ItemsService } from '../../client'
import useCustomToast from '../../hooks/useCustomToast'
interface EditItemProps {
item: ItemOut
isOpen: boolean
onClose: () => void
}
const EditItem: React.FC<EditItemProps> = ({ item, isOpen, onClose }) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const {
register,
handleSubmit,
reset,
formState: { isSubmitting, errors, isDirty },
} = useForm<ItemUpdate>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: item,
})
const updateItem = async (data: ItemUpdate) => {
await ItemsService.updateItem({ id: item.id, requestBody: data })
}
const mutation = useMutation(updateItem, {
onSuccess: () => {
showToast('Success!', 'Item updated successfully.', 'success')
onClose()
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
onSettled: () => {
queryClient.invalidateQueries('items')
},
})
const onSubmit: SubmitHandler<ItemUpdate> = async (data) => {
mutation.mutate(data)
}
const onCancel = () => {
reset()
onClose()
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: 'sm', md: 'md' }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Edit Item</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isInvalid={!!errors.title}>
<FormLabel htmlFor="title">Title</FormLabel>
<Input
id="title"
{...register('title', {
required: 'Title is required',
})}
type="text"
/>
{errors.title && (
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4}>
<FormLabel htmlFor="description">Description</FormLabel>
<Input
id="description"
{...register('description')}
placeholder="Description"
type="text"
/>
</FormControl>
</ModalBody>
<ModalFooter gap={3}>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
isDisabled={!isDirty}
>
Save
</Button>
<Button onClick={onCancel}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
export default EditItem

View File

@@ -0,0 +1,39 @@
import React from 'react'
import {
Badge,
Container,
Heading,
Radio,
RadioGroup,
Stack,
useColorMode,
} from '@chakra-ui/react'
const Appearance: React.FC = () => {
const { colorMode, toggleColorMode } = useColorMode()
return (
<>
<Container maxW="full">
<Heading size="sm" py={4}>
Appearance
</Heading>
<RadioGroup onChange={toggleColorMode} value={colorMode}>
<Stack>
{/* TODO: Add system default option */}
<Radio value="light" colorScheme="teal">
Light mode
<Badge ml="1" colorScheme="teal">
Default
</Badge>
</Radio>
<Radio value="dark" colorScheme="teal">
Dark mode
</Radio>
</Stack>
</RadioGroup>
</Container>
</>
)
}
export default Appearance

View File

@@ -0,0 +1,137 @@
import React from 'react'
import {
Box,
Button,
Container,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
useColorModeValue,
} from '@chakra-ui/react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useMutation } from 'react-query'
import { ApiError, UpdatePassword, UsersService } from '../../client'
import useCustomToast from '../../hooks/useCustomToast'
interface UpdatePasswordForm extends UpdatePassword {
confirm_password: string
}
const ChangePassword: React.FC = () => {
const color = useColorModeValue('gray.700', 'white')
const showToast = useCustomToast()
const {
register,
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting },
} = useForm<UpdatePasswordForm>({
mode: 'onBlur',
criteriaMode: 'all',
})
const UpdatePassword = async (data: UpdatePassword) => {
await UsersService.updatePasswordMe({ requestBody: data })
}
const mutation = useMutation(UpdatePassword, {
onSuccess: () => {
showToast('Success!', 'Password updated.', 'success')
reset()
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
})
const onSubmit: SubmitHandler<UpdatePasswordForm> = async (data) => {
mutation.mutate(data)
}
return (
<>
<Container maxW="full" as="form" onSubmit={handleSubmit(onSubmit)}>
<Heading size="sm" py={4}>
Change Password
</Heading>
<Box w={{ sm: 'full', md: '50%' }}>
<FormControl isRequired isInvalid={!!errors.current_password}>
<FormLabel color={color} htmlFor="current_password">
Current password
</FormLabel>
<Input
id="current_password"
{...register('current_password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
placeholder="Password"
type="password"
/>
{errors.current_password && (
<FormErrorMessage>
{errors.current_password.message}
</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.new_password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register('new_password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
placeholder="Password"
type="password"
/>
{errors.new_password && (
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.confirm_password}>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register('confirm_password', {
required: 'Please confirm your password',
validate: (value) =>
value === getValues().new_password ||
'The passwords do not match',
})}
placeholder="Password"
type="password"
/>
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
mt={4}
type="submit"
isLoading={isSubmitting}
>
Save
</Button>
</Box>
</Container>
</>
)
}
export default ChangePassword

View File

@@ -0,0 +1,42 @@
import React from 'react'
import {
Button,
Container,
Heading,
Text,
useDisclosure,
} from '@chakra-ui/react'
import DeleteConfirmation from './DeleteConfirmation'
const DeleteAccount: React.FC = () => {
const confirmationModal = useDisclosure()
return (
<>
<Container maxW="full">
<Heading size="sm" py={4}>
Delete Account
</Heading>
<Text>
Are you sure you want to delete your account? This action cannot be
undone.
</Text>
<Button
bg="ui.danger"
color="white"
_hover={{ opacity: 0.8 }}
mt={4}
onClick={confirmationModal.onOpen}
>
Delete
</Button>
<DeleteConfirmation
isOpen={confirmationModal.isOpen}
onClose={confirmationModal.onClose}
/>
</Container>
</>
)
}
export default DeleteAccount

View File

@@ -0,0 +1,105 @@
import React from 'react'
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from '@chakra-ui/react'
import { useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from 'react-query'
import { ApiError, UserOut, UsersService } from '../../client'
import useAuth from '../../hooks/useAuth'
import useCustomToast from '../../hooks/useCustomToast'
interface DeleteProps {
isOpen: boolean
onClose: () => void
}
const DeleteConfirmation: React.FC<DeleteProps> = ({ isOpen, onClose }) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const currentUser = queryClient.getQueryData<UserOut>('currentUser')
const { logout } = useAuth()
const deleteCurrentUser = async (id: number) => {
await UsersService.deleteUser({ userId: id })
}
const mutation = useMutation(deleteCurrentUser, {
onSuccess: () => {
showToast(
'Success',
'Your account has been successfully deleted.',
'success',
)
logout()
onClose()
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
onSettled: () => {
queryClient.invalidateQueries('currentUser')
},
})
const onSubmit = async () => {
mutation.mutate(currentUser!.id)
}
return (
<>
<AlertDialog
isOpen={isOpen}
onClose={onClose}
leastDestructiveRef={cancelRef}
size={{ base: 'sm', md: 'md' }}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
<AlertDialogHeader>Confirmation Required</AlertDialogHeader>
<AlertDialogBody>
All your account data will be{' '}
<strong>permanently deleted.</strong> If you are sure, please
click <strong>'Confirm'</strong> to proceed.
</AlertDialogBody>
<AlertDialogFooter gap={3}>
<Button
bg="ui.danger"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
>
Confirm
</Button>
<Button
ref={cancelRef}
onClick={onClose}
isDisabled={isSubmitting}
>
Cancel
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
)
}
export default DeleteConfirmation

View File

@@ -0,0 +1,148 @@
import React, { useState } from 'react'
import {
Box,
Button,
Container,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
Text,
useColorModeValue,
} from '@chakra-ui/react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from 'react-query'
import { ApiError, UserOut, UserUpdateMe, UsersService } from '../../client'
import useAuth from '../../hooks/useAuth'
import useCustomToast from '../../hooks/useCustomToast'
const UserInformation: React.FC = () => {
const queryClient = useQueryClient()
const color = useColorModeValue('gray.700', 'white')
const showToast = useCustomToast()
const [editMode, setEditMode] = useState(false)
const { user: currentUser } = useAuth()
const {
register,
handleSubmit,
reset,
getValues,
formState: { isSubmitting, errors, isDirty },
} = useForm<UserOut>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: {
full_name: currentUser?.full_name,
email: currentUser?.email,
},
})
const toggleEditMode = () => {
setEditMode(!editMode)
}
const updateInfo = async (data: UserUpdateMe) => {
await UsersService.updateUserMe({ requestBody: data })
}
const mutation = useMutation(updateInfo, {
onSuccess: () => {
showToast('Success!', 'User updated successfully.', 'success')
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
onSettled: () => {
queryClient.invalidateQueries('users')
queryClient.invalidateQueries('currentUser')
},
})
const onSubmit: SubmitHandler<UserUpdateMe> = async (data) => {
mutation.mutate(data)
}
const onCancel = () => {
reset()
toggleEditMode()
}
return (
<>
<Container maxW="full" as="form" onSubmit={handleSubmit(onSubmit)}>
<Heading size="sm" py={4}>
User Information
</Heading>
<Box w={{ sm: 'full', md: '50%' }}>
<FormControl>
<FormLabel color={color} htmlFor="name">
Full name
</FormLabel>
{editMode ? (
<Input
id="name"
{...register('full_name', { maxLength: 30 })}
type="text"
size="md"
/>
) : (
<Text size="md" py={2}>
{currentUser?.full_name || 'N/A'}
</Text>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.email}>
<FormLabel color={color} htmlFor="email">
Email
</FormLabel>
{editMode ? (
<Input
id="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i,
message: 'Invalid email address',
},
})}
type="text"
size="md"
/>
) : (
<Text size="md" py={2}>
{currentUser!.email}
</Text>
)}
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<Flex mt={4} gap={3}>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
onClick={toggleEditMode}
type={editMode ? 'button' : 'submit'}
isLoading={editMode ? isSubmitting : false}
isDisabled={editMode ? !isDirty || !getValues('email') : false}
>
{editMode ? 'Save' : 'Edit'}
</Button>
{editMode && (
<Button onClick={onCancel} isDisabled={isSubmitting}>
Cancel
</Button>
)}
</Flex>
</Box>
</Container>
</>
)
}
export default UserInformation

View File

@@ -0,0 +1,42 @@
import { useQuery } from 'react-query'
import { useNavigate } from '@tanstack/react-router'
import {
Body_login_login_access_token as AccessToken,
LoginService,
UserOut,
UsersService,
} from '../client'
const isLoggedIn = () => {
return localStorage.getItem('access_token') !== null
}
const useAuth = () => {
const navigate = useNavigate()
const { data: user, isLoading } = useQuery<UserOut | null, Error>(
'currentUser',
UsersService.readUserMe,
{
enabled: isLoggedIn(),
},
)
const login = async (data: AccessToken) => {
const response = await LoginService.loginAccessToken({
formData: data,
})
localStorage.setItem('access_token', response.access_token)
navigate({ to: '/' })
}
const logout = () => {
localStorage.removeItem('access_token')
navigate({ to: '/login' })
}
return { login, logout, user, isLoading }
}
export { isLoggedIn }
export default useAuth

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react'
import { useToast } from '@chakra-ui/react'
const useCustomToast = () => {
const toast = useToast()
const showToast = useCallback(
(title: string, description: string, status: 'success' | 'error') => {
toast({
title,
description,
status,
isClosable: true,
position: 'bottom-right',
})
},
[toast],
)
return showToast
}
export default useCustomToast

68
frontend/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

33
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,33 @@
import ReactDOM from 'react-dom/client'
import { ChakraProvider } from '@chakra-ui/react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { OpenAPI } from './client'
import theme from './theme'
import { StrictMode } from 'react'
OpenAPI.BASE = import.meta.env.VITE_API_URL
OpenAPI.TOKEN = async () => {
return localStorage.getItem('access_token') || ''
}
const queryClient = new QueryClient()
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<ChakraProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ChakraProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,118 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as ResetPasswordImport } from './routes/reset-password'
import { Route as RecoverPasswordImport } from './routes/recover-password'
import { Route as LoginImport } from './routes/login'
import { Route as LayoutImport } from './routes/_layout'
import { Route as LayoutIndexImport } from './routes/_layout/index'
import { Route as LayoutSettingsImport } from './routes/_layout/settings'
import { Route as LayoutItemsImport } from './routes/_layout/items'
import { Route as LayoutAdminImport } from './routes/_layout/admin'
// Create/Update Routes
const ResetPasswordRoute = ResetPasswordImport.update({
path: '/reset-password',
getParentRoute: () => rootRoute,
} as any)
const RecoverPasswordRoute = RecoverPasswordImport.update({
path: '/recover-password',
getParentRoute: () => rootRoute,
} as any)
const LoginRoute = LoginImport.update({
path: '/login',
getParentRoute: () => rootRoute,
} as any)
const LayoutRoute = LayoutImport.update({
id: '/_layout',
getParentRoute: () => rootRoute,
} as any)
const LayoutIndexRoute = LayoutIndexImport.update({
path: '/',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutSettingsRoute = LayoutSettingsImport.update({
path: '/settings',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutItemsRoute = LayoutItemsImport.update({
path: '/items',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutAdminRoute = LayoutAdminImport.update({
path: '/admin',
getParentRoute: () => LayoutRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_layout': {
preLoaderRoute: typeof LayoutImport
parentRoute: typeof rootRoute
}
'/login': {
preLoaderRoute: typeof LoginImport
parentRoute: typeof rootRoute
}
'/recover-password': {
preLoaderRoute: typeof RecoverPasswordImport
parentRoute: typeof rootRoute
}
'/reset-password': {
preLoaderRoute: typeof ResetPasswordImport
parentRoute: typeof rootRoute
}
'/_layout/admin': {
preLoaderRoute: typeof LayoutAdminImport
parentRoute: typeof LayoutImport
}
'/_layout/items': {
preLoaderRoute: typeof LayoutItemsImport
parentRoute: typeof LayoutImport
}
'/_layout/settings': {
preLoaderRoute: typeof LayoutSettingsImport
parentRoute: typeof LayoutImport
}
'/_layout/': {
preLoaderRoute: typeof LayoutIndexImport
parentRoute: typeof LayoutImport
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([
LayoutRoute.addChildren([
LayoutAdminRoute,
LayoutItemsRoute,
LayoutSettingsRoute,
LayoutIndexRoute,
]),
LoginRoute,
RecoverPasswordRoute,
ResetPasswordRoute,
])
/* prettier-ignore-end */

View File

@@ -0,0 +1,14 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import NotFound from '../components/Common/NotFound'
export const Route = createRootRoute({
component: () => (
<>
<Outlet />
<TanStackRouterDevtools />
</>
),
notFoundComponent: () => <NotFound />,
})

View File

@@ -0,0 +1,37 @@
import { Flex, Spinner } from '@chakra-ui/react'
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
import Sidebar from '../components/Common/Sidebar'
import UserMenu from '../components/Common/UserMenu'
import useAuth, { isLoggedIn } from '../hooks/useAuth'
export const Route = createFileRoute('/_layout')({
component: Layout,
beforeLoad: async () => {
if (!isLoggedIn()) {
throw redirect({
to: '/login',
})
}
},
})
function Layout() {
const { isLoading } = useAuth()
return (
<Flex maxW="large" h="auto" position="relative">
<Sidebar />
{isLoading ? (
<Flex justify="center" align="center" height="100vh" width="full">
<Spinner size="xl" color="ui.main" />
</Flex>
) : (
<Outlet />
)}
<UserMenu />
</Flex>
)
}
export default Layout

View File

@@ -0,0 +1,117 @@
import {
Badge,
Box,
Container,
Flex,
Heading,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@chakra-ui/react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useQueryClient } from 'react-query'
import { ApiError, UserOut, UsersService } from '../../client'
import ActionsMenu from '../../components/Common/ActionsMenu'
import Navbar from '../../components/Common/Navbar'
import useCustomToast from '../../hooks/useCustomToast'
export const Route = createFileRoute('/_layout/admin')({
component: Admin,
})
function Admin() {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const currentUser = queryClient.getQueryData<UserOut>('currentUser')
const {
data: users,
isLoading,
isError,
error,
} = useQuery('users', () => UsersService.readUsers({}))
if (isError) {
const errDetail = (error as ApiError).body?.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
}
return (
<>
{isLoading ? (
// TODO: Add skeleton
<Flex justify="center" align="center" height="100vh" width="full">
<Spinner size="xl" color="ui.main" />
</Flex>
) : (
users && (
<Container maxW="full">
<Heading
size="lg"
textAlign={{ base: 'center', md: 'left' }}
pt={12}
>
User Management
</Heading>
<Navbar type={'User'} />
<TableContainer>
<Table fontSize="md" size={{ base: 'sm', md: 'md' }}>
<Thead>
<Tr>
<Th>Full name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.data.map((user) => (
<Tr key={user.id}>
<Td color={!user.full_name ? 'gray.600' : 'inherit'}>
{user.full_name || 'N/A'}
{currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal">
You
</Badge>
)}
</Td>
<Td>{user.email}</Td>
<Td>{user.is_superuser ? 'Superuser' : 'User'}</Td>
<Td>
<Flex gap={2}>
<Box
w="2"
h="2"
borderRadius="50%"
bg={user.is_active ? 'ui.success' : 'ui.danger'}
alignSelf="center"
/>
{user.is_active ? 'Active' : 'Inactive'}
</Flex>
</Td>
<Td>
<ActionsMenu
type="User"
value={user}
disabled={currentUser?.id === user.id ? true : false}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Container>
)
)}
</>
)
}
export default Admin

View File

@@ -0,0 +1,28 @@
import { Container, Text } from '@chakra-ui/react'
import { useQueryClient } from 'react-query'
import { createFileRoute } from '@tanstack/react-router'
import { UserOut } from '../../client'
export const Route = createFileRoute('/_layout/')({
component: Dashboard,
})
function Dashboard() {
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserOut>('currentUser')
return (
<>
<Container maxW="full" pt={12}>
<Text fontSize="2xl">
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
</Text>
<Text>Welcome back, nice to see you again!</Text>
</Container>
</>
)
}
export default Dashboard

View File

@@ -0,0 +1,91 @@
import {
Container,
Flex,
Heading,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@chakra-ui/react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from 'react-query'
import { ApiError, ItemsService } from '../../client'
import ActionsMenu from '../../components/Common/ActionsMenu'
import Navbar from '../../components/Common/Navbar'
import useCustomToast from '../../hooks/useCustomToast'
export const Route = createFileRoute('/_layout/items')({
component: Items,
})
function Items() {
const showToast = useCustomToast()
const {
data: items,
isLoading,
isError,
error,
} = useQuery('items', () => ItemsService.readItems({}))
if (isError) {
const errDetail = (error as ApiError).body?.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
}
return (
<>
{isLoading ? (
// TODO: Add skeleton
<Flex justify="center" align="center" height="100vh" width="full">
<Spinner size="xl" color="ui.main" />
</Flex>
) : (
items && (
<Container maxW="full">
<Heading
size="lg"
textAlign={{ base: 'center', md: 'left' }}
pt={12}
>
Items Management
</Heading>
<Navbar type={'Item'} />
<TableContainer>
<Table size={{ base: 'sm', md: 'md' }}>
<Thead>
<Tr>
<Th>ID</Th>
<Th>Title</Th>
<Th>Description</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{items.data.map((item) => (
<Tr key={item.id}>
<Td>{item.id}</Td>
<Td>{item.title}</Td>
<Td color={!item.description ? 'gray.600' : 'inherit'}>
{item.description || 'N/A'}
</Td>
<Td>
<ActionsMenu type={'Item'} value={item} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Container>
)
)}
</>
)
}
export default Items

View File

@@ -0,0 +1,60 @@
import {
Container,
Heading,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from '@chakra-ui/react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from 'react-query'
import { UserOut } from '../../client'
import Appearance from '../../components/UserSettings/Appearance'
import ChangePassword from '../../components/UserSettings/ChangePassword'
import DeleteAccount from '../../components/UserSettings/DeleteAccount'
import UserInformation from '../../components/UserSettings/UserInformation'
const tabsConfig = [
{ title: 'My profile', component: UserInformation },
{ title: 'Password', component: ChangePassword },
{ title: 'Appearance', component: Appearance },
{ title: 'Danger zone', component: DeleteAccount },
]
export const Route = createFileRoute('/_layout/settings')({
component: UserSettings,
})
function UserSettings() {
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserOut>('currentUser')
const finalTabs = currentUser?.is_superuser
? tabsConfig.slice(0, 3)
: tabsConfig
return (
<Container maxW="full">
<Heading size="lg" textAlign={{ base: 'center', md: 'left' }} py={12}>
User Settings
</Heading>
<Tabs variant="enclosed">
<TabList>
{finalTabs.map((tab, index) => (
<Tab key={index}>{tab.title}</Tab>
))}
</TabList>
<TabPanels>
{finalTabs.map((tab, index) => (
<TabPanel key={index}>
<tab.component />
</TabPanel>
))}
</TabPanels>
</Tabs>
</Container>
)
}
export default UserSettings

View File

@@ -0,0 +1,144 @@
import React from 'react'
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'
import {
Button,
Center,
Container,
FormControl,
FormErrorMessage,
Icon,
Image,
Input,
InputGroup,
InputRightElement,
Link,
useBoolean,
} from '@chakra-ui/react'
import {
Link as RouterLink,
createFileRoute,
redirect,
} from '@tanstack/react-router'
import { SubmitHandler, useForm } from 'react-hook-form'
import Logo from '../assets/images/fastapi-logo.svg'
import { ApiError } from '../client'
import { Body_login_login_access_token as AccessToken } from '../client/models/Body_login_login_access_token'
import useAuth, { isLoggedIn } from '../hooks/useAuth'
export const Route = createFileRoute('/login')({
component: Login,
beforeLoad: async () => {
if (isLoggedIn()) {
throw redirect({
to: '/',
})
}
},
})
function Login() {
const [show, setShow] = useBoolean()
const { login } = useAuth()
const [error, setError] = React.useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<AccessToken>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: {
username: '',
password: '',
},
})
const onSubmit: SubmitHandler<AccessToken> = async (data) => {
try {
await login(data)
} catch (err) {
const errDetail = (err as ApiError).body.detail
setError(errDetail)
}
}
return (
<>
<Container
as="form"
onSubmit={handleSubmit(onSubmit)}
h="100vh"
maxW="sm"
alignItems="stretch"
justifyContent="center"
gap={4}
centerContent
>
<Image
src={Logo}
alt="FastAPI logo"
height="auto"
maxW="2xs"
alignSelf="center"
mb={4}
/>
<FormControl id="username" isInvalid={!!errors.username || !!error}>
<Input
id="username"
{...register('username', {
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i,
message: 'Invalid email address',
},
})}
placeholder="Email"
type="text"
/>
{errors.username && (
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
)}
</FormControl>
<FormControl id="password" isInvalid={!!error}>
<InputGroup>
<Input
{...register('password')}
type={show ? 'text' : 'password'}
placeholder="Password"
/>
<InputRightElement
color="gray.400"
_hover={{
cursor: 'pointer',
}}
>
<Icon
onClick={setShow.toggle}
aria-label={show ? 'Hide password' : 'Show password'}
>
{show ? <ViewOffIcon /> : <ViewIcon />}
</Icon>
</InputRightElement>
</InputGroup>
{error && <FormErrorMessage>{error}</FormErrorMessage>}
</FormControl>
<Center>
<Link as={RouterLink} to="/recover-password" color="blue.500">
Forgot password?
</Link>
</Center>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
>
Log In
</Button>
</Container>
</>
)
}
export default Login

View File

@@ -0,0 +1,98 @@
import {
Button,
Container,
FormControl,
FormErrorMessage,
Heading,
Input,
Text,
} from '@chakra-ui/react'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { SubmitHandler, useForm } from 'react-hook-form'
import { LoginService } from '../client'
import useCustomToast from '../hooks/useCustomToast'
import { isLoggedIn } from '../hooks/useAuth'
interface FormData {
email: string
}
export const Route = createFileRoute('/recover-password')({
component: RecoverPassword,
beforeLoad: async () => {
if (isLoggedIn()) {
throw redirect({
to: '/',
})
}
},
})
function RecoverPassword() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>()
const showToast = useCustomToast()
const onSubmit: SubmitHandler<FormData> = async (data) => {
await LoginService.recoverPassword({
email: data.email,
})
showToast(
'Email sent.',
'We sent an email with a link to get back into your account.',
'success',
)
}
return (
<Container
as="form"
onSubmit={handleSubmit(onSubmit)}
h="100vh"
maxW="sm"
alignItems="stretch"
justifyContent="center"
gap={4}
centerContent
>
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
Password Recovery
</Heading>
<Text align="center">
A password recovery email will be sent to the registered account.
</Text>
<FormControl isInvalid={!!errors.email}>
<Input
id="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i,
message: 'Invalid email address',
},
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
isLoading={isSubmitting}
>
Continue
</Button>
</Container>
)
}
export default RecoverPassword

View File

@@ -0,0 +1,134 @@
import {
Button,
Container,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
Text,
} from '@chakra-ui/react'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useMutation } from 'react-query'
import { ApiError, LoginService, NewPassword } from '../client'
import { isLoggedIn } from '../hooks/useAuth'
import useCustomToast from '../hooks/useCustomToast'
interface NewPasswordForm extends NewPassword {
confirm_password: string
}
export const Route = createFileRoute('/reset-password')({
component: ResetPassword,
beforeLoad: async () => {
if (isLoggedIn()) {
throw redirect({
to: '/',
})
}
},
})
function ResetPassword() {
const {
register,
handleSubmit,
getValues,
formState: { errors },
} = useForm<NewPasswordForm>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: {
new_password: '',
},
})
const showToast = useCustomToast()
const resetPassword = async (data: NewPassword) => {
const token = new URLSearchParams(window.location.search).get('token')
await LoginService.resetPassword({
requestBody: { new_password: data.new_password, token: token! },
})
}
const mutation = useMutation(resetPassword, {
onSuccess: () => {
showToast('Success!', 'Password updated.', 'success')
},
onError: (err: ApiError) => {
const errDetail = err.body.detail
showToast('Something went wrong.', `${errDetail}`, 'error')
},
})
const onSubmit: SubmitHandler<NewPasswordForm> = async (data) => {
mutation.mutate(data)
}
return (
<Container
as="form"
onSubmit={handleSubmit(onSubmit)}
h="100vh"
maxW="sm"
alignItems="stretch"
justifyContent="center"
gap={4}
centerContent
>
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
Reset Password
</Heading>
<Text textAlign="center">
Please enter your new password and confirm it to reset your password.
</Text>
<FormControl mt={4} isInvalid={!!errors.new_password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register('new_password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
placeholder="Password"
type="password"
/>
{errors.new_password && (
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register('confirm_password', {
required: 'Please confirm your password',
validate: (value) =>
value === getValues().new_password ||
'The passwords do not match',
})}
placeholder="Password"
type="password"
/>
{errors.confirm_password && (
<FormErrorMessage>{errors.confirm_password.message}</FormErrorMessage>
)}
</FormControl>
<Button
bg="ui.main"
color="white"
_hover={{ opacity: 0.8 }}
type="submit"
>
Reset Password
</Button>
</Container>
)
}
export default ResetPassword

27
frontend/src/theme.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { extendTheme } from '@chakra-ui/react'
const theme = extendTheme({
colors: {
ui: {
main: '#009688',
secondary: '#EDF2F7',
success: '#48BB78',
danger: '#E53E3E',
},
},
components: {
Tabs: {
variants: {
enclosed: {
tab: {
_selected: {
color: 'ui.main',
},
},
},
},
},
},
})
export default theme

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

8
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import react from '@vitejs/plugin-react-swc'
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), TanStackRouterVite()],
})