From 61a6f617f417fdf4562080dc3564c6df101957aa Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Fri, 30 Jun 2023 14:41:05 +0200 Subject: [PATCH 01/45] Use remix-auth for local user --- app/auth.server.ts | 45 +++++++++++++++++++++++++++++++++++++ app/routes/login.tsx | 50 ++++++++++++++++++------------------------ app/routes/logout.tsx | 10 ++------- app/routes/profile.tsx | 22 ++++++++----------- app/session.server.ts | 2 +- package-lock.json | 29 ++++++++++++++++++++---- package.json | 4 +++- 7 files changed, 106 insertions(+), 56 deletions(-) create mode 100644 app/auth.server.ts diff --git a/app/auth.server.ts b/app/auth.server.ts new file mode 100644 index 00000000..e35c3c06 --- /dev/null +++ b/app/auth.server.ts @@ -0,0 +1,45 @@ +import { Authenticator } from "remix-auth"; +import { FormStrategy } from "remix-auth-form"; +import { sessionStorage } from "./session.server"; + + +export interface User { + id: string + email: string + // TODO add bartender token generated for this user +} + +// Create an instance of the authenticator, pass a generic with what +// strategies will return and will store in the session +export let authenticator = new Authenticator(sessionStorage, { + throwOnError: true, +}); + +async function login(email: string, password: string) { + // TODO check password + // TODO get id + return { id: email, email } +} + + +// Tell the Authenticator to use the form strategy +authenticator.use( + new FormStrategy(async ({ form }) => { + let email = form.get("email"); + let password = form.get("password"); + console.log({email, password}) + if (email === null || password == null || typeof email !== "string" || typeof password !== 'string') { + // TODO use zod for validation + throw new Error('Email and password must be filled.') + } + let user = await login(email, password); + console.log(user) + // the type of this user must match the type you pass to the Authenticator + // the strategy will automatically inherit the type if you instantiate + // directly inside the `use` method + return user; + }), + // each strategy has a name and can be changed to use another one + // same strategy multiple times, especially useful for the OAuth2 strategy. + "user-pass" +); \ No newline at end of file diff --git a/app/routes/login.tsx b/app/routes/login.tsx index ee9def01..63ee37c6 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,33 +1,23 @@ -import { type ActionArgs, json, redirect } from "@remix-run/node"; +import type { LoaderArgs, ActionArgs } from "@remix-run/node"; import { Form, Link } from "@remix-run/react"; -import { localLogin } from "~/models/user.server"; -import { commitSession, setSession } from "~/session.server"; +import { authenticator } from "~/auth.server"; -export async function action({ request }: ActionArgs) { - const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - - if (typeof username !== "string" || typeof password !== "string") { - return json( - { - errors: { - username: "Email is required", - password: "Password is required", - }, - }, - { status: 400 } - ); - } - - const access_token = await localLogin(username, password); - const session = await setSession(access_token, request); - - return redirect("/", { - headers: { - "Set-Cookie": await commitSession(session), - }, +// Finally, we can export a loader function where we check if the user is +// authenticated with `authenticator.isAuthenticated` and redirect to the +// dashboard if it is or return null if it's not +export async function loader({ request }: LoaderArgs) { + // If the user is already authenticated redirect to /dashboard directly + const r= await authenticator.isAuthenticated(request, { + successRedirect: "/", }); + console.log(r) + return r +}; + +export async function action({ request }: ActionArgs) { + const r = await authenticator.authenticate("user-pass", request); + console.log(r) + return r } export default function LoginPage() { @@ -47,11 +37,12 @@ export default function LoginPage() { diff --git a/app/routes/upload.tsx b/app/routes/upload.tsx index 4680bdfe..9b10eddd 100644 --- a/app/routes/upload.tsx +++ b/app/routes/upload.tsx @@ -14,10 +14,14 @@ import { } from "~/models/user.server"; import { getSession } from "~/session.server"; import { WORKFLOW_CONFIG_FILENAME } from "~/models/constants"; +import { authenticator } from "~/auth.server"; export const loader = async ({ request }: LoaderArgs) => { - const session = await getSession(request); - checkAuthenticated(session.data.bartenderToken); + let user = await authenticator.isAuthenticated(request); + if (!user) { + return redirect("/login"); + } + // TODO get roles of current user const level = await getLevel(session.data.roles); if (!isSubmitAllowed(level)) { throw new Error("Forbidden"); @@ -33,6 +37,7 @@ export const action = async ({ request }: ActionArgs) => { throw new Error("Bad upload"); } const session = await getSession(request); + // TODO fetch token for user const accessToken = session.data.bartenderToken; checkAuthenticated(accessToken); const level = await getLevel(session.data.roles); diff --git a/app/session.server.ts b/app/session.server.ts index 8ea2be16..07635720 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -1,46 +1,20 @@ -import { createCookie, createFileSessionStorage } from "@remix-run/node"; -import { getCurrentUser } from "./models/user.server"; +import { + createCookieSessionStorage, +} from "@remix-run/node"; -type SessionData = { - bartenderToken: string; - isSuperUser: boolean; - roles: string[]; -}; +const COOKIE_NAME = "haddock3_webapp_session"; -type SessionFlashData = { - error: string; -}; - -const COOKIE_NAME = "bartended_haddock3_session"; - -const sessionCookie = createCookie(COOKIE_NAME, { +export const sessionStorage = createCookieSessionStorage({ // TODO add secret + domain + path - sameSite: true, - httpOnly: true, - maxAge: 604_800, // one week - path: "/", - secrets: [process.env.SESSION_SECRET || "somebadsecret"], - secure: process.env.NODE_ENV === "production", -}); - -export const sessionStorage = createFileSessionStorage({ - cookie: sessionCookie, - dir: "./sessions", + cookie: { + name: COOKIE_NAME, + httpOnly: true, + path: "/", + sameSite: "lax", + maxAge: 604_800, // one week + secrets: [process.env.SESSION_SECRET || "somebadsecret"], + secure: process.env.NODE_ENV === "production", + }, }); -export async function getSession(request: Request) { - const cookie = request.headers.get("Cookie"); - return await sessionStorage.getSession(cookie); -} - -export const commitSession = sessionStorage.commitSession; -export const destroySession = sessionStorage.destroySession; - -export async function setSession(access_token: string, request: Request) { - const session = await getSession(request); - const user = await getCurrentUser(access_token); - session.set("bartenderToken", access_token); - session.set("isSuperUser", user.isSuperuser!); - session.set("roles", user.roles ?? []); - return session; -} +export const { getSession, commitSession, destroySession } = sessionStorage; diff --git a/app/utils/db.server.ts b/app/utils/db.server.ts new file mode 100644 index 00000000..d6e9af5e --- /dev/null +++ b/app/utils/db.server.ts @@ -0,0 +1,23 @@ +import { PrismaClient } from "@prisma/client"; + +let db: PrismaClient; + +declare global { + var __db__: PrismaClient | undefined; +} + +// This is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +// In production, we'll have a single connection to the DB. +if (process.env.NODE_ENV === "production") { + db = new PrismaClient(); +} else { + if (!global.__db__) { + global.__db__ = new PrismaClient(); + } + db = global.__db__; + db.$connect(); +} + +export { db }; diff --git a/package-lock.json b/package-lock.json index 8e81f5e4..6a57396d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "version": "0.1.0", "dependencies": { "@i-vresse/wb-core": "^1.2.0", + "@prisma/client": "^5.0.0", "@remix-run/node": "^1.16.1", "@remix-run/react": "^1.16.1", "@remix-run/serve": "^1.16.1", + "bcryptjs": "^2.4.3", "isbot": "^3.6.5", "jose": "^4.13.1", "js-yaml": "^4.1.0", @@ -18,7 +20,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "remix-auth": "^3.4.0", - "remix-auth-form": "^1.3.0" + "remix-auth-form": "^1.3.0", + "remix-auth-github": "^1.4.0" }, "devDependencies": { "@ltd/j-toml": "^1.38.0", @@ -29,6 +32,7 @@ "@tailwindcss/typography": "^0.5.9", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", + "@types/bcryptjs": "^2.4.2", "@types/cookie": "^0.5.1", "@types/eslint": "^8.37.0", "@types/js-yaml": "^4.0.5", @@ -42,7 +46,9 @@ "happy-dom": "^9.20.3", "prettier": "^2.8.7", "prettier-plugin-tailwindcss": "^0.2.7", + "prisma": "^5.0.0", "tailwindcss": "^3.2.7", + "ts-node": "^10.9.1", "typescript": "~4.8.4", "vite-tsconfig-paths": "^4.0.5", "vitest": "^0.31.2" @@ -1965,6 +1971,28 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", @@ -3203,6 +3231,38 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@prisma/client": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.0.0.tgz", + "integrity": "sha512-XlO5ELNAQ7rV4cXIDJUNBEgdLwX3pjtt9Q/RHqDpGf43szpNJx2hJnggfFs7TKNx0cOFsl6KJCSfqr5duEU/bQ==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines-version": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584" + }, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/engines": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.0.0.tgz", + "integrity": "sha512-kyT/8fd0OpWmhAU5YnY7eP31brW1q1YrTGoblWrhQJDiN/1K+Z8S1kylcmtjqx5wsUGcP1HBWutayA/jtyt+sg==", + "devOptional": true, + "hasInstallScript": true + }, + "node_modules/@prisma/engines-version": { + "version": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584.tgz", + "integrity": "sha512-HHiUF6NixsldsP3JROq07TYBLEjXFKr6PdH8H4gK/XAoTmIplOJBCgrIUMrsRAnEuGyRoRLXKXWUb943+PFoKQ==" + }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", @@ -3799,6 +3859,30 @@ "node": ">= 6" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "node_modules/@types/acorn": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", @@ -3814,6 +3898,12 @@ "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==", "dev": true }, + "node_modules/@types/bcryptjs": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.2.tgz", + "integrity": "sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==", + "dev": true + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -5135,6 +5225,11 @@ "node": ">= 0.8" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/big-integer": { "version": "1.6.51", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", @@ -6128,6 +6223,12 @@ "node": ">=10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -10944,6 +11045,12 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/markdown-extensions": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz", @@ -13116,6 +13223,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prisma": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.0.0.tgz", + "integrity": "sha512-KYWk83Fhi1FH59jSpavAYTt2eoMVW9YKgu8ci0kuUnt6Dup5Qy47pcB4/TLmiPAbhGrxxSz7gsSnJcCmkyPANA==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.0.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + } + }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -13920,6 +14043,32 @@ "remix-auth": "^3.4.0" } }, + "node_modules/remix-auth-github": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/remix-auth-github/-/remix-auth-github-1.4.0.tgz", + "integrity": "sha512-oHaoSMh35iiH6sShbSKXl8DHNNEZQnBgIw7ePhzKT8+2xhVAlokgSDF9Mi0kdB5r/AMi+DZhxWkrDTog/6USgQ==", + "dependencies": { + "remix-auth-oauth2": "^1.4.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0", + "remix-auth": "^3.4.0" + } + }, + "node_modules/remix-auth-oauth2": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/remix-auth-oauth2/-/remix-auth-oauth2-1.7.0.tgz", + "integrity": "sha512-hgSJWz1j13Zf1tcNdSs0OFXikDda24md+E93xtQQKIuuIUZ7YtlujT/pnXeqTjJ0f8DwBrzGyPGNF/lugxT6WA==", + "dependencies": { + "debug": "^4.3.4", + "remix-auth": "^3.4.0", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0", + "remix-auth": "^3.2.2" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -15116,6 +15265,64 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tsconfck": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-2.0.3.tgz", @@ -15137,9 +15344,9 @@ } }, "node_modules/tsconfig-paths": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.2.tgz", - "integrity": "sha512-uhxiMgnXQp1IR622dUXI+9Ehnws7i/y6xvpZB9IbUVOPy0muvdvgXeZOn88UcGPiT98Vp3rJPTa8bFoalZ3Qhw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", "dev": true, "dependencies": { "json5": "^2.2.2", @@ -15609,6 +15816,12 @@ "node": ">=8" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -16313,6 +16526,15 @@ "node": ">=10" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0e97fcc1..27c68c6b 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,11 @@ ], "dependencies": { "@i-vresse/wb-core": "^1.2.0", + "@prisma/client": "^5.0.0", "@remix-run/node": "^1.16.1", "@remix-run/react": "^1.16.1", "@remix-run/serve": "^1.16.1", + "bcryptjs": "^2.4.3", "isbot": "^3.6.5", "jose": "^4.13.1", "js-yaml": "^4.1.0", @@ -36,7 +38,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "remix-auth": "^3.4.0", - "remix-auth-form": "^1.3.0" + "remix-auth-form": "^1.3.0", + "remix-auth-github": "^1.4.0" }, "devDependencies": { "@ltd/j-toml": "^1.38.0", @@ -47,6 +50,7 @@ "@tailwindcss/typography": "^0.5.9", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", + "@types/bcryptjs": "^2.4.2", "@types/cookie": "^0.5.1", "@types/eslint": "^8.37.0", "@types/js-yaml": "^4.0.5", @@ -60,12 +64,17 @@ "happy-dom": "^9.20.3", "prettier": "^2.8.7", "prettier-plugin-tailwindcss": "^0.2.7", + "prisma": "^5.0.0", "tailwindcss": "^3.2.7", + "ts-node": "^10.9.1", "typescript": "~4.8.4", "vite-tsconfig-paths": "^4.0.5", "vitest": "^0.31.2" }, "engines": { "node": ">=18" + }, + "prisma": { + "seed": "ts-node prisma/seed.mts" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..4fe0483f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,37 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + // User can have no password if they use OAuth + passwordHash String? + roles Role[] + oauthAccounts OAUthAccount[] +} + +model Role { + name String @id + users User[] +} + +model OAUthAccount { + id String @id @default(uuid()) + provider String + accessToken String + refreshToken String + expiresAt DateTime? + user User @relation(fields: [userId], references: [id]) + userId String +} \ No newline at end of file diff --git a/prisma/seed.mts b/prisma/seed.mts new file mode 100644 index 00000000..e629f271 --- /dev/null +++ b/prisma/seed.mts @@ -0,0 +1,22 @@ +import { PrismaClient } from "@prisma/client"; +const db = new PrismaClient(); + +async function seed() { + + await Promise.all( + // TODO use createMany when postgresql is used + getRoles().map(async (role) => { + return db.role.create({ + data: { + name: role, + }, + }); + }) + ); +} + +seed(); + +function getRoles() { + return ["guru", "expert", "easy"]; +} diff --git a/tsconfig.json b/tsconfig.json index 675787b4..55fde9fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,8 @@ // Remix takes care of building everything in `remix build`. "noEmit": true + }, + "ts-node": { + "esm": true } } From e3af0d07978c82f39096eb9aac3348a32ec33be7 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Tue, 25 Jul 2023 08:10:03 +0200 Subject: [PATCH 05/45] type fix --- app/models/user.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.server.ts b/app/models/user.server.ts index f7e7abb9..93483ae7 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -40,7 +40,7 @@ export async function localLogin(email: string, password: string) { if (!user) { throw new Error("User not found"); } - const isValid = await compare(password, user.passwordHash); + const isValid = await compare(password, user.passwordHash || ''); if (!isValid) { throw new Error("Wrong password"); } From 03dee09b36ab68fdfa3b4ade6e46807a07cb3cb0 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Tue, 25 Jul 2023 17:04:29 +0200 Subject: [PATCH 06/45] Got GH login to work + bartender token generation in theory --- .env.example | 1 + .gitignore | 3 + README.md | 24 ++- TODO.md | 18 +- app/auth.server.ts | 95 +++++----- app/auth.ts | 61 ++++++ app/bartender_token.server.ts | 64 +++++++ app/components/Navbar.tsx | 10 +- app/components/admin/UserTableRow.tsx | 22 +-- app/models/user.server.ts | 249 ++++++++++++++----------- app/root.tsx | 12 +- app/routes/admin/index.tsx | 10 +- app/routes/admin/users.tsx | 37 +--- app/routes/auth/$provider/authorize.ts | 11 +- app/routes/builder.tsx | 20 +- app/routes/jobs/$id.edit.tsx | 11 +- app/routes/jobs/$id.tsx | 2 +- app/routes/jobs/$id/[input.zip].tsx | 2 +- app/routes/jobs/$id/[output.zip].tsx | 2 +- app/routes/jobs/$id/archive/$.ts | 2 +- app/routes/jobs/$id/files/$.ts | 2 +- app/routes/jobs/$id/stderr.tsx | 2 +- app/routes/jobs/$id/stdout.tsx | 2 +- app/routes/jobs/$id/zip.tsx | 2 +- app/routes/jobs/index.tsx | 2 +- app/routes/login.tsx | 93 ++++----- app/routes/profile.tsx | 32 ++-- app/routes/register.tsx | 6 +- app/routes/upload.tsx | 19 +- app/session.server.ts | 5 +- app/session.ts | 15 -- app/token.server.ts | 25 --- prisma/schema.prisma | 11 -- prisma/seed.mts | 13 +- 34 files changed, 486 insertions(+), 399 deletions(-) create mode 100644 .env.example create mode 100644 app/auth.ts create mode 100644 app/bartender_token.server.ts delete mode 100644 app/session.ts delete mode 100644 app/token.server.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..6532ba3e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL=file:./dev.db \ No newline at end of file diff --git a/.gitignore b/.gitignore index 672257da..dc10f472 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ node_modules /coverage /prisma/dev.db + +/private_key.pem +/public_key.pem \ No newline at end of file diff --git a/README.md b/README.md index 1ec8c4fd..0930aad3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,17 @@ sequenceDiagram - [Remix Docs](https://remix.run/docs) +## Setup + +```shell +npm install +cp .env.example .env +npx prisma db push +npx prisma db seed +``` + +First user that registers is a admin user. + ## Development From your terminal: @@ -151,16 +162,17 @@ npm start ### Social login -To enable GitHub or Orcid or EGI Check-in login the bartender web service needs following environment variables. +To enable GitHub or Orcid or EGI Check-in login the web apps needs following environment variables. ```shell -BARTENDER_GITHUB_REDIRECT_URL="http://localhost:3000/auth/github/callback" -BARTENDER_ORCIDSANDBOX_REDIRECT_URL="http://localhost:3000/auth/orcidsandbox/callback" -BARTENDER_ORCID_REDIRECT_URL="http://localhost:3000/auth/orcid/callback" -BARTENDER_EGI_REDIRECT_URL="http://localhost:3000/auth/egi/callback" +HADDOCK3WEBAPP_GITHUB_CLIENT_ID=... +HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=... +HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback ``` -Where `http://localhost:3000` is the URL where the Remix run app is running. +The environment variables can also be stored in a `.env` file. + +Only use social logins where the email address has been verified. ## Haddock3 application diff --git a/TODO.md b/TODO.md index d73da5bf..3290e3b5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,10 @@ -* [ ] store private key for making bartender tokens -* [ ] use remix auth for local users with remix-auth-form -* [ ] use remix-auth-oauth2 for - * [ ] github - * [ ] orcid sandbox - * [ ] orcid prod - * [ ] egi-checkin -* [ ] associate local user with oauth account aka mail matching +- [ ] store private key for making bartender tokens +- [x] use remix auth for local users with remix-auth-form +- [ ] use remix-auth-oauth2 for + - [x] github + - [ ] orcid sandbox + - [ ] orcid prod + - [ ] egi-checkin +- [x] associate local user with oauth account aka mail matching +- [ ] dont generate bartender token every time, but store/fetch from db +- [ ] store users in postgresql instead of sqlite diff --git a/app/auth.server.ts b/app/auth.server.ts index eff20443..6d4a1f2f 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -2,21 +2,18 @@ import { Authenticator } from "remix-auth"; import { GitHubStrategy } from "remix-auth-github"; import { FormStrategy } from "remix-auth-form"; import { sessionStorage } from "./session.server"; -import { localLogin } from "./models/user.server"; -import { db } from "./utils/db.server"; - - -export interface User { - id: string - email: string - // TODO add bartender token generated for this user - // TODO verify email -} +import { + getUserById, + localLogin, + oauthregister, + verifyIsAdmin, +} from "./models/user.server"; +import { json } from "@remix-run/node"; // Create an instance of the authenticator, pass a generic with what // strategies will return and will store in the session -export let authenticator = new Authenticator(sessionStorage, { - throwOnError: true, +export let authenticator = new Authenticator(sessionStorage, { + throwOnError: true, }); // Tell the Authenticator to use the form strategy @@ -24,50 +21,60 @@ authenticator.use( new FormStrategy(async ({ form }) => { let email = form.get("email"); let password = form.get("password"); - if (email === null || password == null || typeof email !== "string" || typeof password !== 'string') { - // TODO use zod for validation - throw new Error('Email and password must be filled.') + if ( + email === null || + password == null || + typeof email !== "string" || + typeof password !== "string" + ) { + // TODO use zod for validation + throw new Error("Email and password must be filled."); } - return await localLogin(email, password); + const user = await localLogin(email, password); + return user.id; }), "user-pass" ); - -if (process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID && process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET) { - +if ( + process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID && + process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET +) { let gitHubStrategy = new GitHubStrategy( { clientID: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID, clientSecret: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET!, - callbackURL: process.env.HADDOCK3WEBAPP_GITHUB_CALLBACK_URL || "http://localhost:3000/auth/github/callback", + callbackURL: + process.env.HADDOCK3WEBAPP_GITHUB_CALLBACK_URL || + "http://localhost:3000/auth/github/callback", }, - async ({ accessToken, extraParams, profile }) => { - console.log({ - accessToken, - extraParams, - profile, - }) - // TODO fetch if user exists - await db.oAUthAccount.create({ - data: { - provider: 'github', - accessToken: accessToken, - refreshToken: '', - expiresAt: new Date(extraParams.accessTokenExpiresIn! * 1000), - user: { - create: { - email: profile.emails[0].value, - } - } - } - }); - return { - id: profile.id, - email: profile.emails[0].value, - } + async ({ profile }) => { + // TODO store photo or avatar so it can be displayed in NavBar + const primaryEmail = profile.emails[0].value; + const userId = await oauthregister(primaryEmail); + return userId; } ); authenticator.use(gitHubStrategy); } + +export async function getUser(request: Request) { + const userId = await authenticator.isAuthenticated(request); + if (userId === null) { + return null; + } + const user = await getUserById(userId); + return user; +} + +export async function mustBeAdmin(request: Request) { + const userId = await authenticator.isAuthenticated(request); + if (userId === null) { + throw json("Unauthorized", { status: 401 }); + } + const isAdmin = await verifyIsAdmin(userId); + if (!isAdmin) { + throw json("Forbidden", { status: 403 }); + } +} diff --git a/app/auth.ts b/app/auth.ts new file mode 100644 index 00000000..bdeb4b61 --- /dev/null +++ b/app/auth.ts @@ -0,0 +1,61 @@ +import { useMatches } from "@remix-run/react"; +import type { User } from "./models/user.server"; +import { useMemo } from "react"; + +/** + * This base hook is used in other hooks to quickly search for specific data + * across all loader data using useMatches. + * @param {string} id The route id + * @returns {JSON|undefined} The router data or undefined if not found + */ +export function useMatchesData( + id: string +): Record | undefined { + const matchingRoutes = useMatches(); + const route = useMemo( + () => matchingRoutes.find((route) => route.id === id), + [matchingRoutes, id] + ); + return route?.data; +} + +function isUser(user: any): user is User { + return user && typeof user === "object" && typeof user.email === "string"; +} + +export function useOptionalUser(): User | undefined { + const data = useMatchesData("root"); + if (!data || !isUser(data.user)) { + return undefined; + } + return data.user; +} + +export function useUser(): User { + const maybeUser = useOptionalUser(); + if (!maybeUser) { + throw new Error( + "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." + ); + } + return maybeUser; +} + +export function useIsAdmin(): boolean { + const user = useOptionalUser(); + return !!user?.roles.find((role) => role.name === "admin"); +} + +export function useIsLoggedIn(): boolean { + return !!useOptionalUser(); +} + +export function availableSocialLogins() { + return Object.keys(process.env) + .filter( + (key) => key.startsWith("HADDOCK3WEBAPP_") && key.endsWith("_CLIENT_ID") + ) + .map((key) => + key.replace("HADDOCK3WEBAPP_", "").replace("_CLIENT_ID", "").toLowerCase() + ); +} diff --git a/app/bartender_token.server.ts b/app/bartender_token.server.ts new file mode 100644 index 00000000..001aa38a --- /dev/null +++ b/app/bartender_token.server.ts @@ -0,0 +1,64 @@ +/** + * Functions dealing with access token of bartender web service. + */ +import { readFile } from "fs/promises"; +import { type KeyLike, SignJWT, importPKCS8 } from "jose"; +import { getUser } from "./auth.server"; +import { json } from "@remix-run/node"; +import { getLevel, isSubmitAllowed } from "./models/user.server"; + + +class TokenGenerator { + private privateKeyFilename: string; + private issuer: string; + private privateKey: KeyLike | undefined = undefined; + constructor(privateKeyFilename: string, issuer: string = 'bartender') { + this.privateKeyFilename = privateKeyFilename; + this.issuer = issuer; + } + + async init() { + if (this.privateKey) { + return + } + const alg = 'RS256' + const privateKeyBody = await readFile(this.privateKeyFilename, 'utf8'); + const privateKey = await importPKCS8(privateKeyBody, alg); + this.privateKey = privateKey; + } + + async generate(sub: string, email: string, roles: string[]) { + if (!this.privateKey) { + throw new Error('private key not initialized') + } + const jwt = await new SignJWT({ + email: email, + roles: roles, + }).setIssuer(this.issuer) + .setExpirationTime('30d') + .setIssuedAt() + .setSubject(sub) + .setProtectedHeader({ alg: 'RS256' }) + .sign(this.privateKey); + return jwt + } + +} +const privateKeyFilename = process.env.BARTENDER_PRIVATE_KEY || 'private_key.pem'; +// TODO only use singleton if reading private key is slow +const generator = new TokenGenerator(privateKeyFilename); + +export async function getAccessToken(request:Request) { + const user = await getUser(request); + if (!user) { + throw json({ error: "Unauthorized" }, { status: 401 }); + } + const roles = user.roles.map(r => r.name) + const level = await getLevel(roles); + if (!isSubmitAllowed(level)) { + throw json({ error: "Forbidden" }, { status: 403 }); + } + + await generator.init(); + return await generator.generate(user.id, user.email, roles); +} \ No newline at end of file diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 81ea5b0e..72944605 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -1,8 +1,8 @@ import { Link, NavLink } from "@remix-run/react"; -import { useIsAuthenticated, useIsSuperUser } from "~/session"; +import { useIsAdmin, useIsLoggedIn } from "~/auth"; const LoggedInButton = () => { - const isSuperUser = useIsSuperUser(); + const isAdmin = useIsAdmin(); return (
); diff --git a/app/components/admin/UserTableRow.tsx b/app/components/admin/UserTableRow.tsx index 541725cf..40127f0e 100644 --- a/app/components/admin/UserTableRow.tsx +++ b/app/components/admin/UserTableRow.tsx @@ -1,29 +1,17 @@ -import type { UserAsListItem } from "~/bartender-client"; +import type { User } from "~/models/user.server"; interface IProps { - user: UserAsListItem; + user: User; roles: string[]; onUpdate: (data: FormData) => void; submitting: boolean; } export const UserTableRow = ({ user, roles, onUpdate, submitting }: IProps) => { + const userRoles = user.roles.map((r) => r.name); return ( {user.email} - - { - const data = new FormData(); - data.set("isSuperuser", user.isSuperuser ? "false" : "true"); - onUpdate(data); - }} - /> -
    {roles.map((role) => { @@ -34,14 +22,14 @@ export const UserTableRow = ({ user, roles, onUpdate, submitting }: IProps) => { {role} { const data = new FormData(); data.set( role, - user.roles.includes(role) ? "false" : "true" + userRoles.includes(role) ? "false" : "true" ); onUpdate(data); }} diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 93483ae7..7dcafd8e 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,34 +1,49 @@ -import { RolesApi } from "~/bartender-client"; -import { UsersApi } from "~/bartender-client"; -import { AuthApi } from "~/bartender-client/apis/AuthApi"; -import { buildConfig } from "./config.server"; import { db } from "~/utils/db.server"; import { compare, hash } from "bcryptjs"; -function buildAuthApi(accessToken: string = "") { - return new AuthApi(buildConfig(accessToken)); +export interface User { + readonly id: string; + readonly email: string; + readonly roles: { + readonly name: string; + }[]; } -function buildUsersApi(accessToken: string) { - return new UsersApi(buildConfig(accessToken)); -} - -function buildRolesApi(accessToken: string) { - return new RolesApi(buildConfig(accessToken)); -} +const userSelect = { + id: true, + email: true, + roles: { + select: { + name: true, + }, + }, +} as const; export async function register(email: string, password: string) { + const roles = await firstUserShouldBeAdmin(); const passwordHash = await hash(password, 10); - return db.user.create({ + const user = await db.user.create({ data: { - email, + email, + roles, passwordHash, }, - select: { - id: true, - email: true, - } + select: userSelect, }); + return user; +} + +async function firstUserShouldBeAdmin() { + const userCount = await db.user.count(); + const roles = + userCount === 0 + ? { + connect: { + name: "admin", + }, + } + : undefined; + return roles; } export async function localLogin(email: string, password: string) { @@ -36,73 +51,79 @@ export async function localLogin(email: string, password: string) { where: { email: email, }, + select: { + ...userSelect, + passwordHash: true, + }, }); if (!user) { throw new Error("User not found"); } - const isValid = await compare(password, user.passwordHash || ''); + const { passwordHash, ...userWithoutPasswordHash } = user; + const isValid = await compare(password, user.passwordHash || ""); if (!isValid) { throw new Error("Wrong password"); } - return { - id: user.id, - email: user.email, - } + + return userWithoutPasswordHash; } -export async function getProfile(accessToken: string) { - const api = buildUsersApi(accessToken); - return await api.profile(); +export async function oauthregister(email: string) { + const roles = await firstUserShouldBeAdmin(); + const user = await db.user.upsert({ + where: { + email: email, + }, + create: { + email: email, + roles, + }, + update: {}, + select: { + id: true, + }, + }); + return user.id; } -export async function oauthAuthorize(provider: string) { - const api = buildAuthApi(); - let url: string; - switch (provider) { - case "github": - url = (await api.oauthGithubRemoteAuthorize()).authorizationUrl; - break; - case "orcid": - url = (await api.oauthOrcidOrgRemoteAuthorize()).authorizationUrl; - break; - case "orcidsandbox": - url = (await api.oauthSandboxOrcidOrgRemoteAuthorize()).authorizationUrl; - break; - case "egi": - url = (await api.oauthEGICheckInRemoteAuthorize()).authorizationUrl; - break; - default: - throw new Error("Unknown provider"); +export async function getUserById(userId: string) { + const user = await db.user.findUnique({ + where: { + id: userId, + }, + select: userSelect, + }); + if (!user) { + throw new Error("User not found"); } - return url; + return user; } -export async function oauthCallback(provider: string, search: URLSearchParams) { - const api = buildAuthApi(); - const request = { - code: search.get("code") || undefined, - codeVerifier: search.get("code_verifier") || undefined, - state: search.get("state") || undefined, - error: search.get("error") || undefined, - }; - let response: any; - switch (provider) { - case "github": - response = await api.oauthGithubRemoteCallback(request); - break; - case "orcid": - response = await api.oauthOrcidOrgRemoteCallback(request); - break; - case "orcidsandbox": - response = await api.oauthSandboxOrcidOrgRemoteCallback(request); - break; - case "egi": - response = await api.oauthEGICheckInRemoteCallback(request); - break; - default: - throw new Error("Unknown provider"); +export async function getUserByEmail(email: string) { + const user = await db.user.findUnique({ + where: { + email: email, + }, + select: userSelect, + }); + if (!user) { + throw new Error("User not found"); } - return response.access_token; + return user; +} + +export async function verifyIsAdmin(userId: string) { + const result = await db.user.findUnique({ + where: { + id: userId, + roles: { + some: { + name: "admin", + }, + }, + }, + }); + return !!result; } export async function getLevel( @@ -132,55 +153,59 @@ export function isSubmitAllowed(level: string) { return level !== ""; } -export async function getCurrentUser(accessToken: string) { - const api = buildUsersApi(accessToken); - return await api.usersCurrentUser(); -} - -export async function listUsers(accessToken: string, limit = 100, offset = 0) { - const api = buildUsersApi(accessToken); - return await api.listUsers({ limit, offset }); -} - -export async function listRoles(accessToken: string) { - const api = buildRolesApi(accessToken); - return await api.listRoles(); +export async function listUsers(limit = 100, offset = 0) { + const users = await db.user.findMany({ + select: userSelect, + take: limit, + skip: offset, + }); + return users; } -export async function setSuperUser( - accessToken: string, - userId: string, - checked: boolean -) { - const api = buildUsersApi(accessToken); - return await api.usersPatchUser({ - id: userId, - userUpdate: { - isSuperuser: checked, +export async function listRoles() { + const roles = await db.role.findMany({ + select: { + name: true, + }, + orderBy: { + name: "asc", }, }); + return roles.map((role) => role.name); } -export async function assignRole( - accessToken: string, - userId: string, - roleId: string -) { - const api = buildRolesApi(accessToken); - api.assignRoleToUser({ - userId, - roleId, +export async function assignRole(userId: string, roleId: string) { + await db.user.update({ + where: { + id: userId, + }, + data: { + roles: { + connect: { + name: roleId, + }, + }, + }, + select: { + id: true, + }, }); } -export async function unassignRole( - accessToken: string, - userId: string, - roleId: string -) { - const api = buildRolesApi(accessToken); - api.unassignRoleFromUser({ - userId, - roleId, +export async function unassignRole(userId: string, roleId: string) { + await db.user.update({ + where: { + id: userId, + }, + data: { + roles: { + disconnect: { + name: roleId, + }, + }, + }, + select: { + id: true, + }, }); } diff --git a/app/root.tsx b/app/root.tsx index 4d4922dc..4fe84f82 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -16,6 +16,7 @@ import { import { Navbar } from "~/components/Navbar"; import styles from "./tailwind.css"; import { authenticator } from "./auth.server"; +import { getUserById } from "./models/user.server"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; @@ -26,11 +27,12 @@ export const meta: MetaFunction = () => ({ }); export async function loader({ request }: LoaderArgs) { - const user = await authenticator.isAuthenticated(request); - return json({ - isAuthenticated: user !== null, - isSuperUser: true, // TODO store this in db - }); + const userId = await authenticator.isAuthenticated(request); + if (userId === null) { + return json({ user: null }); + } + const user = await getUserById(userId); + return json({ user }); } export default function App() { diff --git a/app/routes/admin/index.tsx b/app/routes/admin/index.tsx index e0d9f2da..f37bc95f 100644 --- a/app/routes/admin/index.tsx +++ b/app/routes/admin/index.tsx @@ -1,16 +1,10 @@ import type { LoaderArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Link } from "@remix-run/react"; -import { checkAuthenticated } from "~/models/user.server"; -import { getSession } from "~/session.server"; +import { mustBeAdmin } from "~/auth.server"; export async function loader({ request }: LoaderArgs) { - const session = await getSession(request); - const accessToken = session.data.bartenderToken; - checkAuthenticated(accessToken); - if (!session.data.isSuperUser) { - throw new Error("Forbidden"); - } + await mustBeAdmin(request); return json({}); } diff --git a/app/routes/admin/users.tsx b/app/routes/admin/users.tsx index 0a2e022b..1d45bf11 100644 --- a/app/routes/admin/users.tsx +++ b/app/routes/admin/users.tsx @@ -2,26 +2,18 @@ import type { ActionArgs, LoaderArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useFetcher, useLoaderData } from "@remix-run/react"; import { UserTableRow } from "~/components/admin/UserTableRow"; -import { getAccessToken } from "~/token.server"; import { assignRole, - checkAuthenticated, listRoles, listUsers, - setSuperUser, unassignRole, } from "~/models/user.server"; -import { getSession } from "~/session.server"; +import { mustBeAdmin } from "~/auth.server"; export async function loader({ request }: LoaderArgs) { - const session = await getSession(request); - const accessToken = session.data.bartenderToken; - checkAuthenticated(accessToken); - if (!session.data.isSuperUser) { - throw new Error("Forbidden"); - } - const users = await listUsers(accessToken!); - const roles = await listRoles(accessToken!); + await mustBeAdmin(request); + const users = await listUsers(); + const roles = await listRoles(); return json({ users, roles, @@ -29,27 +21,20 @@ export async function loader({ request }: LoaderArgs) { } export async function action({ request }: ActionArgs) { - const accessToken = await getAccessToken(request); - if (accessToken === undefined) { - throw new Error("Unauthenticated"); - } + await mustBeAdmin(request); // TODO is this needed? const formData = await request.formData(); const userId = formData.get("userId"); if (userId === null || typeof userId !== "string") { - throw new Error("Unknown user"); - } - const isSuperuser = formData.get("isSuperuser"); - if (isSuperuser !== null) { - setSuperUser(accessToken, userId, isSuperuser === "true"); + throw json({ error: "Unknown user" }, { status: 400 }); } - const roles = await listRoles(accessToken); + const roles = await listRoles(); for (const role of roles) { const roleState = formData.get(role); if (roleState !== null) { if (roleState === "true") { - assignRole(accessToken, userId, role); + await assignRole(userId, role); } else { - unassignRole(accessToken, userId, role); + await unassignRole(userId, role); } } } @@ -66,7 +51,6 @@ export default function AdminUsersPage() { Email - Super user Roles @@ -90,9 +74,6 @@ export default function AdminUsersPage() { })} -

    - When roles or super is changed then the user should logout and login. -

    ); } diff --git a/app/routes/auth/$provider/authorize.ts b/app/routes/auth/$provider/authorize.ts index 6c1249ee..9f19c53b 100644 --- a/app/routes/auth/$provider/authorize.ts +++ b/app/routes/auth/$provider/authorize.ts @@ -1,8 +1,17 @@ -import type { ActionArgs } from "@remix-run/node"; +import { json, type ActionArgs } from "@remix-run/node"; import { redirect } from "react-router"; +import { availableSocialLogins } from "~/auth"; import { authenticator } from "~/auth.server"; +export async function loader() { + return redirect("/login"); +} + export const action = async ({ params, request }: ActionArgs) => { const provider = params.provider || ""; + const socials = availableSocialLogins(); + if (!socials.includes(provider)) { + throw json("Not found", { status: 404 }); + } return authenticator.authenticate(provider, request); }; diff --git a/app/routes/builder.tsx b/app/routes/builder.tsx index 66cfa4a9..ec2f426a 100644 --- a/app/routes/builder.tsx +++ b/app/routes/builder.tsx @@ -1,17 +1,17 @@ -import { type ActionArgs, type LoaderArgs, redirect } from "@remix-run/node"; +import { type ActionArgs, type LoaderArgs, redirect, json } from "@remix-run/node"; import { getCatalog } from "~/catalogs/index.server"; import { Haddock3WorkflowBuilder } from "~/components/Haddock3/Form.client"; import { haddock3Styles } from "~/components/Haddock3/styles"; import { submitJob } from "~/models/applicaton.server"; import { - checkAuthenticated, getLevel, isSubmitAllowed, } from "~/models/user.server"; -import { getSession } from "~/session.server"; import { type ICatalog } from "@i-vresse/wb-core/dist/types"; import { ClientOnly } from "~/components/ClientOnly"; +import { getUser } from "~/auth.server"; +import { getAccessToken } from "~/bartender_token.server"; export const loader = async ({ request, @@ -20,8 +20,8 @@ export const loader = async ({ submitAllowed: boolean; archive: string | undefined; }> => { - const session = await getSession(request); - const level = await getLevel(session.data.roles); + const user = await getUser(request); + const level = await getLevel(user ? user.roles.map(r => r.name) : undefined); // When user does not have a level he/she // can still use builder with easy level // but cannot submit only download @@ -33,17 +33,11 @@ export const loader = async ({ export const action = async ({ request }: ActionArgs) => { const formData = await request.formData(); const upload = formData.get("upload"); - if (typeof upload === "string" || upload === null) { throw new Error("Bad upload"); } - const session = await getSession(request); - const accessToken = session.data.bartenderToken; - checkAuthenticated(accessToken); - const level = await getLevel(session.data.roles); - if (!isSubmitAllowed(level)) { - throw new Error("Forbidden"); - } + + const accessToken = await getAccessToken(request) const job = await submitJob(upload, accessToken!); const job_url = `/jobs/${job.id}`; return redirect(job_url); diff --git a/app/routes/jobs/$id.edit.tsx b/app/routes/jobs/$id.edit.tsx index c544c9f6..14ee3ff1 100644 --- a/app/routes/jobs/$id.edit.tsx +++ b/app/routes/jobs/$id.edit.tsx @@ -9,16 +9,15 @@ import { action } from "~/routes/builder"; import { useLoaderData } from "@remix-run/react"; import { ClientOnly } from "~/components/ClientOnly"; import { getJobById } from "~/models/job.server"; +import { getUser } from "~/auth.server"; +import { getAccessToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { const jobId = params.id || ""; - const session = await getSession(request); - const level = await getLevel(session.data.roles); - if (level === "") { - throw new Error("Unauthenticated"); - } + const user = await getUser(request); + const level = await getLevel(user ? user.roles.map(r => r.name) : undefined); const catalog = await getCatalog(level); - const accessToken = session.data.bartenderToken; + const accessToken = await getAccessToken(request) // Check that user can see job, otherwise throw 404 await getJobById(parseInt(jobId), accessToken!); // return same shape as loader in ~/routes/builder.tsx diff --git a/app/routes/jobs/$id.tsx b/app/routes/jobs/$id.tsx index 74c46ab2..66941cef 100644 --- a/app/routes/jobs/$id.tsx +++ b/app/routes/jobs/$id.tsx @@ -1,6 +1,6 @@ import { json, type LoaderArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; import { listOutputFiles, getJobById, diff --git a/app/routes/jobs/$id/[input.zip].tsx b/app/routes/jobs/$id/[input.zip].tsx index 9f7192ec..bcfe84b4 100644 --- a/app/routes/jobs/$id/[input.zip].tsx +++ b/app/routes/jobs/$id/[input.zip].tsx @@ -1,6 +1,6 @@ import { type LoaderArgs } from "@remix-run/node"; import { getInputArchive } from "~/models/job.server"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { const job_id = params.id || ""; diff --git a/app/routes/jobs/$id/[output.zip].tsx b/app/routes/jobs/$id/[output.zip].tsx index 2d9e06f0..da39ea79 100644 --- a/app/routes/jobs/$id/[output.zip].tsx +++ b/app/routes/jobs/$id/[output.zip].tsx @@ -1,6 +1,6 @@ import { type LoaderArgs } from "@remix-run/node"; import { getOutputArchive } from "~/models/job.server"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { const job_id = params.id || ""; diff --git a/app/routes/jobs/$id/archive/$.ts b/app/routes/jobs/$id/archive/$.ts index d2b2fd62..acf7ea66 100644 --- a/app/routes/jobs/$id/archive/$.ts +++ b/app/routes/jobs/$id/archive/$.ts @@ -1,5 +1,5 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; import { getSubDirectoryAsArchive } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { diff --git a/app/routes/jobs/$id/files/$.ts b/app/routes/jobs/$id/files/$.ts index 3e254edc..af1375fa 100644 --- a/app/routes/jobs/$id/files/$.ts +++ b/app/routes/jobs/$id/files/$.ts @@ -1,5 +1,5 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; import { getJobfile } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { diff --git a/app/routes/jobs/$id/stderr.tsx b/app/routes/jobs/$id/stderr.tsx index 8affe4e0..529842ea 100644 --- a/app/routes/jobs/$id/stderr.tsx +++ b/app/routes/jobs/$id/stderr.tsx @@ -1,5 +1,5 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; import { getJobStderr } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { diff --git a/app/routes/jobs/$id/stdout.tsx b/app/routes/jobs/$id/stdout.tsx index 01b8d7cf..106aee1a 100644 --- a/app/routes/jobs/$id/stdout.tsx +++ b/app/routes/jobs/$id/stdout.tsx @@ -1,5 +1,5 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; import { getJobStdout } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { diff --git a/app/routes/jobs/$id/zip.tsx b/app/routes/jobs/$id/zip.tsx index 49ec5664..adc126ad 100644 --- a/app/routes/jobs/$id/zip.tsx +++ b/app/routes/jobs/$id/zip.tsx @@ -1,6 +1,6 @@ import { type LoaderArgs } from "@remix-run/node"; import { getArchive } from "~/models/job.server"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { const job_id = params.id || ""; diff --git a/app/routes/jobs/index.tsx b/app/routes/jobs/index.tsx index 392b59d4..9aaff0e8 100644 --- a/app/routes/jobs/index.tsx +++ b/app/routes/jobs/index.tsx @@ -1,6 +1,6 @@ import { json, type LoaderArgs } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; -import { getAccessToken } from "~/token.server"; +import { getAccessToken } from "~/bartender_token.server"; import { getJobs } from "~/models/job.server"; export const loader = async ({ request }: LoaderArgs) => { diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 9dbefa30..61a868ec 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,16 +1,21 @@ -import type { LoaderArgs, ActionArgs } from "@remix-run/node"; -import { Form, Link } from "@remix-run/react"; -import { authenticator } from "~/auth.server"; +import { + type LoaderArgs, + type ActionArgs, + redirect, + json, +} from "@remix-run/node"; +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { availableSocialLogins } from "~/auth"; +import { authenticator, getUser } from "~/auth.server"; -// Finally, we can export a loader function where we check if the user is -// authenticated with `authenticator.isAuthenticated` and redirect to the -// dashboard if it is or return null if it's not export async function loader({ request }: LoaderArgs) { - // If the user is already authenticated redirect to /dashboard directly - return await authenticator.isAuthenticated(request, { - successRedirect: "/", - }); -}; + const user = await getUser(request); + if (user) { + return redirect("/"); + } + const socials = availableSocialLogins(); + return json({ socials }); +} export async function action({ request }: ActionArgs) { return await authenticator.authenticate("user-pass", request, { @@ -20,6 +25,7 @@ export async function action({ request }: ActionArgs) { } export default function LoginPage() { + const { socials } = useLoaderData(); // Shared style between login and register. Extract if we use it more often? const centeredColumn = "flex flex-col items-center gap-4"; const formStyle = @@ -68,38 +74,41 @@ export default function LoginPage() { {/* Social buttons */}

    Other login methods

    - {/* TODO only show buttons for enabled providers - by checking http://localhost:8000/api/openapi.json */}
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    + {socials.includes("github") && ( +
    + +
    + )} + {socials.includes("orcid") && ( +
    + +
    + )} + {socials.includes("egi") && ( +
    + +
    + )}
    ); diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index 4e7499e1..cdbd114c 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -1,34 +1,32 @@ import { json, type LoaderArgs } from "@remix-run/node"; -import { Link, useLoaderData } from "@remix-run/react"; -import { getTokenPayload } from "~/token.server"; -import { checkAuthenticated, getLevel, getProfile } from "~/models/user.server"; -import { getSession } from "~/session.server"; +import { Link } from "@remix-run/react"; import { authenticator } from "~/auth.server"; +import { useUser } from "~/auth"; export const loader = async ({ request }: LoaderArgs) => { - let user = await authenticator.isAuthenticated(request, { + await authenticator.isAuthenticated(request, { failureRedirect: "/login", }); - return json({ user }); + return json({}); }; export default function JobPage() { - const { user } = useLoaderData(); + const user = useUser(); return (

    Email: {user.email}

    - {/*

    Expertise level: {level}

    - OAuth accounts: -

      - {profile.oauthAccounts.map((a) => ( -
    • - {a.oauthName}: {a.accountEmail} -
    • - ))} -
    + Roles:  + {user.roles.length ? ( +
      + {user.roles.map((role) => ( +
    • {role.name}
    • + ))} +
    + ) : ( + None + )}

    -

    Login expires: {new Date(expireDate).toISOString()}

    */} Logout diff --git a/app/routes/register.tsx b/app/routes/register.tsx index 9143bebf..dcf1278b 100644 --- a/app/routes/register.tsx +++ b/app/routes/register.tsx @@ -6,8 +6,8 @@ import { } from "@remix-run/node"; import { Form, Link } from "@remix-run/react"; import { authenticator } from "~/auth.server"; -import { localLogin, register } from "~/models/user.server"; -import { commitSession, getSession, setSession } from "~/session.server"; +import { register } from "~/models/user.server"; +import { commitSession, getSession } from "~/session.server"; export async function loader({ request }: LoaderArgs) { // TODO check already logged in @@ -32,7 +32,7 @@ export async function action({ request }: ActionArgs) { const user = await register(username, password); // Make just registered user logged in const session = await getSession(request.headers.get("cookie")); - session.set(authenticator.sessionKey, user); + session.set(authenticator.sessionKey, user.id); let headers = new Headers({ "Set-Cookie": await commitSession(session) }); return redirect("/", { headers }); } diff --git a/app/routes/upload.tsx b/app/routes/upload.tsx index 9b10eddd..ef88de9d 100644 --- a/app/routes/upload.tsx +++ b/app/routes/upload.tsx @@ -8,21 +8,20 @@ import { Form } from "@remix-run/react"; import { submitJob } from "~/models/applicaton.server"; import { - checkAuthenticated, getLevel, isSubmitAllowed, } from "~/models/user.server"; -import { getSession } from "~/session.server"; import { WORKFLOW_CONFIG_FILENAME } from "~/models/constants"; -import { authenticator } from "~/auth.server"; +import { getUser } from "~/auth.server"; +import { getAccessToken } from "~/bartender_token.server"; export const loader = async ({ request }: LoaderArgs) => { - let user = await authenticator.isAuthenticated(request); + const user = await getUser(request); if (!user) { return redirect("/login"); } // TODO get roles of current user - const level = await getLevel(session.data.roles); + const level = await getLevel(user ? user.roles.map(r => r.name) : undefined); if (!isSubmitAllowed(level)) { throw new Error("Forbidden"); } @@ -36,14 +35,8 @@ export const action = async ({ request }: ActionArgs) => { if (typeof upload === "string" || upload === null) { throw new Error("Bad upload"); } - const session = await getSession(request); - // TODO fetch token for user - const accessToken = session.data.bartenderToken; - checkAuthenticated(accessToken); - const level = await getLevel(session.data.roles); - if (!isSubmitAllowed(level)) { - throw new Error("Forbidden"); - } + + const accessToken = await getAccessToken(request) const job = await submitJob(upload, accessToken!); const job_url = `/jobs/${job.id}`; return redirect(job_url); diff --git a/app/session.server.ts b/app/session.server.ts index 07635720..63903afa 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -1,11 +1,8 @@ -import { - createCookieSessionStorage, -} from "@remix-run/node"; +import { createCookieSessionStorage } from "@remix-run/node"; const COOKIE_NAME = "haddock3_webapp_session"; export const sessionStorage = createCookieSessionStorage({ - // TODO add secret + domain + path cookie: { name: COOKIE_NAME, httpOnly: true, diff --git a/app/session.ts b/app/session.ts deleted file mode 100644 index c24d117c..00000000 --- a/app/session.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useRouteLoaderData } from "@remix-run/react"; - -export function useIsAuthenticated() { - const { isAuthenticated } = useRouteLoaderData("root") as { - isAuthenticated?: boolean; - }; - return isAuthenticated; -} - -export function useIsSuperUser() { - const { isSuperUser } = useRouteLoaderData("root") as { - isSuperUser?: boolean; - }; - return isSuperUser; -} diff --git a/app/token.server.ts b/app/token.server.ts deleted file mode 100644 index 275e5743..00000000 --- a/app/token.server.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Functions dealing with access token of bartender web service. - */ -import { decodeJwt } from "jose"; -import { getSession } from "./session.server"; - -export async function getAccessToken(request: Request) { - const session = await getSession(request); - return session.data.bartenderToken; -} - -export function isExpired(accessToken: string | undefined) { - const payload = getTokenPayload(accessToken); - const now = Date.now() / 1000; - return payload.exp !== undefined && payload.exp <= now; -} - -export function getTokenPayload(accessToken: string | undefined) { - if (accessToken === undefined) { - return {}; - } - // TODO verify token by using HS256 algorithm, - // see https://github.com/i-VRESSE/bartender/issues/58 - return decodeJwt(accessToken); -} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4fe0483f..9929cb57 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,20 +18,9 @@ model User { // User can have no password if they use OAuth passwordHash String? roles Role[] - oauthAccounts OAUthAccount[] } model Role { name String @id users User[] } - -model OAUthAccount { - id String @id @default(uuid()) - provider String - accessToken String - refreshToken String - expiresAt DateTime? - user User @relation(fields: [userId], references: [id]) - userId String -} \ No newline at end of file diff --git a/prisma/seed.mts b/prisma/seed.mts index e629f271..e5252ffe 100644 --- a/prisma/seed.mts +++ b/prisma/seed.mts @@ -2,15 +2,14 @@ import { PrismaClient } from "@prisma/client"; const db = new PrismaClient(); async function seed() { - await Promise.all( // TODO use createMany when postgresql is used getRoles().map(async (role) => { - return db.role.create({ - data: { - name: role, - }, - }); + return db.role.create({ + data: { + name: role, + }, + }); }) ); } @@ -18,5 +17,5 @@ async function seed() { seed(); function getRoles() { - return ["guru", "expert", "easy"]; + return ["admin", "guru", "expert", "easy"]; } From 1f49596074f02ab4a3932ba4c30b3a1c9f7238a8 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Tue, 25 Jul 2023 21:46:15 +0200 Subject: [PATCH 07/45] Fix lint --- app/bartender_token.server.ts | 30 ++++++++++++++++-------------- app/routes/builder.tsx | 13 ++++++------- app/routes/jobs/$id.edit.tsx | 7 ++++--- app/routes/upload.tsx | 11 +++++------ 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/app/bartender_token.server.ts b/app/bartender_token.server.ts index 001aa38a..560a7aae 100644 --- a/app/bartender_token.server.ts +++ b/app/bartender_token.server.ts @@ -7,58 +7,60 @@ import { getUser } from "./auth.server"; import { json } from "@remix-run/node"; import { getLevel, isSubmitAllowed } from "./models/user.server"; +const alg = "RS256"; class TokenGenerator { private privateKeyFilename: string; private issuer: string; private privateKey: KeyLike | undefined = undefined; - constructor(privateKeyFilename: string, issuer: string = 'bartender') { + constructor(privateKeyFilename: string, issuer: string = "bartender") { this.privateKeyFilename = privateKeyFilename; this.issuer = issuer; } async init() { if (this.privateKey) { - return + return; } - const alg = 'RS256' - const privateKeyBody = await readFile(this.privateKeyFilename, 'utf8'); + const privateKeyBody = await readFile(this.privateKeyFilename, "utf8"); const privateKey = await importPKCS8(privateKeyBody, alg); this.privateKey = privateKey; } async generate(sub: string, email: string, roles: string[]) { if (!this.privateKey) { - throw new Error('private key not initialized') + throw new Error("private key not initialized"); } const jwt = await new SignJWT({ email: email, roles: roles, - }).setIssuer(this.issuer) - .setExpirationTime('30d') + }) + .setIssuer(this.issuer) + .setExpirationTime("30d") .setIssuedAt() .setSubject(sub) - .setProtectedHeader({ alg: 'RS256' }) + .setProtectedHeader({ alg }) .sign(this.privateKey); - return jwt + return jwt; } - } -const privateKeyFilename = process.env.BARTENDER_PRIVATE_KEY || 'private_key.pem'; +const privateKeyFilename = + process.env.BARTENDER_PRIVATE_KEY || "private_key.pem"; // TODO only use singleton if reading private key is slow const generator = new TokenGenerator(privateKeyFilename); -export async function getAccessToken(request:Request) { +export async function getAccessToken(request: Request) { const user = await getUser(request); if (!user) { throw json({ error: "Unauthorized" }, { status: 401 }); } - const roles = user.roles.map(r => r.name) + const roles = user.roles.map((r) => r.name); const level = await getLevel(roles); if (!isSubmitAllowed(level)) { throw json({ error: "Forbidden" }, { status: 403 }); } + // TODO fetch non-expired token from database await generator.init(); return await generator.generate(user.id, user.email, roles); -} \ No newline at end of file +} diff --git a/app/routes/builder.tsx b/app/routes/builder.tsx index ec2f426a..8ea044f4 100644 --- a/app/routes/builder.tsx +++ b/app/routes/builder.tsx @@ -1,13 +1,10 @@ -import { type ActionArgs, type LoaderArgs, redirect, json } from "@remix-run/node"; +import { type ActionArgs, type LoaderArgs, redirect } from "@remix-run/node"; import { getCatalog } from "~/catalogs/index.server"; import { Haddock3WorkflowBuilder } from "~/components/Haddock3/Form.client"; import { haddock3Styles } from "~/components/Haddock3/styles"; import { submitJob } from "~/models/applicaton.server"; -import { - getLevel, - isSubmitAllowed, -} from "~/models/user.server"; +import { getLevel, isSubmitAllowed } from "~/models/user.server"; import { type ICatalog } from "@i-vresse/wb-core/dist/types"; import { ClientOnly } from "~/components/ClientOnly"; import { getUser } from "~/auth.server"; @@ -21,7 +18,9 @@ export const loader = async ({ archive: string | undefined; }> => { const user = await getUser(request); - const level = await getLevel(user ? user.roles.map(r => r.name) : undefined); + const level = await getLevel( + user ? user.roles.map((r) => r.name) : undefined + ); // When user does not have a level he/she // can still use builder with easy level // but cannot submit only download @@ -37,7 +36,7 @@ export const action = async ({ request }: ActionArgs) => { throw new Error("Bad upload"); } - const accessToken = await getAccessToken(request) + const accessToken = await getAccessToken(request); const job = await submitJob(upload, accessToken!); const job_url = `/jobs/${job.id}`; return redirect(job_url); diff --git a/app/routes/jobs/$id.edit.tsx b/app/routes/jobs/$id.edit.tsx index 14ee3ff1..e894d7b0 100644 --- a/app/routes/jobs/$id.edit.tsx +++ b/app/routes/jobs/$id.edit.tsx @@ -4,7 +4,6 @@ import { getCatalog } from "~/catalogs/index.server"; import { Haddock3WorkflowBuilder } from "~/components/Haddock3/Form.client"; import { haddock3Styles } from "~/components/Haddock3/styles"; import { getLevel, isSubmitAllowed } from "~/models/user.server"; -import { getSession } from "~/session.server"; import { action } from "~/routes/builder"; import { useLoaderData } from "@remix-run/react"; import { ClientOnly } from "~/components/ClientOnly"; @@ -15,9 +14,11 @@ import { getAccessToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { const jobId = params.id || ""; const user = await getUser(request); - const level = await getLevel(user ? user.roles.map(r => r.name) : undefined); + const level = await getLevel( + user ? user.roles.map((r) => r.name) : undefined + ); const catalog = await getCatalog(level); - const accessToken = await getAccessToken(request) + const accessToken = await getAccessToken(request); // Check that user can see job, otherwise throw 404 await getJobById(parseInt(jobId), accessToken!); // return same shape as loader in ~/routes/builder.tsx diff --git a/app/routes/upload.tsx b/app/routes/upload.tsx index ef88de9d..7d0eef62 100644 --- a/app/routes/upload.tsx +++ b/app/routes/upload.tsx @@ -7,10 +7,7 @@ import { import { Form } from "@remix-run/react"; import { submitJob } from "~/models/applicaton.server"; -import { - getLevel, - isSubmitAllowed, -} from "~/models/user.server"; +import { getLevel, isSubmitAllowed } from "~/models/user.server"; import { WORKFLOW_CONFIG_FILENAME } from "~/models/constants"; import { getUser } from "~/auth.server"; import { getAccessToken } from "~/bartender_token.server"; @@ -21,7 +18,9 @@ export const loader = async ({ request }: LoaderArgs) => { return redirect("/login"); } // TODO get roles of current user - const level = await getLevel(user ? user.roles.map(r => r.name) : undefined); + const level = await getLevel( + user ? user.roles.map((r) => r.name) : undefined + ); if (!isSubmitAllowed(level)) { throw new Error("Forbidden"); } @@ -36,7 +35,7 @@ export const action = async ({ request }: ActionArgs) => { throw new Error("Bad upload"); } - const accessToken = await getAccessToken(request) + const accessToken = await getAccessToken(request); const job = await submitJob(upload, accessToken!); const job_url = `/jobs/${job.id}`; return redirect(job_url); From ddb9d99d92ceef908dced878745b6eb996a3838e Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 28 Jul 2023 08:20:13 +0200 Subject: [PATCH 08/45] Make user more haddock3 specific --- app/models/user.server.ts | 105 +++++++++++++++++++++++++------------ app/root.tsx | 4 +- app/routes/admin/users.tsx | 14 ++--- prisma/schema.prisma | 22 ++++---- prisma/seed.mts | 10 ++-- 5 files changed, 100 insertions(+), 55 deletions(-) diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 7dcafd8e..35cdad2f 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -4,28 +4,36 @@ import { compare, hash } from "bcryptjs"; export interface User { readonly id: string; readonly email: string; - readonly roles: { + readonly expertiseLevels: { readonly name: string; }[]; + readonly isAdmin: boolean; + readonly preferredExpertiseLevel: string | null; + readonly bartenderToken: string | null; + readonly bartenderTokenExpiresAt: Date | null; } const userSelect = { id: true, email: true, - roles: { + expertiseLevels: { select: { name: true, }, }, + isAdmin: true, + preferredExpertiseLevel: true, + bartenderToken: true, + bartenderTokenExpiresAt: true, } as const; export async function register(email: string, password: string) { - const roles = await firstUserShouldBeAdmin(); + const isAdmin = await firstUserShouldBeAdmin(); const passwordHash = await hash(password, 10); const user = await db.user.create({ data: { email, - roles, + isAdmin, passwordHash, }, select: userSelect, @@ -35,15 +43,7 @@ export async function register(email: string, password: string) { async function firstUserShouldBeAdmin() { const userCount = await db.user.count(); - const roles = - userCount === 0 - ? { - connect: { - name: "admin", - }, - } - : undefined; - return roles; + return userCount === 0 } export async function localLogin(email: string, password: string) { @@ -69,14 +69,14 @@ export async function localLogin(email: string, password: string) { } export async function oauthregister(email: string) { - const roles = await firstUserShouldBeAdmin(); + const isAdmin = await firstUserShouldBeAdmin(); const user = await db.user.upsert({ where: { - email: email, + email, }, create: { - email: email, - roles, + email, + isAdmin, }, update: {}, select: { @@ -112,15 +112,11 @@ export async function getUserByEmail(email: string) { return user; } -export async function verifyIsAdmin(userId: string) { +export async function verifyIsAdmin(id: string) { const result = await db.user.findUnique({ where: { - id: userId, - roles: { - some: { - name: "admin", - }, - }, + id, + isAdmin: true, }, }); return !!result; @@ -162,8 +158,8 @@ export async function listUsers(limit = 100, offset = 0) { return users; } -export async function listRoles() { - const roles = await db.role.findMany({ +export async function listExpertiseLevels() { + const levels = await db.expertiseLevel.findMany({ select: { name: true, }, @@ -171,18 +167,18 @@ export async function listRoles() { name: "asc", }, }); - return roles.map((role) => role.name); + return levels.map((level) => level.name); } -export async function assignRole(userId: string, roleId: string) { +export async function assignExpertiseLevel(userId: string, level: string) { await db.user.update({ where: { id: userId, }, data: { - roles: { + expertiseLevels: { connect: { - name: roleId, + name: level, }, }, }, @@ -192,15 +188,15 @@ export async function assignRole(userId: string, roleId: string) { }); } -export async function unassignRole(userId: string, roleId: string) { +export async function unassignExpertiseLevel(userId: string, level: string) { await db.user.update({ where: { id: userId, }, data: { - roles: { + expertiseLevels: { disconnect: { - name: roleId, + name: level, }, }, }, @@ -209,3 +205,46 @@ export async function unassignRole(userId: string, roleId: string) { }, }); } + +export async function setPreferredExpertiseLevel(userId: string, level: string) { + await db.user.update({ + where: { + id: userId, + }, + data: { + preferredExpertiseLevel: level, + }, + select: { + id: true, + }, + }); +} + +export async function setIsAdmin(userId: string, isAdmin: boolean) { + await db.user.update({ + where: { + id: userId, + }, + data: { + isAdmin, + }, + select: { + id: true, + }, + }); +} + +export async function setBartenderToken(userId: string, token: string, expireAt: Date) { + await db.user.update({ + where: { + id: userId, + }, + data: { + bartenderToken: token, + bartenderTokenExpiresAt: expireAt, + }, + select: { + id: true, + }, + }); +} \ No newline at end of file diff --git a/app/root.tsx b/app/root.tsx index 4fe84f82..fdd825de 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -32,7 +32,9 @@ export async function loader({ request }: LoaderArgs) { return json({ user: null }); } const user = await getUserById(userId); - return json({ user }); + // Client should not have access to the bartender token + const { bartenderToken, ...tokenLessUser } = user; + return json({ user: tokenLessUser }); } export default function App() { diff --git a/app/routes/admin/users.tsx b/app/routes/admin/users.tsx index 1d45bf11..de99a402 100644 --- a/app/routes/admin/users.tsx +++ b/app/routes/admin/users.tsx @@ -3,17 +3,17 @@ import { json } from "@remix-run/node"; import { useFetcher, useLoaderData } from "@remix-run/react"; import { UserTableRow } from "~/components/admin/UserTableRow"; import { - assignRole, - listRoles, + assignExpertiseLevel, + listExpertiseLevels, listUsers, - unassignRole, + unassignExpertiseLevel, } from "~/models/user.server"; import { mustBeAdmin } from "~/auth.server"; export async function loader({ request }: LoaderArgs) { await mustBeAdmin(request); const users = await listUsers(); - const roles = await listRoles(); + const roles = await listExpertiseLevels(); return json({ users, roles, @@ -27,14 +27,14 @@ export async function action({ request }: ActionArgs) { if (userId === null || typeof userId !== "string") { throw json({ error: "Unknown user" }, { status: 400 }); } - const roles = await listRoles(); + const roles = await listExpertiseLevels(); for (const role of roles) { const roleState = formData.get(role); if (roleState !== null) { if (roleState === "true") { - await assignRole(userId, role); + await assignExpertiseLevel(userId, role); } else { - await unassignRole(userId, role); + await unassignExpertiseLevel(userId, role); } } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9929cb57..9186543e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,16 +11,20 @@ datasource db { } model User { - id String @id @default(uuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String @unique + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique // User can have no password if they use OAuth - passwordHash String? - roles Role[] + passwordHash String? + isAdmin Boolean @default(false) + bartenderToken String? + bartenderTokenExpiresAt DateTime? + expertiseLevels ExpertiseLevel[] + preferredExpertiseLevel String? } -model Role { - name String @id - users User[] +model ExpertiseLevel { + name String @id + users User[] } diff --git a/prisma/seed.mts b/prisma/seed.mts index e5252ffe..b9fe11a6 100644 --- a/prisma/seed.mts +++ b/prisma/seed.mts @@ -4,10 +4,10 @@ const db = new PrismaClient(); async function seed() { await Promise.all( // TODO use createMany when postgresql is used - getRoles().map(async (role) => { - return db.role.create({ + getExpertiseLevels().map(async (name) => { + return db.expertiseLevel.create({ data: { - name: role, + name, }, }); }) @@ -16,6 +16,6 @@ async function seed() { seed(); -function getRoles() { - return ["admin", "guru", "expert", "easy"]; +function getExpertiseLevels() { + return ["guru", "expert", "easy"]; } From b0b471e76dcd87c540dc5680bd8524acdba97a5a Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Fri, 28 Jul 2023 12:42:08 +0200 Subject: [PATCH 09/45] Make login button easier to see --- app/components/Navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 72944605..d38595e1 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -37,7 +37,7 @@ export const Navbar = () => { const loggedIn = useIsLoggedIn(); return ( -
    +
    Haddock3 From c836fba7abbfcb2baba440e2bd2a473fb3526214 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Fri, 28 Jul 2023 13:20:48 +0200 Subject: [PATCH 10/45] Regenerate client --- app/bartender-client/.openapi-generator/FILES | 15 +- app/bartender-client/apis/ApplicationApi.ts | 12 +- app/bartender-client/apis/AuthApi.ts | 1300 ----------------- app/bartender-client/apis/DefaultApi.ts | 4 +- app/bartender-client/apis/JobApi.ts | 108 +- app/bartender-client/apis/RolesApi.ts | 254 ---- app/bartender-client/apis/UserApi.ts | 71 + app/bartender-client/apis/UsersApi.ts | 502 ------- app/bartender-client/apis/index.ts | 4 +- app/bartender-client/models/BearerResponse.ts | 75 - app/bartender-client/models/Detail.ts | 45 - app/bartender-client/models/ErrorModel.ts | 69 - .../models/OAuth2AuthorizeResponse.ts | 70 - .../models/OAuthAccountName.ts | 84 -- app/bartender-client/models/User.ts | 83 ++ app/bartender-client/models/UserAsListItem.ts | 131 -- app/bartender-client/models/UserCreate.ts | 101 -- .../models/UserProfileInputDTO.ts | 97 -- app/bartender-client/models/UserRead.ts | 109 -- app/bartender-client/models/UserUpdate.ts | 99 -- app/bartender-client/models/index.ts | 11 +- package-lock.json | 78 +- package.json | 2 +- 23 files changed, 216 insertions(+), 3108 deletions(-) delete mode 100644 app/bartender-client/apis/AuthApi.ts delete mode 100644 app/bartender-client/apis/RolesApi.ts create mode 100644 app/bartender-client/apis/UserApi.ts delete mode 100644 app/bartender-client/apis/UsersApi.ts delete mode 100644 app/bartender-client/models/BearerResponse.ts delete mode 100644 app/bartender-client/models/Detail.ts delete mode 100644 app/bartender-client/models/ErrorModel.ts delete mode 100644 app/bartender-client/models/OAuth2AuthorizeResponse.ts delete mode 100644 app/bartender-client/models/OAuthAccountName.ts create mode 100644 app/bartender-client/models/User.ts delete mode 100644 app/bartender-client/models/UserAsListItem.ts delete mode 100644 app/bartender-client/models/UserCreate.ts delete mode 100644 app/bartender-client/models/UserProfileInputDTO.ts delete mode 100644 app/bartender-client/models/UserRead.ts delete mode 100644 app/bartender-client/models/UserUpdate.ts diff --git a/app/bartender-client/.openapi-generator/FILES b/app/bartender-client/.openapi-generator/FILES index bcd56ff4..ff495456 100644 --- a/app/bartender-client/.openapi-generator/FILES +++ b/app/bartender-client/.openapi-generator/FILES @@ -1,26 +1,15 @@ apis/ApplicationApi.ts -apis/AuthApi.ts apis/DefaultApi.ts apis/JobApi.ts -apis/RolesApi.ts -apis/UsersApi.ts +apis/UserApi.ts apis/index.ts index.ts models/ApplicatonConfiguration.ts -models/BearerResponse.ts -models/Detail.ts models/DirectoryItem.ts -models/ErrorModel.ts models/HTTPValidationError.ts models/JobModelDTO.ts models/LocationInner.ts -models/OAuth2AuthorizeResponse.ts -models/OAuthAccountName.ts -models/UserAsListItem.ts -models/UserCreate.ts -models/UserProfileInputDTO.ts -models/UserRead.ts -models/UserUpdate.ts +models/User.ts models/ValidationError.ts models/index.ts runtime.ts diff --git a/app/bartender-client/apis/ApplicationApi.ts b/app/bartender-client/apis/ApplicationApi.ts index fe18d65e..d8b5be0e 100644 --- a/app/bartender-client/apis/ApplicationApi.ts +++ b/app/bartender-client/apis/ApplicationApi.ts @@ -156,14 +156,6 @@ export class ApplicationApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -172,6 +164,10 @@ export class ApplicationApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const consumes: runtime.Consume[] = [ { contentType: "multipart/form-data" }, ]; diff --git a/app/bartender-client/apis/AuthApi.ts b/app/bartender-client/apis/AuthApi.ts deleted file mode 100644 index b0ab9b40..00000000 --- a/app/bartender-client/apis/AuthApi.ts +++ /dev/null @@ -1,1300 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import * as runtime from "../runtime"; -import type { - BearerResponse, - ErrorModel, - HTTPValidationError, - OAuth2AuthorizeResponse, - UserCreate, - UserRead, -} from "../models"; -import { - BearerResponseFromJSON, - BearerResponseToJSON, - ErrorModelFromJSON, - ErrorModelToJSON, - HTTPValidationErrorFromJSON, - HTTPValidationErrorToJSON, - OAuth2AuthorizeResponseFromJSON, - OAuth2AuthorizeResponseToJSON, - UserCreateFromJSON, - UserCreateToJSON, - UserReadFromJSON, - UserReadToJSON, -} from "../models"; - -export interface AuthLocalLoginRequest { - username: string; - password: string; - grantType?: string; - scope?: string; - clientId?: string; - clientSecret?: string; -} - -export interface OauthAssociateEGICheckInAuthorizeRequest { - scopes?: Array; -} - -export interface OauthAssociateEGICheckInCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface OauthAssociateGithubAuthorizeRequest { - scopes?: Array; -} - -export interface OauthAssociateGithubCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface OauthAssociateOrcidOrgAuthorizeRequest { - scopes?: Array; -} - -export interface OauthAssociateOrcidOrgCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface OauthAssociateSandboxOrcidOrgAuthorizeRequest { - scopes?: Array; -} - -export interface OauthAssociateSandboxOrcidOrgCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface OauthEGICheckInRemoteAuthorizeRequest { - scopes?: Array; -} - -export interface OauthEGICheckInRemoteCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface OauthGithubRemoteAuthorizeRequest { - scopes?: Array; -} - -export interface OauthGithubRemoteCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface OauthOrcidOrgRemoteAuthorizeRequest { - scopes?: Array; -} - -export interface OauthOrcidOrgRemoteCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface OauthSandboxOrcidOrgRemoteAuthorizeRequest { - scopes?: Array; -} - -export interface OauthSandboxOrcidOrgRemoteCallbackRequest { - code?: string; - codeVerifier?: string; - state?: string; - error?: string; -} - -export interface RegisterRegisterRequest { - userCreate: UserCreate; -} - -/** - * - */ -export class AuthApi extends runtime.BaseAPI { - /** - * Auth:Local.Login - */ - async authLocalLoginRaw( - requestParameters: AuthLocalLoginRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - if ( - requestParameters.username === null || - requestParameters.username === undefined - ) { - throw new runtime.RequiredError( - "username", - "Required parameter requestParameters.username was null or undefined when calling authLocalLogin." - ); - } - - if ( - requestParameters.password === null || - requestParameters.password === undefined - ) { - throw new runtime.RequiredError( - "password", - "Required parameter requestParameters.password was null or undefined when calling authLocalLogin." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - const consumes: runtime.Consume[] = [ - { contentType: "application/x-www-form-urlencoded" }, - ]; - // @ts-ignore: canConsumeForm may be unused - const canConsumeForm = runtime.canConsumeForm(consumes); - - let formParams: { append(param: string, value: any): any }; - let useForm = false; - if (useForm) { - formParams = new FormData(); - } else { - formParams = new URLSearchParams(); - } - - if (requestParameters.grantType !== undefined) { - formParams.append("grant_type", requestParameters.grantType as any); - } - - if (requestParameters.username !== undefined) { - formParams.append("username", requestParameters.username as any); - } - - if (requestParameters.password !== undefined) { - formParams.append("password", requestParameters.password as any); - } - - if (requestParameters.scope !== undefined) { - formParams.append("scope", requestParameters.scope as any); - } - - if (requestParameters.clientId !== undefined) { - formParams.append("client_id", requestParameters.clientId as any); - } - - if (requestParameters.clientSecret !== undefined) { - formParams.append("client_secret", requestParameters.clientSecret as any); - } - - const response = await this.request( - { - path: `/auth/jwt/login`, - method: "POST", - headers: headerParameters, - query: queryParameters, - body: formParams, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - BearerResponseFromJSON(jsonValue) - ); - } - - /** - * Auth:Local.Login - */ - async authLocalLogin( - requestParameters: AuthLocalLoginRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.authLocalLoginRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Auth:Local.Logout - */ - async authLocalLogoutRaw( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/jwt/logout`, - method: "POST", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - if (this.isJsonMime(response.headers.get("content-type"))) { - return new runtime.JSONApiResponse(response); - } else { - return new runtime.TextApiResponse(response) as any; - } - } - - /** - * Auth:Local.Logout - */ - async authLocalLogout( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.authLocalLogoutRaw(initOverrides); - return await response.value(); - } - - /** - * Oauth-Associate:Egi Check-In.Authorize - */ - async oauthAssociateEGICheckInAuthorizeRaw( - requestParameters: OauthAssociateEGICheckInAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/egi/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth-Associate:Egi Check-In.Authorize - */ - async oauthAssociateEGICheckInAuthorize( - requestParameters: OauthAssociateEGICheckInAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateEGICheckInAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Egi Check-In.Callback - */ - async oauthAssociateEGICheckInCallbackRaw( - requestParameters: OauthAssociateEGICheckInCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/egi/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Egi Check-In.Callback - */ - async oauthAssociateEGICheckInCallback( - requestParameters: OauthAssociateEGICheckInCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateEGICheckInCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Oauth-Associate:Github.Authorize - */ - async oauthAssociateGithubAuthorizeRaw( - requestParameters: OauthAssociateGithubAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/github/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth-Associate:Github.Authorize - */ - async oauthAssociateGithubAuthorize( - requestParameters: OauthAssociateGithubAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateGithubAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Github.Callback - */ - async oauthAssociateGithubCallbackRaw( - requestParameters: OauthAssociateGithubCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/github/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Github.Callback - */ - async oauthAssociateGithubCallback( - requestParameters: OauthAssociateGithubCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateGithubCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Oauth-Associate:Orcid.Org.Authorize - */ - async oauthAssociateOrcidOrgAuthorizeRaw( - requestParameters: OauthAssociateOrcidOrgAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/orcid/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth-Associate:Orcid.Org.Authorize - */ - async oauthAssociateOrcidOrgAuthorize( - requestParameters: OauthAssociateOrcidOrgAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateOrcidOrgAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Orcid.Org.Callback - */ - async oauthAssociateOrcidOrgCallbackRaw( - requestParameters: OauthAssociateOrcidOrgCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/orcid/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Orcid.Org.Callback - */ - async oauthAssociateOrcidOrgCallback( - requestParameters: OauthAssociateOrcidOrgCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateOrcidOrgCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Oauth-Associate:Sandbox.Orcid.Org.Authorize - */ - async oauthAssociateSandboxOrcidOrgAuthorizeRaw( - requestParameters: OauthAssociateSandboxOrcidOrgAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/orcidsandbox/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth-Associate:Sandbox.Orcid.Org.Authorize - */ - async oauthAssociateSandboxOrcidOrgAuthorize( - requestParameters: OauthAssociateSandboxOrcidOrgAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateSandboxOrcidOrgAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Sandbox.Orcid.Org.Callback - */ - async oauthAssociateSandboxOrcidOrgCallbackRaw( - requestParameters: OauthAssociateSandboxOrcidOrgCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/auth/associate/orcidsandbox/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * The response varies based on the authentication backend used. - * Oauth-Associate:Sandbox.Orcid.Org.Callback - */ - async oauthAssociateSandboxOrcidOrgCallback( - requestParameters: OauthAssociateSandboxOrcidOrgCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthAssociateSandboxOrcidOrgCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Oauth:Egi Check-In.Remote.Authorize - */ - async oauthEGICheckInRemoteAuthorizeRaw( - requestParameters: OauthEGICheckInRemoteAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/egi/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth:Egi Check-In.Remote.Authorize - */ - async oauthEGICheckInRemoteAuthorize( - requestParameters: OauthEGICheckInRemoteAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthEGICheckInRemoteAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Egi Check-In.Remote.Callback - */ - async oauthEGICheckInRemoteCallbackRaw( - requestParameters: OauthEGICheckInRemoteCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/egi/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - if (this.isJsonMime(response.headers.get("content-type"))) { - return new runtime.JSONApiResponse(response); - } else { - return new runtime.TextApiResponse(response) as any; - } - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Egi Check-In.Remote.Callback - */ - async oauthEGICheckInRemoteCallback( - requestParameters: OauthEGICheckInRemoteCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthEGICheckInRemoteCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Oauth:Github.Remote.Authorize - */ - async oauthGithubRemoteAuthorizeRaw( - requestParameters: OauthGithubRemoteAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/github/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth:Github.Remote.Authorize - */ - async oauthGithubRemoteAuthorize( - requestParameters: OauthGithubRemoteAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthGithubRemoteAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Github.Remote.Callback - */ - async oauthGithubRemoteCallbackRaw( - requestParameters: OauthGithubRemoteCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/github/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - if (this.isJsonMime(response.headers.get("content-type"))) { - return new runtime.JSONApiResponse(response); - } else { - return new runtime.TextApiResponse(response) as any; - } - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Github.Remote.Callback - */ - async oauthGithubRemoteCallback( - requestParameters: OauthGithubRemoteCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthGithubRemoteCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Oauth:Orcid.Org.Remote.Authorize - */ - async oauthOrcidOrgRemoteAuthorizeRaw( - requestParameters: OauthOrcidOrgRemoteAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/orcid/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth:Orcid.Org.Remote.Authorize - */ - async oauthOrcidOrgRemoteAuthorize( - requestParameters: OauthOrcidOrgRemoteAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthOrcidOrgRemoteAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Orcid.Org.Remote.Callback - */ - async oauthOrcidOrgRemoteCallbackRaw( - requestParameters: OauthOrcidOrgRemoteCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/orcid/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - if (this.isJsonMime(response.headers.get("content-type"))) { - return new runtime.JSONApiResponse(response); - } else { - return new runtime.TextApiResponse(response) as any; - } - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Orcid.Org.Remote.Callback - */ - async oauthOrcidOrgRemoteCallback( - requestParameters: OauthOrcidOrgRemoteCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthOrcidOrgRemoteCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Oauth:Sandbox.Orcid.Org.Remote.Authorize - */ - async oauthSandboxOrcidOrgRemoteAuthorizeRaw( - requestParameters: OauthSandboxOrcidOrgRemoteAuthorizeRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.scopes) { - queryParameters["scopes"] = requestParameters.scopes; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/orcidsandbox/authorize`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - OAuth2AuthorizeResponseFromJSON(jsonValue) - ); - } - - /** - * Oauth:Sandbox.Orcid.Org.Remote.Authorize - */ - async oauthSandboxOrcidOrgRemoteAuthorize( - requestParameters: OauthSandboxOrcidOrgRemoteAuthorizeRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthSandboxOrcidOrgRemoteAuthorizeRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Sandbox.Orcid.Org.Remote.Callback - */ - async oauthSandboxOrcidOrgRemoteCallbackRaw( - requestParameters: OauthSandboxOrcidOrgRemoteCallbackRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - if (requestParameters.code !== undefined) { - queryParameters["code"] = requestParameters.code; - } - - if (requestParameters.codeVerifier !== undefined) { - queryParameters["code_verifier"] = requestParameters.codeVerifier; - } - - if (requestParameters.state !== undefined) { - queryParameters["state"] = requestParameters.state; - } - - if (requestParameters.error !== undefined) { - queryParameters["error"] = requestParameters.error; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - const response = await this.request( - { - path: `/auth/orcidsandbox/callback`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - if (this.isJsonMime(response.headers.get("content-type"))) { - return new runtime.JSONApiResponse(response); - } else { - return new runtime.TextApiResponse(response) as any; - } - } - - /** - * The response varies based on the authentication backend used. - * Oauth:Sandbox.Orcid.Org.Remote.Callback - */ - async oauthSandboxOrcidOrgRemoteCallback( - requestParameters: OauthSandboxOrcidOrgRemoteCallbackRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.oauthSandboxOrcidOrgRemoteCallbackRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Register:Register - */ - async registerRegisterRaw( - requestParameters: RegisterRegisterRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - if ( - requestParameters.userCreate === null || - requestParameters.userCreate === undefined - ) { - throw new runtime.RequiredError( - "userCreate", - "Required parameter requestParameters.userCreate was null or undefined when calling registerRegister." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - headerParameters["Content-Type"] = "application/json"; - - const response = await this.request( - { - path: `/auth/register`, - method: "POST", - headers: headerParameters, - query: queryParameters, - body: UserCreateToJSON(requestParameters.userCreate), - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * Register:Register - */ - async registerRegister( - requestParameters: RegisterRegisterRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.registerRegisterRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } -} diff --git a/app/bartender-client/apis/DefaultApi.ts b/app/bartender-client/apis/DefaultApi.ts index 5cb8df21..db29161c 100644 --- a/app/bartender-client/apis/DefaultApi.ts +++ b/app/bartender-client/apis/DefaultApi.ts @@ -19,7 +19,7 @@ import * as runtime from "../runtime"; */ export class DefaultApi extends runtime.BaseAPI { /** - * Checks the health of a project. It returns 200 if the project is healthy. + * Checks the health of a project. It returns 200 if the project is healthy. Args: session: SQLAlchemy session. * Health Check */ async healthCheckRaw( @@ -47,7 +47,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** - * Checks the health of a project. It returns 200 if the project is healthy. + * Checks the health of a project. It returns 200 if the project is healthy. Args: session: SQLAlchemy session. * Health Check */ async healthCheck( diff --git a/app/bartender-client/apis/JobApi.ts b/app/bartender-client/apis/JobApi.ts index a1dae0a6..b64ea5c5 100644 --- a/app/bartender-client/apis/JobApi.ts +++ b/app/bartender-client/apis/JobApi.ts @@ -101,14 +101,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -117,6 +109,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}`.replace( @@ -176,14 +172,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -192,6 +180,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}/directories`.replace( @@ -261,14 +253,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -277,6 +261,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}/directories/{path}` @@ -349,14 +337,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -365,6 +345,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}/archive`.replace( @@ -432,14 +416,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -448,6 +424,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}/files/{path}` @@ -510,14 +490,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -526,6 +498,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}/stderr`.replace( @@ -583,14 +559,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -599,6 +567,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}/stdout`.replace( @@ -678,14 +650,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -694,6 +658,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/{jobid}/archive/{path}` @@ -754,14 +722,6 @@ export class JobApi extends runtime.BaseAPI { const headerParameters: runtime.HTTPHeaders = {}; - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("HTTPBearer", []); @@ -770,6 +730,10 @@ export class JobApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + const response = await this.request( { path: `/api/job/`, diff --git a/app/bartender-client/apis/RolesApi.ts b/app/bartender-client/apis/RolesApi.ts deleted file mode 100644 index 433e4aab..00000000 --- a/app/bartender-client/apis/RolesApi.ts +++ /dev/null @@ -1,254 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import * as runtime from "../runtime"; -import type { HTTPValidationError } from "../models"; -import { - HTTPValidationErrorFromJSON, - HTTPValidationErrorToJSON, -} from "../models"; - -export interface AssignRoleToUserRequest { - roleId: string; - userId: string; -} - -export interface UnassignRoleFromUserRequest { - roleId: string; - userId: string; -} - -/** - * - */ -export class RolesApi extends runtime.BaseAPI { - /** - * Assign role to user. Requires super user powers. Args: role_id: Role id user_id: User id roles: Set of allowed roles super_user: Check if current user is super. user_db: User db. Raises: HTTPException: When user is not found Returns: Roles assigned to user. - * Assign Role To User - */ - async assignRoleToUserRaw( - requestParameters: AssignRoleToUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise>> { - if ( - requestParameters.roleId === null || - requestParameters.roleId === undefined - ) { - throw new runtime.RequiredError( - "roleId", - "Required parameter requestParameters.roleId was null or undefined when calling assignRoleToUser." - ); - } - - if ( - requestParameters.userId === null || - requestParameters.userId === undefined - ) { - throw new runtime.RequiredError( - "userId", - "Required parameter requestParameters.userId was null or undefined when calling assignRoleToUser." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/api/roles/{role_id}/{user_id}` - .replace( - `{${"role_id"}}`, - encodeURIComponent(String(requestParameters.roleId)) - ) - .replace( - `{${"user_id"}}`, - encodeURIComponent(String(requestParameters.userId)) - ), - method: "PUT", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response); - } - - /** - * Assign role to user. Requires super user powers. Args: role_id: Role id user_id: User id roles: Set of allowed roles super_user: Check if current user is super. user_db: User db. Raises: HTTPException: When user is not found Returns: Roles assigned to user. - * Assign Role To User - */ - async assignRoleToUser( - requestParameters: AssignRoleToUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const response = await this.assignRoleToUserRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * List available roles. Requires logged in user to be a super user. Args: roles: Roles from config. super_user: Checks if current user is super. Returns: List of role names. - * List Roles - */ - async listRolesRaw( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise>> { - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/api/roles/`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response); - } - - /** - * List available roles. Requires logged in user to be a super user. Args: roles: Roles from config. super_user: Checks if current user is super. Returns: List of role names. - * List Roles - */ - async listRoles( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const response = await this.listRolesRaw(initOverrides); - return await response.value(); - } - - /** - * Unassign role from user. Requires super user powers. Args: role_id: Role id user_id: User id roles: Set of allowed roles super_user: Check if current user is super. user_db: User db. Raises: HTTPException: When user is not found Returns: Roles assigned to user. - * Unassign Role From User - */ - async unassignRoleFromUserRaw( - requestParameters: UnassignRoleFromUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise>> { - if ( - requestParameters.roleId === null || - requestParameters.roleId === undefined - ) { - throw new runtime.RequiredError( - "roleId", - "Required parameter requestParameters.roleId was null or undefined when calling unassignRoleFromUser." - ); - } - - if ( - requestParameters.userId === null || - requestParameters.userId === undefined - ) { - throw new runtime.RequiredError( - "userId", - "Required parameter requestParameters.userId was null or undefined when calling unassignRoleFromUser." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/api/roles/{role_id}/{user_id}` - .replace( - `{${"role_id"}}`, - encodeURIComponent(String(requestParameters.roleId)) - ) - .replace( - `{${"user_id"}}`, - encodeURIComponent(String(requestParameters.userId)) - ), - method: "DELETE", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response); - } - - /** - * Unassign role from user. Requires super user powers. Args: role_id: Role id user_id: User id roles: Set of allowed roles super_user: Check if current user is super. user_db: User db. Raises: HTTPException: When user is not found Returns: Roles assigned to user. - * Unassign Role From User - */ - async unassignRoleFromUser( - requestParameters: UnassignRoleFromUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const response = await this.unassignRoleFromUserRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } -} diff --git a/app/bartender-client/apis/UserApi.ts b/app/bartender-client/apis/UserApi.ts new file mode 100644 index 00000000..5d982e0c --- /dev/null +++ b/app/bartender-client/apis/UserApi.ts @@ -0,0 +1,71 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * bartender + * Job middleware for i-VRESSE + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import * as runtime from "../runtime"; +import type { User } from "../models"; +import { UserFromJSON, UserToJSON } from "../models"; + +/** + * + */ +export class UserApi extends runtime.BaseAPI { + /** + * Get current user based on API key. Args: user: Current user. Returns: Current logged in user. + * Whoami + */ + async whoamiRaw( + initOverrides?: RequestInit | runtime.InitOverrideFunction + ): Promise> { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("HTTPBearer", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + if (this.configuration && this.configuration.apiKey) { + queryParameters["token"] = this.configuration.apiKey("token"); // APIKeyQuery authentication + } + + const response = await this.request( + { + path: `/api/whoami`, + method: "GET", + headers: headerParameters, + query: queryParameters, + }, + initOverrides + ); + + return new runtime.JSONApiResponse(response, (jsonValue) => + UserFromJSON(jsonValue) + ); + } + + /** + * Get current user based on API key. Args: user: Current user. Returns: Current logged in user. + * Whoami + */ + async whoami( + initOverrides?: RequestInit | runtime.InitOverrideFunction + ): Promise { + const response = await this.whoamiRaw(initOverrides); + return await response.value(); + } +} diff --git a/app/bartender-client/apis/UsersApi.ts b/app/bartender-client/apis/UsersApi.ts deleted file mode 100644 index 89a7fb90..00000000 --- a/app/bartender-client/apis/UsersApi.ts +++ /dev/null @@ -1,502 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import * as runtime from "../runtime"; -import type { - ErrorModel, - HTTPValidationError, - UserAsListItem, - UserProfileInputDTO, - UserRead, - UserUpdate, -} from "../models"; -import { - ErrorModelFromJSON, - ErrorModelToJSON, - HTTPValidationErrorFromJSON, - HTTPValidationErrorToJSON, - UserAsListItemFromJSON, - UserAsListItemToJSON, - UserProfileInputDTOFromJSON, - UserProfileInputDTOToJSON, - UserReadFromJSON, - UserReadToJSON, - UserUpdateFromJSON, - UserUpdateToJSON, -} from "../models"; - -export interface ListUsersRequest { - limit?: number; - offset?: number; -} - -export interface UsersDeleteUserRequest { - id: any; -} - -export interface UsersPatchCurrentUserRequest { - userUpdate: UserUpdate; -} - -export interface UsersPatchUserRequest { - id: any; - userUpdate: UserUpdate; -} - -export interface UsersUserRequest { - id: any; -} - -/** - * - */ -export class UsersApi extends runtime.BaseAPI { - /** - * List of users. Requires super user powers. Args: limit: Number of users to return. Defaults to 50. offset: Offset. Defaults to 0. super_user: Check if current user is super. user_db: User db. Returns: List of users. - * List Users - */ - async listUsersRaw( - requestParameters: ListUsersRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise>> { - const queryParameters: any = {}; - - if (requestParameters.limit !== undefined) { - queryParameters["limit"] = requestParameters.limit; - } - - if (requestParameters.offset !== undefined) { - queryParameters["offset"] = requestParameters.offset; - } - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/api/users/`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - jsonValue.map(UserAsListItemFromJSON) - ); - } - - /** - * List of users. Requires super user powers. Args: limit: Number of users to return. Defaults to 50. offset: Offset. Defaults to 0. super_user: Check if current user is super. user_db: User db. Returns: List of users. - * List Users - */ - async listUsers( - requestParameters: ListUsersRequest = {}, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const response = await this.listUsersRaw(requestParameters, initOverrides); - return await response.value(); - } - - /** - * Retrieve profile of currently logged in user. Args: user: Current active user. Returns: user profile. - * Profile - */ - async profileRaw( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/api/users/profile`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserProfileInputDTOFromJSON(jsonValue) - ); - } - - /** - * Retrieve profile of currently logged in user. Args: user: Current active user. Returns: user profile. - * Profile - */ - async profile( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.profileRaw(initOverrides); - return await response.value(); - } - - /** - * Users:Current User - */ - async usersCurrentUserRaw( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/users/me`, - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * Users:Current User - */ - async usersCurrentUser( - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.usersCurrentUserRaw(initOverrides); - return await response.value(); - } - - /** - * Users:Delete User - */ - async usersDeleteUserRaw( - requestParameters: UsersDeleteUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - if (requestParameters.id === null || requestParameters.id === undefined) { - throw new runtime.RequiredError( - "id", - "Required parameter requestParameters.id was null or undefined when calling usersDeleteUser." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/users/{id}`.replace( - `{${"id"}}`, - encodeURIComponent(String(requestParameters.id)) - ), - method: "DELETE", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.VoidApiResponse(response); - } - - /** - * Users:Delete User - */ - async usersDeleteUser( - requestParameters: UsersDeleteUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - await this.usersDeleteUserRaw(requestParameters, initOverrides); - } - - /** - * Users:Patch Current User - */ - async usersPatchCurrentUserRaw( - requestParameters: UsersPatchCurrentUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - if ( - requestParameters.userUpdate === null || - requestParameters.userUpdate === undefined - ) { - throw new runtime.RequiredError( - "userUpdate", - "Required parameter requestParameters.userUpdate was null or undefined when calling usersPatchCurrentUser." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - headerParameters["Content-Type"] = "application/json"; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/users/me`, - method: "PATCH", - headers: headerParameters, - query: queryParameters, - body: UserUpdateToJSON(requestParameters.userUpdate), - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * Users:Patch Current User - */ - async usersPatchCurrentUser( - requestParameters: UsersPatchCurrentUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.usersPatchCurrentUserRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Users:Patch User - */ - async usersPatchUserRaw( - requestParameters: UsersPatchUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - if (requestParameters.id === null || requestParameters.id === undefined) { - throw new runtime.RequiredError( - "id", - "Required parameter requestParameters.id was null or undefined when calling usersPatchUser." - ); - } - - if ( - requestParameters.userUpdate === null || - requestParameters.userUpdate === undefined - ) { - throw new runtime.RequiredError( - "userUpdate", - "Required parameter requestParameters.userUpdate was null or undefined when calling usersPatchUser." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - headerParameters["Content-Type"] = "application/json"; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/users/{id}`.replace( - `{${"id"}}`, - encodeURIComponent(String(requestParameters.id)) - ), - method: "PATCH", - headers: headerParameters, - query: queryParameters, - body: UserUpdateToJSON(requestParameters.userUpdate), - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * Users:Patch User - */ - async usersPatchUser( - requestParameters: UsersPatchUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.usersPatchUserRaw( - requestParameters, - initOverrides - ); - return await response.value(); - } - - /** - * Users:User - */ - async usersUserRaw( - requestParameters: UsersUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise> { - if (requestParameters.id === null || requestParameters.id === undefined) { - throw new runtime.RequiredError( - "id", - "Required parameter requestParameters.id was null or undefined when calling usersUser." - ); - } - - const queryParameters: any = {}; - - const headerParameters: runtime.HTTPHeaders = {}; - - if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken( - "OAuth2PasswordBearer", - [] - ); - } - - if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("HTTPBearer", []); - - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } - } - const response = await this.request( - { - path: `/users/{id}`.replace( - `{${"id"}}`, - encodeURIComponent(String(requestParameters.id)) - ), - method: "GET", - headers: headerParameters, - query: queryParameters, - }, - initOverrides - ); - - return new runtime.JSONApiResponse(response, (jsonValue) => - UserReadFromJSON(jsonValue) - ); - } - - /** - * Users:User - */ - async usersUser( - requestParameters: UsersUserRequest, - initOverrides?: RequestInit | runtime.InitOverrideFunction - ): Promise { - const response = await this.usersUserRaw(requestParameters, initOverrides); - return await response.value(); - } -} diff --git a/app/bartender-client/apis/index.ts b/app/bartender-client/apis/index.ts index ca036c6a..e3ef3240 100644 --- a/app/bartender-client/apis/index.ts +++ b/app/bartender-client/apis/index.ts @@ -1,8 +1,6 @@ /* tslint:disable */ /* eslint-disable */ export * from "./ApplicationApi"; -export * from "./AuthApi"; export * from "./DefaultApi"; export * from "./JobApi"; -export * from "./RolesApi"; -export * from "./UsersApi"; +export * from "./UserApi"; diff --git a/app/bartender-client/models/BearerResponse.ts b/app/bartender-client/models/BearerResponse.ts deleted file mode 100644 index abd4e170..00000000 --- a/app/bartender-client/models/BearerResponse.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -/** - * - * @export - * @interface BearerResponse - */ -export interface BearerResponse { - /** - * - * @type {string} - * @memberof BearerResponse - */ - accessToken: string; - /** - * - * @type {string} - * @memberof BearerResponse - */ - tokenType: string; -} - -/** - * Check if a given object implements the BearerResponse interface. - */ -export function instanceOfBearerResponse(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "accessToken" in value; - isInstance = isInstance && "tokenType" in value; - - return isInstance; -} - -export function BearerResponseFromJSON(json: any): BearerResponse { - return BearerResponseFromJSONTyped(json, false); -} - -export function BearerResponseFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): BearerResponse { - if (json === undefined || json === null) { - return json; - } - return { - accessToken: json["access_token"], - tokenType: json["token_type"], - }; -} - -export function BearerResponseToJSON(value?: BearerResponse | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - access_token: value.accessToken, - token_type: value.tokenType, - }; -} diff --git a/app/bartender-client/models/Detail.ts b/app/bartender-client/models/Detail.ts deleted file mode 100644 index 8f57f977..00000000 --- a/app/bartender-client/models/Detail.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -/** - * - * @export - * @interface Detail - */ -export interface Detail {} - -/** - * Check if a given object implements the Detail interface. - */ -export function instanceOfDetail(value: object): boolean { - let isInstance = true; - - return isInstance; -} - -export function DetailFromJSON(json: any): Detail { - return DetailFromJSONTyped(json, false); -} - -export function DetailFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): Detail { - return json; -} - -export function DetailToJSON(value?: Detail | null): any { - return value; -} diff --git a/app/bartender-client/models/ErrorModel.ts b/app/bartender-client/models/ErrorModel.ts deleted file mode 100644 index baf70ed4..00000000 --- a/app/bartender-client/models/ErrorModel.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -import type { Detail } from "./Detail"; -import { DetailFromJSON, DetailFromJSONTyped, DetailToJSON } from "./Detail"; - -/** - * - * @export - * @interface ErrorModel - */ -export interface ErrorModel { - /** - * - * @type {Detail} - * @memberof ErrorModel - */ - detail: Detail; -} - -/** - * Check if a given object implements the ErrorModel interface. - */ -export function instanceOfErrorModel(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "detail" in value; - - return isInstance; -} - -export function ErrorModelFromJSON(json: any): ErrorModel { - return ErrorModelFromJSONTyped(json, false); -} - -export function ErrorModelFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): ErrorModel { - if (json === undefined || json === null) { - return json; - } - return { - detail: DetailFromJSON(json["detail"]), - }; -} - -export function ErrorModelToJSON(value?: ErrorModel | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - detail: DetailToJSON(value.detail), - }; -} diff --git a/app/bartender-client/models/OAuth2AuthorizeResponse.ts b/app/bartender-client/models/OAuth2AuthorizeResponse.ts deleted file mode 100644 index 76e0b81d..00000000 --- a/app/bartender-client/models/OAuth2AuthorizeResponse.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -/** - * - * @export - * @interface OAuth2AuthorizeResponse - */ -export interface OAuth2AuthorizeResponse { - /** - * - * @type {string} - * @memberof OAuth2AuthorizeResponse - */ - authorizationUrl: string; -} - -/** - * Check if a given object implements the OAuth2AuthorizeResponse interface. - */ -export function instanceOfOAuth2AuthorizeResponse(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "authorizationUrl" in value; - - return isInstance; -} - -export function OAuth2AuthorizeResponseFromJSON( - json: any -): OAuth2AuthorizeResponse { - return OAuth2AuthorizeResponseFromJSONTyped(json, false); -} - -export function OAuth2AuthorizeResponseFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): OAuth2AuthorizeResponse { - if (json === undefined || json === null) { - return json; - } - return { - authorizationUrl: json["authorization_url"], - }; -} - -export function OAuth2AuthorizeResponseToJSON( - value?: OAuth2AuthorizeResponse | null -): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - authorization_url: value.authorizationUrl, - }; -} diff --git a/app/bartender-client/models/OAuthAccountName.ts b/app/bartender-client/models/OAuthAccountName.ts deleted file mode 100644 index d24216e8..00000000 --- a/app/bartender-client/models/OAuthAccountName.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -/** - * DTO for social providers name of a user. - * @export - * @interface OAuthAccountName - */ -export interface OAuthAccountName { - /** - * - * @type {string} - * @memberof OAuthAccountName - */ - oauthName: string; - /** - * - * @type {string} - * @memberof OAuthAccountName - */ - accountId: string; - /** - * - * @type {string} - * @memberof OAuthAccountName - */ - accountEmail: string; -} - -/** - * Check if a given object implements the OAuthAccountName interface. - */ -export function instanceOfOAuthAccountName(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "oauthName" in value; - isInstance = isInstance && "accountId" in value; - isInstance = isInstance && "accountEmail" in value; - - return isInstance; -} - -export function OAuthAccountNameFromJSON(json: any): OAuthAccountName { - return OAuthAccountNameFromJSONTyped(json, false); -} - -export function OAuthAccountNameFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): OAuthAccountName { - if (json === undefined || json === null) { - return json; - } - return { - oauthName: json["oauth_name"], - accountId: json["account_id"], - accountEmail: json["account_email"], - }; -} - -export function OAuthAccountNameToJSON(value?: OAuthAccountName | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - oauth_name: value.oauthName, - account_id: value.accountId, - account_email: value.accountEmail, - }; -} diff --git a/app/bartender-client/models/User.ts b/app/bartender-client/models/User.ts new file mode 100644 index 00000000..1aae08c9 --- /dev/null +++ b/app/bartender-client/models/User.ts @@ -0,0 +1,83 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * bartender + * Job middleware for i-VRESSE + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * User model. + * @export + * @interface User + */ +export interface User { + /** + * + * @type {string} + * @memberof User + */ + username: string; + /** + * + * @type {Array} + * @memberof User + */ + roles?: Array; + /** + * + * @type {string} + * @memberof User + */ + apikey: string; +} + +/** + * Check if a given object implements the User interface. + */ +export function instanceOfUser(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "username" in value; + isInstance = isInstance && "apikey" in value; + + return isInstance; +} + +export function UserFromJSON(json: any): User { + return UserFromJSONTyped(json, false); +} + +export function UserFromJSONTyped( + json: any, + ignoreDiscriminator: boolean +): User { + if (json === undefined || json === null) { + return json; + } + return { + username: json["username"], + roles: !exists(json, "roles") ? undefined : json["roles"], + apikey: json["apikey"], + }; +} + +export function UserToJSON(value?: User | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + username: value.username, + roles: value.roles, + apikey: value.apikey, + }; +} diff --git a/app/bartender-client/models/UserAsListItem.ts b/app/bartender-client/models/UserAsListItem.ts deleted file mode 100644 index 73b82cff..00000000 --- a/app/bartender-client/models/UserAsListItem.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -import type { OAuthAccountName } from "./OAuthAccountName"; -import { - OAuthAccountNameFromJSON, - OAuthAccountNameFromJSONTyped, - OAuthAccountNameToJSON, -} from "./OAuthAccountName"; - -/** - * DTO for user in a list. - * @export - * @interface UserAsListItem - */ -export interface UserAsListItem { - /** - * - * @type {string} - * @memberof UserAsListItem - */ - email: string; - /** - * - * @type {Array} - * @memberof UserAsListItem - */ - oauthAccounts: Array; - /** - * - * @type {Array} - * @memberof UserAsListItem - */ - roles: Array; - /** - * - * @type {string} - * @memberof UserAsListItem - */ - id: string; - /** - * - * @type {boolean} - * @memberof UserAsListItem - */ - isActive: boolean; - /** - * - * @type {boolean} - * @memberof UserAsListItem - */ - isSuperuser: boolean; - /** - * - * @type {boolean} - * @memberof UserAsListItem - */ - isVerified: boolean; -} - -/** - * Check if a given object implements the UserAsListItem interface. - */ -export function instanceOfUserAsListItem(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "email" in value; - isInstance = isInstance && "oauthAccounts" in value; - isInstance = isInstance && "roles" in value; - isInstance = isInstance && "id" in value; - isInstance = isInstance && "isActive" in value; - isInstance = isInstance && "isSuperuser" in value; - isInstance = isInstance && "isVerified" in value; - - return isInstance; -} - -export function UserAsListItemFromJSON(json: any): UserAsListItem { - return UserAsListItemFromJSONTyped(json, false); -} - -export function UserAsListItemFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): UserAsListItem { - if (json === undefined || json === null) { - return json; - } - return { - email: json["email"], - oauthAccounts: (json["oauth_accounts"] as Array).map( - OAuthAccountNameFromJSON - ), - roles: json["roles"], - id: json["id"], - isActive: json["is_active"], - isSuperuser: json["is_superuser"], - isVerified: json["is_verified"], - }; -} - -export function UserAsListItemToJSON(value?: UserAsListItem | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - email: value.email, - oauth_accounts: (value.oauthAccounts as Array).map( - OAuthAccountNameToJSON - ), - roles: value.roles, - id: value.id, - is_active: value.isActive, - is_superuser: value.isSuperuser, - is_verified: value.isVerified, - }; -} diff --git a/app/bartender-client/models/UserCreate.ts b/app/bartender-client/models/UserCreate.ts deleted file mode 100644 index 2293978d..00000000 --- a/app/bartender-client/models/UserCreate.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -/** - * DTO to create user. - * @export - * @interface UserCreate - */ -export interface UserCreate { - /** - * - * @type {string} - * @memberof UserCreate - */ - email: string; - /** - * - * @type {string} - * @memberof UserCreate - */ - password: string; - /** - * - * @type {boolean} - * @memberof UserCreate - */ - isActive?: boolean; - /** - * - * @type {boolean} - * @memberof UserCreate - */ - isSuperuser?: boolean; - /** - * - * @type {boolean} - * @memberof UserCreate - */ - isVerified?: boolean; -} - -/** - * Check if a given object implements the UserCreate interface. - */ -export function instanceOfUserCreate(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "email" in value; - isInstance = isInstance && "password" in value; - - return isInstance; -} - -export function UserCreateFromJSON(json: any): UserCreate { - return UserCreateFromJSONTyped(json, false); -} - -export function UserCreateFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): UserCreate { - if (json === undefined || json === null) { - return json; - } - return { - email: json["email"], - password: json["password"], - isActive: !exists(json, "is_active") ? undefined : json["is_active"], - isSuperuser: !exists(json, "is_superuser") - ? undefined - : json["is_superuser"], - isVerified: !exists(json, "is_verified") ? undefined : json["is_verified"], - }; -} - -export function UserCreateToJSON(value?: UserCreate | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - email: value.email, - password: value.password, - is_active: value.isActive, - is_superuser: value.isSuperuser, - is_verified: value.isVerified, - }; -} diff --git a/app/bartender-client/models/UserProfileInputDTO.ts b/app/bartender-client/models/UserProfileInputDTO.ts deleted file mode 100644 index d39909ac..00000000 --- a/app/bartender-client/models/UserProfileInputDTO.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -import type { OAuthAccountName } from "./OAuthAccountName"; -import { - OAuthAccountNameFromJSON, - OAuthAccountNameFromJSONTyped, - OAuthAccountNameToJSON, -} from "./OAuthAccountName"; - -/** - * DTO for profile of current user model. - * @export - * @interface UserProfileInputDTO - */ -export interface UserProfileInputDTO { - /** - * - * @type {string} - * @memberof UserProfileInputDTO - */ - email: string; - /** - * - * @type {Array} - * @memberof UserProfileInputDTO - */ - oauthAccounts: Array; - /** - * - * @type {Array} - * @memberof UserProfileInputDTO - */ - roles: Array; -} - -/** - * Check if a given object implements the UserProfileInputDTO interface. - */ -export function instanceOfUserProfileInputDTO(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "email" in value; - isInstance = isInstance && "oauthAccounts" in value; - isInstance = isInstance && "roles" in value; - - return isInstance; -} - -export function UserProfileInputDTOFromJSON(json: any): UserProfileInputDTO { - return UserProfileInputDTOFromJSONTyped(json, false); -} - -export function UserProfileInputDTOFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): UserProfileInputDTO { - if (json === undefined || json === null) { - return json; - } - return { - email: json["email"], - oauthAccounts: (json["oauth_accounts"] as Array).map( - OAuthAccountNameFromJSON - ), - roles: json["roles"], - }; -} - -export function UserProfileInputDTOToJSON( - value?: UserProfileInputDTO | null -): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - email: value.email, - oauth_accounts: (value.oauthAccounts as Array).map( - OAuthAccountNameToJSON - ), - roles: value.roles, - }; -} diff --git a/app/bartender-client/models/UserRead.ts b/app/bartender-client/models/UserRead.ts deleted file mode 100644 index d76562f7..00000000 --- a/app/bartender-client/models/UserRead.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -/** - * DTO for read user. - * @export - * @interface UserRead - */ -export interface UserRead { - /** - * - * @type {any} - * @memberof UserRead - */ - id?: any | null; - /** - * - * @type {string} - * @memberof UserRead - */ - email: string; - /** - * - * @type {boolean} - * @memberof UserRead - */ - isActive?: boolean; - /** - * - * @type {boolean} - * @memberof UserRead - */ - isSuperuser?: boolean; - /** - * - * @type {boolean} - * @memberof UserRead - */ - isVerified?: boolean; - /** - * - * @type {Array} - * @memberof UserRead - */ - roles: Array; -} - -/** - * Check if a given object implements the UserRead interface. - */ -export function instanceOfUserRead(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "email" in value; - isInstance = isInstance && "roles" in value; - - return isInstance; -} - -export function UserReadFromJSON(json: any): UserRead { - return UserReadFromJSONTyped(json, false); -} - -export function UserReadFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): UserRead { - if (json === undefined || json === null) { - return json; - } - return { - id: !exists(json, "id") ? undefined : json["id"], - email: json["email"], - isActive: !exists(json, "is_active") ? undefined : json["is_active"], - isSuperuser: !exists(json, "is_superuser") - ? undefined - : json["is_superuser"], - isVerified: !exists(json, "is_verified") ? undefined : json["is_verified"], - roles: json["roles"], - }; -} - -export function UserReadToJSON(value?: UserRead | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - id: value.id, - email: value.email, - is_active: value.isActive, - is_superuser: value.isSuperuser, - is_verified: value.isVerified, - roles: value.roles, - }; -} diff --git a/app/bartender-client/models/UserUpdate.ts b/app/bartender-client/models/UserUpdate.ts deleted file mode 100644 index 577e86f3..00000000 --- a/app/bartender-client/models/UserUpdate.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * bartender - * Job middleware for i-VRESSE - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from "../runtime"; -/** - * DTO to update user. - * @export - * @interface UserUpdate - */ -export interface UserUpdate { - /** - * - * @type {string} - * @memberof UserUpdate - */ - password?: string; - /** - * - * @type {string} - * @memberof UserUpdate - */ - email?: string; - /** - * - * @type {boolean} - * @memberof UserUpdate - */ - isActive?: boolean; - /** - * - * @type {boolean} - * @memberof UserUpdate - */ - isSuperuser?: boolean; - /** - * - * @type {boolean} - * @memberof UserUpdate - */ - isVerified?: boolean; -} - -/** - * Check if a given object implements the UserUpdate interface. - */ -export function instanceOfUserUpdate(value: object): boolean { - let isInstance = true; - - return isInstance; -} - -export function UserUpdateFromJSON(json: any): UserUpdate { - return UserUpdateFromJSONTyped(json, false); -} - -export function UserUpdateFromJSONTyped( - json: any, - ignoreDiscriminator: boolean -): UserUpdate { - if (json === undefined || json === null) { - return json; - } - return { - password: !exists(json, "password") ? undefined : json["password"], - email: !exists(json, "email") ? undefined : json["email"], - isActive: !exists(json, "is_active") ? undefined : json["is_active"], - isSuperuser: !exists(json, "is_superuser") - ? undefined - : json["is_superuser"], - isVerified: !exists(json, "is_verified") ? undefined : json["is_verified"], - }; -} - -export function UserUpdateToJSON(value?: UserUpdate | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - password: value.password, - email: value.email, - is_active: value.isActive, - is_superuser: value.isSuperuser, - is_verified: value.isVerified, - }; -} diff --git a/app/bartender-client/models/index.ts b/app/bartender-client/models/index.ts index 5d507499..90b138a5 100644 --- a/app/bartender-client/models/index.ts +++ b/app/bartender-client/models/index.ts @@ -1,18 +1,9 @@ /* tslint:disable */ /* eslint-disable */ export * from "./ApplicatonConfiguration"; -export * from "./BearerResponse"; -export * from "./Detail"; export * from "./DirectoryItem"; -export * from "./ErrorModel"; export * from "./HTTPValidationError"; export * from "./JobModelDTO"; export * from "./LocationInner"; -export * from "./OAuth2AuthorizeResponse"; -export * from "./OAuthAccountName"; -export * from "./UserAsListItem"; -export * from "./UserCreate"; -export * from "./UserProfileInputDTO"; -export * from "./UserRead"; -export * from "./UserUpdate"; +export * from "./User"; export * from "./ValidationError"; diff --git a/package-lock.json b/package-lock.json index 6a57396d..58810f9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ }, "devDependencies": { "@ltd/j-toml": "^1.38.0", - "@openapitools/openapi-generator-cli": "^2.5.2", + "@openapitools/openapi-generator-cli": "^2.7.0", "@remix-run/dev": "^1.16.1", "@remix-run/eslint-config": "^1.16.1", "@tailwindcss/forms": "^0.5.3", @@ -2883,37 +2883,35 @@ } }, "node_modules/@nestjs/axios": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-0.0.8.tgz", - "integrity": "sha512-oJyfR9/h9tVk776il0829xyj3b2e81yTu6HjPraxynwNtMNGqZBHHmAQL24yMB3tVbBM0RvG3eUXH8+pRCGwlg==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-0.1.0.tgz", + "integrity": "sha512-b2TT2X6BFbnNoeteiaxCIiHaFcSbVW+S5yygYqiIq5i6H77yIU3IVuLdpQkHq8/EqOWFwMopLN8jdkUT71Am9w==", "dev": true, "dependencies": { "axios": "0.27.2" }, "peerDependencies": { - "@nestjs/common": "^7.0.0 || ^8.0.0", + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", "reflect-metadata": "^0.1.12", "rxjs": "^6.0.0 || ^7.0.0" } }, "node_modules/@nestjs/common": { - "version": "8.4.7", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-8.4.7.tgz", - "integrity": "sha512-m/YsbcBal+gA5CFrDpqXqsSfylo+DIQrkFY3qhVIltsYRfu8ct8J9pqsTO6OPf3mvqdOpFGrV5sBjoyAzOBvsw==", + "version": "9.3.11", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-9.3.11.tgz", + "integrity": "sha512-IFZ2G/5UKWC2Uo7tJ4SxGed2+aiA+sJyWeWsGTogKVDhq90oxVBToh+uCDeI31HNUpqYGoWmkletfty42zUd8A==", "dev": true, - "peer": true, "dependencies": { - "axios": "0.27.2", "iterare": "1.2.1", - "tslib": "2.4.0", - "uuid": "8.3.2" + "tslib": "2.5.0", + "uid": "2.0.1" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/nest" }, "peerDependencies": { - "cache-manager": "*", + "cache-manager": "<=5", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12", @@ -2931,13 +2929,6 @@ } } }, - "node_modules/@nestjs/common/node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true, - "peer": true - }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -3037,13 +3028,13 @@ } }, "node_modules/@openapitools/openapi-generator-cli": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.6.0.tgz", - "integrity": "sha512-M/aOpR7G+Y1nMf+ofuar8pGszajgfhs1aSPSijkcr2tHTxKAI3sA3YYcOGbszxaNRKFyvOcDq+KP9pcJvKoCHg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.7.0.tgz", + "integrity": "sha512-ieEpHTA/KsDz7ANw03lLPYyjdedDEXYEyYoGBRWdduqXWSX65CJtttjqa8ZaB1mNmIjMtchUHwAYQmTLVQ8HYg==", "dev": true, "hasInstallScript": true, "dependencies": { - "@nestjs/axios": "0.0.8", + "@nestjs/axios": "0.1.0", "@nestjs/common": "9.3.11", "@nestjs/core": "9.3.11", "@nuxtjs/opencollective": "0.3.2", @@ -3071,45 +3062,6 @@ "url": "https://opencollective.com/openapi_generator" } }, - "node_modules/@openapitools/openapi-generator-cli/node_modules/@nestjs/common": { - "version": "9.3.11", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-9.3.11.tgz", - "integrity": "sha512-IFZ2G/5UKWC2Uo7tJ4SxGed2+aiA+sJyWeWsGTogKVDhq90oxVBToh+uCDeI31HNUpqYGoWmkletfty42zUd8A==", - "dev": true, - "dependencies": { - "iterare": "1.2.1", - "tslib": "2.5.0", - "uid": "2.0.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "cache-manager": "<=5", - "class-transformer": "*", - "class-validator": "*", - "reflect-metadata": "^0.1.12", - "rxjs": "^7.1.0" - }, - "peerDependenciesMeta": { - "cache-manager": { - "optional": true - }, - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, - "node_modules/@openapitools/openapi-generator-cli/node_modules/@nestjs/common/node_modules/tslib": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", - "dev": true - }, "node_modules/@openapitools/openapi-generator-cli/node_modules/@nestjs/core": { "version": "9.3.11", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-9.3.11.tgz", diff --git a/package.json b/package.json index 27c68c6b..173b9039 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@ltd/j-toml": "^1.38.0", - "@openapitools/openapi-generator-cli": "^2.5.2", + "@openapitools/openapi-generator-cli": "^2.7.0", "@remix-run/dev": "^1.16.1", "@remix-run/eslint-config": "^1.16.1", "@tailwindcss/forms": "^0.5.3", From aa50570f723955e432d66893ddc6977a82fed005 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Fri, 28 Jul 2023 13:59:42 +0200 Subject: [PATCH 11/45] Store bartender token in db + make preferred expertise level configurable by user + lots of other stuff to make things work and look cleaner --- .env.example | 3 +- .github/workflows/ci.yml | 5 ++ README.md | 73 +++++++++++++------------ app/auth.server.ts | 53 ++++++++++++++---- app/auth.ts | 13 ++--- app/bartender_token.server.ts | 53 ++++++++++-------- app/components/admin/UserTableRow.tsx | 46 ++++++++++++---- app/models/job.server.ts | 61 ++++++++++++--------- app/models/user.server.ts | 77 +++++++++++++++++++-------- app/root.tsx | 13 ++--- app/routes/admin/users.tsx | 34 +++++++----- app/routes/builder.tsx | 15 +++--- app/routes/jobs/$id.edit.tsx | 22 ++++---- app/routes/jobs/$id.tsx | 15 +++--- app/routes/jobs/$id/[input.zip].tsx | 13 ++--- app/routes/jobs/$id/[output.zip].tsx | 13 ++--- app/routes/jobs/$id/archive/$.ts | 14 ++--- app/routes/jobs/$id/files/$.ts | 13 ++--- app/routes/jobs/$id/stderr.tsx | 13 ++--- app/routes/jobs/$id/stdout.tsx | 13 ++--- app/routes/jobs/$id/zip.tsx | 13 ++--- app/routes/jobs/index.tsx | 10 ++-- app/routes/login.tsx | 4 +- app/routes/profile.tsx | 75 ++++++++++++++++++++------ app/routes/register.tsx | 4 +- app/routes/upload.tsx | 23 +++----- prisma/schema.prisma | 7 +-- 27 files changed, 414 insertions(+), 284 deletions(-) diff --git a/.env.example b/.env.example index 6532ba3e..01beb7f0 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ -DATABASE_URL=file:./dev.db \ No newline at end of file +DATABASE_URL=file:./dev.db +SESSION_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6994d546..000f502c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: cache: "npm" - run: npm ci - run: npm run build --if-present + - name: Generate RSA key pair + run: | + openssl genpkey -algorithm RSA -out private_key.pem \ + -pkeyopt rsa_keygen_bits:2048 + openssl rsa -pubout -in private_key.pem -out public_key.pem - run: npm test -- --coverage - run: npm run typecheck - run: npx prettier --check . diff --git a/README.md b/README.md index 0930aad3..1c04a67d 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ Uses -- [bartender](https://github.com/i-VRESSE/bartender) for user and job management. +- [bartender](https://github.com/i-VRESSE/bartender) for job execution. - [workflow-builder](https://github.com/i-VRESSE/workflow-builder) to construct a Haddock3 workflow config file. - [haddock3](https://github.com/haddocking/haddock3) to compute ```mermaid sequenceDiagram - Web app->>+Bartender: Login + Web app->>+Web app: Login Web app->>+Builder: Construct workflow config Builder->>+Bartender: Submit job Bartender->>+haddock3: Run @@ -29,10 +29,12 @@ npm install cp .env.example .env npx prisma db push npx prisma db seed +# Create rsa key pair for signing & verifying JWT tokens for bartender web service +openssl genpkey -algorithm RSA -out private_key.pem \ + -pkeyopt rsa_keygen_bits:2048 +openssl rsa -pubout -in private_key.pem -out public_key.pem ``` -First user that registers is a admin user. - ## Development From your terminal: @@ -121,20 +123,31 @@ docker compose up Web application running at http://localhost:8080 . -Create super user with +## Authentication & authorization -```sh -# First register user in web application -docker compose exec bartender bartender super +A user can only submit jobs when he/she is logged in and has at least one expertise level. +A super user should assign an expertise level to the user at http://localhost:3000/admin/users. +A super user can be made through the admin page or by being the first registered user. + +The sessions will be encrypted with a secret key from an environment variable. + +```shell +SESSION_SECRET=... ``` -## Sessions +### Social login + +To enable GitHub or Orcid or EGI Check-in login the web apps needs following environment variables. + +```shell +HADDOCK3WEBAPP_GITHUB_CLIENT_ID=... +HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=... +HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback +``` -Making the login session secure requires a session secret. -The session secret can be configured by setting the `SESSION_SECRET` environment variable. -If not set, a hardcoded secret is used, which should not be used in production. +The environment variables can also be stored in a `.env` file. -The data of the login sessions in stored in the `./sessions` directory. +Only use social logins where the email address has been verified. ## Bartender web service client @@ -150,29 +163,27 @@ npm run generate-client ## Bartender web service configuration -### Bartender - -The web application needs to know where the [Bartender web service](https://github.com/i-VRESSE/bartender) is running. +The haddock3 web application needs to know where the [Bartender web service](https://github.com/i-VRESSE/bartender) is running. Configure bartender location with `BARTENDER_API_URL` environment variable. ```sh -export BARTENDER_API_URL='http://127.0.0.1:8000' -npm start +BARTENDER_API_URL=http://localhost:8000 ``` -### Social login - -To enable GitHub or Orcid or EGI Check-in login the web apps needs following environment variables. +The haddock3 web application must be trusted by the bartender web service using a JWT token. +An RSA private key is used by the haddock3 web application to sign the JWT token. +To tell the bartender web service where to find the private key, use the `BARTENDER_PRIVATE_KEY` environment variable. -```shell -HADDOCK3WEBAPP_GITHUB_CLIENT_ID=... -HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=... -HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback +```sh +BARTENDER_PRIVATE_KEY=private_key.pem ``` -The environment variables can also be stored in a `.env` file. +An RSA public key is used by the bartender web service to verify the JWT token. +To tell the bartender web service where to find the public key, use the `BARTENDER_PUBLIC_KEY` environment variable. -Only use social logins where the email address has been verified. +```sh +BARTENDER_PUBLIC_KEY=public_key.pem +``` ## Haddock3 application @@ -183,18 +194,10 @@ applications: haddock3: command: haddock3 $config config: workflow.cfg - allowed_roles: - - easy - - expert - - guru ``` This allows the archive generated with the workflow builder to be submitted. -The user can only submit jobs when he/she has any of these allowed roles. -A super user should assign a role to the user at http://localhost:3000/admin/users. -A super user can be made through the admin page or by running `bartender super ` on the server - ## Catalogs This repo has a copy (`./app/catalogs/*.yaml`) of the [haddock3 workflow build catalogs](https://github.com/i-VRESSE/workflow-builder/tree/main/packages/haddock3_catalog/public/catalog). diff --git a/app/auth.server.ts b/app/auth.server.ts index 6d4a1f2f..409a0ace 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -1,14 +1,16 @@ import { Authenticator } from "remix-auth"; import { GitHubStrategy } from "remix-auth-github"; import { FormStrategy } from "remix-auth-form"; +import { json } from "@remix-run/node"; + import { sessionStorage } from "./session.server"; import { + type TokenLessUser, getUserById, + isSubmitAllowed, localLogin, oauthregister, - verifyIsAdmin, } from "./models/user.server"; -import { json } from "@remix-run/node"; // Create an instance of the authenticator, pass a generic with what // strategies will return and will store in the session @@ -50,6 +52,7 @@ if ( }, async ({ profile }) => { // TODO store photo or avatar so it can be displayed in NavBar + // TODO fetch verified email not just first email const primaryEmail = profile.emails[0].value; const userId = await oauthregister(primaryEmail); return userId; @@ -59,22 +62,52 @@ if ( authenticator.use(gitHubStrategy); } -export async function getUser(request: Request) { +export async function mustBeAuthenticated(request: Request) { + const userId = await authenticator.isAuthenticated(request); + if (userId === null) { + throw json({ error: "Unauthorized" }, { status: 401 }); + } + return userId; +} + +export async function getOptionalUser(request: Request) { const userId = await authenticator.isAuthenticated(request); if (userId === null) { return null; } - const user = await getUserById(userId); + return await getUserById(userId); +} + +export async function getUser(request: Request) { + const user = await getOptionalUser(request); + if (!user) { + throw json({ error: "Unauthorized" }, { status: 401 }); + } return user; } +export async function getOptionalClientUser( + request: Request +): Promise { + const user = await getOptionalUser(request); + if (!user) { + return null; + } + const { bartenderToken, bartenderTokenExpiresAt, ...tokenLessUser } = user; + return tokenLessUser; +} + export async function mustBeAdmin(request: Request) { - const userId = await authenticator.isAuthenticated(request); - if (userId === null) { - throw json("Unauthorized", { status: 401 }); + const user = await getUser(request); + if (!user.isAdmin) { + throw json("Forbidden, not admin", { status: 403 }); } - const isAdmin = await verifyIsAdmin(userId); - if (!isAdmin) { - throw json("Forbidden", { status: 403 }); +} + +export async function mustBeAllowedToSubmit(request: Request) { + const user = await getUser(request); + if (!isSubmitAllowed(user.preferredExpertiseLevel)) { + throw json({ error: "Submit not allowed" }, { status: 403 }); } + return user; } diff --git a/app/auth.ts b/app/auth.ts index bdeb4b61..70f9da53 100644 --- a/app/auth.ts +++ b/app/auth.ts @@ -1,6 +1,7 @@ -import { useMatches } from "@remix-run/react"; -import type { User } from "./models/user.server"; import { useMemo } from "react"; +import { useMatches } from "@remix-run/react"; + +import type { TokenLessUser } from "./models/user.server"; /** * This base hook is used in other hooks to quickly search for specific data @@ -19,11 +20,11 @@ export function useMatchesData( return route?.data; } -function isUser(user: any): user is User { +function isUser(user: any): user is TokenLessUser { return user && typeof user === "object" && typeof user.email === "string"; } -export function useOptionalUser(): User | undefined { +export function useOptionalUser() { const data = useMatchesData("root"); if (!data || !isUser(data.user)) { return undefined; @@ -31,7 +32,7 @@ export function useOptionalUser(): User | undefined { return data.user; } -export function useUser(): User { +export function useUser() { const maybeUser = useOptionalUser(); if (!maybeUser) { throw new Error( @@ -43,7 +44,7 @@ export function useUser(): User { export function useIsAdmin(): boolean { const user = useOptionalUser(); - return !!user?.roles.find((role) => role.name === "admin"); + return user?.isAdmin ?? false; } export function useIsLoggedIn(): boolean { diff --git a/app/bartender_token.server.ts b/app/bartender_token.server.ts index 560a7aae..9be76533 100644 --- a/app/bartender_token.server.ts +++ b/app/bartender_token.server.ts @@ -2,20 +2,27 @@ * Functions dealing with access token of bartender web service. */ import { readFile } from "fs/promises"; -import { type KeyLike, SignJWT, importPKCS8 } from "jose"; +import { type KeyLike, SignJWT, importPKCS8, decodeJwt } from "jose"; + import { getUser } from "./auth.server"; -import { json } from "@remix-run/node"; -import { getLevel, isSubmitAllowed } from "./models/user.server"; +import type { User } from "./models/user.server"; +import { setBartenderToken } from "./models/user.server"; const alg = "RS256"; -class TokenGenerator { +export class TokenGenerator { private privateKeyFilename: string; private issuer: string; + private lifeSpan: string; private privateKey: KeyLike | undefined = undefined; - constructor(privateKeyFilename: string, issuer: string = "bartender") { + constructor( + privateKeyFilename: string, + issuer: string = "bartender", + lifespan = "8h" + ) { this.privateKeyFilename = privateKeyFilename; this.issuer = issuer; + this.lifeSpan = lifespan; } async init() { @@ -27,16 +34,17 @@ class TokenGenerator { this.privateKey = privateKey; } - async generate(sub: string, email: string, roles: string[]) { + async generate(sub: string, email: string) { if (!this.privateKey) { throw new Error("private key not initialized"); } + // If bartender has been configured with allowed_roles for an application, + // then the a role claim should be in the JWT. const jwt = await new SignJWT({ email: email, - roles: roles, }) .setIssuer(this.issuer) - .setExpirationTime("30d") + .setExpirationTime(this.lifeSpan) .setIssuedAt() .setSubject(sub) .setProtectedHeader({ alg }) @@ -46,21 +54,24 @@ class TokenGenerator { } const privateKeyFilename = process.env.BARTENDER_PRIVATE_KEY || "private_key.pem"; -// TODO only use singleton if reading private key is slow const generator = new TokenGenerator(privateKeyFilename); -export async function getAccessToken(request: Request) { +export async function getBartenderToken(request: Request) { const user = await getUser(request); - if (!user) { - throw json({ error: "Unauthorized" }, { status: 401 }); - } - const roles = user.roles.map((r) => r.name); - const level = await getLevel(roles); - if (!isSubmitAllowed(level)) { - throw json({ error: "Forbidden" }, { status: 403 }); - } + return getBartenderTokenByUser(user); +} - // TODO fetch non-expired token from database - await generator.init(); - return await generator.generate(user.id, user.email, roles); +export async function getBartenderTokenByUser(user: User) { + // if token expires in less than 2 minutes, refresh it + const leeway = 120; + const nowInSeconds = new Date().getTime() / 1000; + const tokenIsExpired = user.bartenderTokenExpiresAt < nowInSeconds + leeway; + if (tokenIsExpired || !user.bartenderToken) { + await generator.init(); + const token = await generator.generate(user.id, user.email); + const { exp } = decodeJwt(token); + await setBartenderToken(user.id, token, exp!); + return token; + } + return user.bartenderToken; } diff --git a/app/components/admin/UserTableRow.tsx b/app/components/admin/UserTableRow.tsx index 40127f0e..ef3623af 100644 --- a/app/components/admin/UserTableRow.tsx +++ b/app/components/admin/UserTableRow.tsx @@ -2,34 +2,57 @@ import type { User } from "~/models/user.server"; interface IProps { user: User; - roles: string[]; + expertiseLevels: string[]; onUpdate: (data: FormData) => void; submitting: boolean; } -export const UserTableRow = ({ user, roles, onUpdate, submitting }: IProps) => { - const userRoles = user.roles.map((r) => r.name); +export const UserTableRow = ({ + user, + expertiseLevels, + onUpdate, + submitting, +}: IProps) => { + const usersExpertiseLevels = user.expertiseLevels.map((r) => r.name); return ( + + + {user.email} + + { + const data = new FormData(); + data.set("isAdmin", user.isAdmin ? "false" : "true"); + onUpdate(data); + }} + /> +
      - {roles.map((role) => { + {expertiseLevels.map((expertiseLevel) => { return ( -
    • +
    + + + ); }; diff --git a/app/models/job.server.ts b/app/models/job.server.ts index 810bf047..3db4ebf2 100644 --- a/app/models/job.server.ts +++ b/app/models/job.server.ts @@ -1,3 +1,4 @@ +import type { Params } from "@remix-run/react"; import { JobApi } from "~/bartender-client/apis/JobApi"; import { buildConfig } from "./config.server"; import { JOB_OUTPUT_DIR } from "./constants"; @@ -11,12 +12,20 @@ const BOOK_KEEPING_FILES = [ "workflow.cfg.orig", ]; -function buildJobApi(accessToken: string = "") { - return new JobApi(buildConfig(accessToken)); +function buildJobApi(bartenderToken: string = "") { + return new JobApi(buildConfig(bartenderToken)); } -export async function getJobs(accessToken: string, limit = 10, offset = 0) { - const api = buildJobApi(accessToken); +export function jobIdFromParams(params: Params) { + const jobId = params.id; + if (jobId == null) { + throw new Error("job id not given"); + } + return parseInt(jobId); +} + +export async function getJobs(bartenderToken: string, limit = 10, offset = 0) { + const api = buildJobApi(bartenderToken); return await api.retrieveJobs({ limit, offset, @@ -34,10 +43,10 @@ function handleApiError(error: unknown): never { } async function safeApi( - accessToken: string, + bartenderToken: string, fn: (api: JobApi) => Promise ): Promise { - const api = buildJobApi(accessToken); + const api = buildJobApi(bartenderToken); try { return await fn(api); } catch (error) { @@ -45,19 +54,19 @@ async function safeApi( } } -export async function getJobById(jobid: number, accessToken: string) { - return await safeApi(accessToken, (api) => api.retrieveJob({ jobid })); +export async function getJobById(jobid: number, bartenderToken: string) { + return await safeApi(bartenderToken, (api) => api.retrieveJob({ jobid })); } -export async function getJobStdout(jobid: number, accessToken: string) { - return await safeApi(accessToken, async (api) => { +export async function getJobStdout(jobid: number, bartenderToken: string) { + return await safeApi(bartenderToken, async (api) => { const response = await api.retrieveJobStdoutRaw({ jobid }); return response.raw; }); } -export async function getJobStderr(jobid: number, accessToken: string) { - return await safeApi(accessToken, async (api) => { +export async function getJobStderr(jobid: number, bartenderToken: string) { + return await safeApi(bartenderToken, async (api) => { const response = await api.retrieveJobStderrRaw({ jobid }); return response.raw; }); @@ -66,16 +75,16 @@ export async function getJobStderr(jobid: number, accessToken: string) { export async function getJobfile( jobid: number, path: string, - accessToken: string + bartenderToken: string ) { - return await safeApi(accessToken, async (api) => { + return await safeApi(bartenderToken, async (api) => { const response = await api.retrieveJobFilesRaw({ jobid, path }); return response.raw; }); } -export async function listOutputFiles(jobid: number, accessToken: string) { - return await safeApi(accessToken, async (api) => { +export async function listOutputFiles(jobid: number, bartenderToken: string) { + return await safeApi(bartenderToken, async (api) => { const items = await api.retrieveJobDirectoriesFromPath({ jobid, path: JOB_OUTPUT_DIR, @@ -88,8 +97,8 @@ export async function listOutputFiles(jobid: number, accessToken: string) { }); } -export async function listInputFiles(jobid: number, accessToken: string) { - return await safeApi(accessToken, async (api) => { +export async function listInputFiles(jobid: number, bartenderToken: string) { + return await safeApi(bartenderToken, async (api) => { const items = await api.retrieveJobDirectories({ jobid, maxDepth: 3, @@ -101,8 +110,8 @@ export async function listInputFiles(jobid: number, accessToken: string) { }); } -export async function getArchive(jobid: number, accessToken: string) { - return await safeApi(accessToken, async (api) => { +export async function getArchive(jobid: number, bartenderToken: string) { + return await safeApi(bartenderToken, async (api) => { const response = await api.retrieveJobDirectoryAsArchiveRaw({ jobid, archiveFormat: ".zip", @@ -111,8 +120,8 @@ export async function getArchive(jobid: number, accessToken: string) { }); } -export async function getInputArchive(jobid: number, accessToken: string) { - return await safeApi(accessToken, async (api) => { +export async function getInputArchive(jobid: number, bartenderToken: string) { + return await safeApi(bartenderToken, async (api) => { const response = await api.retrieveJobDirectoryAsArchiveRaw({ jobid, exclude: BOOK_KEEPING_FILES, @@ -123,16 +132,16 @@ export async function getInputArchive(jobid: number, accessToken: string) { }); } -export async function getOutputArchive(jobid: number, accessToken: string) { - return await getSubDirectoryAsArchive(jobid, JOB_OUTPUT_DIR, accessToken); +export async function getOutputArchive(jobid: number, bartenderToken: string) { + return await getSubDirectoryAsArchive(jobid, JOB_OUTPUT_DIR, bartenderToken); } export async function getSubDirectoryAsArchive( jobid: number, path: string, - accessToken: string + bartenderToken: string ) { - return await safeApi(accessToken, async (api) => { + return await safeApi(bartenderToken, async (api) => { const response = await api.retrieveJobSubdirectoryAsArchiveRaw({ jobid, path, diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 35cdad2f..909c368e 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -8,11 +8,16 @@ export interface User { readonly name: string; }[]; readonly isAdmin: boolean; - readonly preferredExpertiseLevel: string | null; + readonly preferredExpertiseLevel: string; readonly bartenderToken: string | null; - readonly bartenderTokenExpiresAt: Date | null; + readonly bartenderTokenExpiresAt: number; } +export type TokenLessUser = Omit< + User, + "bartenderToken" | "bartenderTokenExpiresAt" +>; + const userSelect = { id: true, email: true, @@ -43,7 +48,7 @@ export async function register(email: string, password: string) { async function firstUserShouldBeAdmin() { const userCount = await db.user.count(); - return userCount === 0 + return userCount === 0; } export async function localLogin(email: string, password: string) { @@ -112,16 +117,6 @@ export async function getUserByEmail(email: string) { return user; } -export async function verifyIsAdmin(id: string) { - const result = await db.user.findUnique({ - where: { - id, - isAdmin: true, - }, - }); - return !!result; -} - export async function getLevel( userRoles: string[] | undefined ): Promise { @@ -139,12 +134,6 @@ export async function getLevel( return ""; } -export function checkAuthenticated(accessToken: string | undefined) { - if (accessToken === undefined) { - throw new Error("Unauthenticated"); - } -} - export function isSubmitAllowed(level: string) { return level !== ""; } @@ -171,11 +160,18 @@ export async function listExpertiseLevels() { } export async function assignExpertiseLevel(userId: string, level: string) { + // set preferred level to the assigned level if no preferred level is set + const user = await getUserById(userId); + const preferredExpertiseLevel = user.preferredExpertiseLevel + ? user.preferredExpertiseLevel + : level; + await db.user.update({ where: { id: userId, }, data: { + preferredExpertiseLevel, expertiseLevels: { connect: { name: level, @@ -189,11 +185,22 @@ export async function assignExpertiseLevel(userId: string, level: string) { } export async function unassignExpertiseLevel(userId: string, level: string) { + // set preferred level to the first remaining level if the preferred level is the one being removed + const user = await getUserById(userId); + let preferredExpertiseLevel = user.preferredExpertiseLevel; + if (preferredExpertiseLevel === level) { + const remainingLevels = user + .expertiseLevels!.map((level) => level.name) + .filter((name) => name !== level); + preferredExpertiseLevel = remainingLevels[0] || ""; + } + await db.user.update({ where: { id: userId, }, data: { + preferredExpertiseLevel, expertiseLevels: { disconnect: { name: level, @@ -206,7 +213,29 @@ export async function unassignExpertiseLevel(userId: string, level: string) { }); } -export async function setPreferredExpertiseLevel(userId: string, level: string) { +export async function setPreferredExpertiseLevel( + userId: string, + level: string +) { + const user = await db.user.findUnique({ + where: { + id: userId, + }, + select: { + expertiseLevels: { + select: { + name: true, + }, + }, + }, + }); + if (!user) { + throw new Error("User not found"); + } + const levels = user.expertiseLevels.map((level) => level.name); + if (!levels.includes(level)) { + throw new Error("User does not have this expertise level"); + } await db.user.update({ where: { id: userId, @@ -234,7 +263,11 @@ export async function setIsAdmin(userId: string, isAdmin: boolean) { }); } -export async function setBartenderToken(userId: string, token: string, expireAt: Date) { +export async function setBartenderToken( + userId: string, + token: string, + expireAt: number +) { await db.user.update({ where: { id: userId, @@ -247,4 +280,4 @@ export async function setBartenderToken(userId: string, token: string, expireAt: id: true, }, }); -} \ No newline at end of file +} diff --git a/app/root.tsx b/app/root.tsx index fdd825de..8c8b1787 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -14,9 +14,8 @@ import { } from "@remix-run/react"; import { Navbar } from "~/components/Navbar"; +import { getOptionalClientUser } from "./auth.server"; import styles from "./tailwind.css"; -import { authenticator } from "./auth.server"; -import { getUserById } from "./models/user.server"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; @@ -27,14 +26,8 @@ export const meta: MetaFunction = () => ({ }); export async function loader({ request }: LoaderArgs) { - const userId = await authenticator.isAuthenticated(request); - if (userId === null) { - return json({ user: null }); - } - const user = await getUserById(userId); - // Client should not have access to the bartender token - const { bartenderToken, ...tokenLessUser } = user; - return json({ user: tokenLessUser }); + const user = await getOptionalClientUser(request); + return json({ user }); } export default function App() { diff --git a/app/routes/admin/users.tsx b/app/routes/admin/users.tsx index de99a402..4ea65dbd 100644 --- a/app/routes/admin/users.tsx +++ b/app/routes/admin/users.tsx @@ -6,6 +6,7 @@ import { assignExpertiseLevel, listExpertiseLevels, listUsers, + setIsAdmin, unassignExpertiseLevel, } from "~/models/user.server"; import { mustBeAdmin } from "~/auth.server"; @@ -13,28 +14,32 @@ import { mustBeAdmin } from "~/auth.server"; export async function loader({ request }: LoaderArgs) { await mustBeAdmin(request); const users = await listUsers(); - const roles = await listExpertiseLevels(); + const expertiseLevels = await listExpertiseLevels(); return json({ users, - roles, + expertiseLevels, }); } export async function action({ request }: ActionArgs) { - await mustBeAdmin(request); // TODO is this needed? + await mustBeAdmin(request); const formData = await request.formData(); const userId = formData.get("userId"); if (userId === null || typeof userId !== "string") { throw json({ error: "Unknown user" }, { status: 400 }); } - const roles = await listExpertiseLevels(); - for (const role of roles) { - const roleState = formData.get(role); - if (roleState !== null) { - if (roleState === "true") { - await assignExpertiseLevel(userId, role); + const isAdmin = formData.get("isAdmin"); + if (isAdmin !== null) { + await setIsAdmin(userId, isAdmin === "true"); + } + const levels = await listExpertiseLevels(); + for (const level of levels) { + const levelState = formData.get(level); + if (levelState !== null) { + if (levelState === "true") { + await assignExpertiseLevel(userId, level); } else { - await unassignExpertiseLevel(userId, role); + await unassignExpertiseLevel(userId, level); } } } @@ -42,7 +47,7 @@ export async function action({ request }: ActionArgs) { } export default function AdminUsersPage() { - const { users, roles } = useLoaderData(); + const { users, expertiseLevels } = useLoaderData(); const { submit, state } = useFetcher(); return (
    @@ -50,8 +55,11 @@ export default function AdminUsersPage() { + - + + + @@ -68,7 +76,7 @@ export default function AdminUsersPage() { submitting={state === "submitting"} onUpdate={update} user={user} - roles={roles} + expertiseLevels={expertiseLevels} /> ); })} diff --git a/app/routes/builder.tsx b/app/routes/builder.tsx index 8ea044f4..77a3d694 100644 --- a/app/routes/builder.tsx +++ b/app/routes/builder.tsx @@ -4,11 +4,11 @@ import { getCatalog } from "~/catalogs/index.server"; import { Haddock3WorkflowBuilder } from "~/components/Haddock3/Form.client"; import { haddock3Styles } from "~/components/Haddock3/styles"; import { submitJob } from "~/models/applicaton.server"; -import { getLevel, isSubmitAllowed } from "~/models/user.server"; +import { isSubmitAllowed } from "~/models/user.server"; import { type ICatalog } from "@i-vresse/wb-core/dist/types"; import { ClientOnly } from "~/components/ClientOnly"; -import { getUser } from "~/auth.server"; -import { getAccessToken } from "~/bartender_token.server"; +import { getOptionalUser, mustBeAllowedToSubmit } from "~/auth.server"; +import { getBartenderTokenByUser } from "~/bartender_token.server"; export const loader = async ({ request, @@ -17,10 +17,8 @@ export const loader = async ({ submitAllowed: boolean; archive: string | undefined; }> => { - const user = await getUser(request); - const level = await getLevel( - user ? user.roles.map((r) => r.name) : undefined - ); + const user = await getOptionalUser(request); + const level = user ? user.preferredExpertiseLevel : ""; // When user does not have a level he/she // can still use builder with easy level // but cannot submit only download @@ -36,7 +34,8 @@ export const action = async ({ request }: ActionArgs) => { throw new Error("Bad upload"); } - const accessToken = await getAccessToken(request); + const user = await mustBeAllowedToSubmit(request); + const accessToken = await getBartenderTokenByUser(user); const job = await submitJob(upload, accessToken!); const job_url = `/jobs/${job.id}`; return redirect(job_url); diff --git a/app/routes/jobs/$id.edit.tsx b/app/routes/jobs/$id.edit.tsx index e894d7b0..8ac6bbb6 100644 --- a/app/routes/jobs/$id.edit.tsx +++ b/app/routes/jobs/$id.edit.tsx @@ -3,30 +3,28 @@ import { type LoaderArgs } from "@remix-run/node"; import { getCatalog } from "~/catalogs/index.server"; import { Haddock3WorkflowBuilder } from "~/components/Haddock3/Form.client"; import { haddock3Styles } from "~/components/Haddock3/styles"; -import { getLevel, isSubmitAllowed } from "~/models/user.server"; +import { isSubmitAllowed } from "~/models/user.server"; import { action } from "~/routes/builder"; import { useLoaderData } from "@remix-run/react"; import { ClientOnly } from "~/components/ClientOnly"; -import { getJobById } from "~/models/job.server"; +import { getJobById, jobIdFromParams } from "~/models/job.server"; import { getUser } from "~/auth.server"; -import { getAccessToken } from "~/bartender_token.server"; +import { getBartenderTokenByUser } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const jobId = params.id || ""; + const jobId = jobIdFromParams(params); const user = await getUser(request); - const level = await getLevel( - user ? user.roles.map((r) => r.name) : undefined - ); + const level = user.preferredExpertiseLevel; const catalog = await getCatalog(level); - const accessToken = await getAccessToken(request); + const token = await getBartenderTokenByUser(user); // Check that user can see job, otherwise throw 404 - await getJobById(parseInt(jobId), accessToken!); + await getJobById(jobId, token); // return same shape as loader in ~/routes/builder.tsx return { catalog, submitAllowed: isSubmitAllowed(level), archive: `/jobs/${jobId}/input.zip`, - job_id: jobId, + jobId, }; }; @@ -35,13 +33,13 @@ export { action }; export const links = () => [...haddock3Styles()]; export default function EditPage() { - const { job_id } = useLoaderData(); + const { jobId } = useLoaderData(); // TODO replace ClientOnly with Suspense, // see https://github.com/sergiodxa/remix-utils#clientonly return (

    - Editing input of job {job_id} + Editing input of job {jobId}

    Loading...

    }> {() => } diff --git a/app/routes/jobs/$id.tsx b/app/routes/jobs/$id.tsx index 66941cef..51d22a4a 100644 --- a/app/routes/jobs/$id.tsx +++ b/app/routes/jobs/$id.tsx @@ -1,29 +1,28 @@ import { json, type LoaderArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; -import { getAccessToken } from "~/bartender_token.server"; +import { getBartenderToken } from "~/bartender_token.server"; import { listOutputFiles, getJobById, listInputFiles, + jobIdFromParams, } from "~/models/job.server"; import { CompletedJobs } from "~/utils"; -import { checkAuthenticated } from "~/models/user.server"; import type { DirectoryItem } from "~/bartender-client"; import { ListLogFiles } from "~/components/ListLogFiles"; import { OutputReport } from "~/components/OutputReport"; import { ListFiles } from "~/components/ListFiles"; export const loader = async ({ params, request }: LoaderArgs) => { - const jobId = parseInt(params.id || ""); - const accessToken = await getAccessToken(request); - checkAuthenticated(accessToken); - const job = await getJobById(jobId, accessToken!); + const jobId = jobIdFromParams(params); + const token = await getBartenderToken(request); + const job = await getJobById(jobId, token!); // TODO check if job belongs to user let inputFiles: DirectoryItem | undefined = undefined; let outputFiles: DirectoryItem | undefined = undefined; if (CompletedJobs.has(job.state)) { - inputFiles = await listInputFiles(jobId, accessToken!); - outputFiles = await listOutputFiles(jobId, accessToken!); + inputFiles = await listInputFiles(jobId, token!); + outputFiles = await listOutputFiles(jobId, token!); } return json({ job, inputFiles, outputFiles }); }; diff --git a/app/routes/jobs/$id/[input.zip].tsx b/app/routes/jobs/$id/[input.zip].tsx index bcfe84b4..1cfd5b49 100644 --- a/app/routes/jobs/$id/[input.zip].tsx +++ b/app/routes/jobs/$id/[input.zip].tsx @@ -1,12 +1,9 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getInputArchive } from "~/models/job.server"; -import { getAccessToken } from "~/bartender_token.server"; +import { getInputArchive, jobIdFromParams } from "~/models/job.server"; +import { getBartenderToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const job_id = params.id || ""; - const access_token = await getAccessToken(request); - if (access_token === undefined) { - throw new Error("Unauthenticated"); - } - return await getInputArchive(parseInt(job_id), access_token); + const id = jobIdFromParams(params); + const token = await getBartenderToken(request); + return await getInputArchive(id, token); }; diff --git a/app/routes/jobs/$id/[output.zip].tsx b/app/routes/jobs/$id/[output.zip].tsx index da39ea79..2b26fe0b 100644 --- a/app/routes/jobs/$id/[output.zip].tsx +++ b/app/routes/jobs/$id/[output.zip].tsx @@ -1,12 +1,9 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getOutputArchive } from "~/models/job.server"; -import { getAccessToken } from "~/bartender_token.server"; +import { getOutputArchive, jobIdFromParams } from "~/models/job.server"; +import { getBartenderToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const job_id = params.id || ""; - const access_token = await getAccessToken(request); - if (access_token === undefined) { - throw new Error("Unauthenticated"); - } - return await getOutputArchive(parseInt(job_id), access_token); + const id = jobIdFromParams(params); + const token = await getBartenderToken(request); + return await getOutputArchive(id, token); }; diff --git a/app/routes/jobs/$id/archive/$.ts b/app/routes/jobs/$id/archive/$.ts index acf7ea66..7cdddfdc 100644 --- a/app/routes/jobs/$id/archive/$.ts +++ b/app/routes/jobs/$id/archive/$.ts @@ -1,14 +1,10 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/bartender_token.server"; -import { getSubDirectoryAsArchive } from "~/models/job.server"; +import { getBartenderToken } from "~/bartender_token.server"; +import { getSubDirectoryAsArchive, jobIdFromParams } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const job_id = params.id || ""; + const id = jobIdFromParams(params); const path = params["*"] || ""; - const accessToken = await getAccessToken(request); - if (accessToken === undefined) { - throw new Error("Unauthenticated"); - } - - return await getSubDirectoryAsArchive(parseInt(job_id), path, accessToken); + const token = await getBartenderToken(request); + return await getSubDirectoryAsArchive(id, path, token); }; diff --git a/app/routes/jobs/$id/files/$.ts b/app/routes/jobs/$id/files/$.ts index af1375fa..ab5eec38 100644 --- a/app/routes/jobs/$id/files/$.ts +++ b/app/routes/jobs/$id/files/$.ts @@ -1,13 +1,10 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/bartender_token.server"; -import { getJobfile } from "~/models/job.server"; +import { getBartenderToken } from "~/bartender_token.server"; +import { getJobfile, jobIdFromParams } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const job_id = params.id || ""; + const id = jobIdFromParams(params); const path = params["*"] || ""; - const accessToken = await getAccessToken(request); - if (accessToken === undefined) { - throw new Error("Unauthenticated"); - } - return await getJobfile(parseInt(job_id), path, accessToken); + const token = await getBartenderToken(request); + return await getJobfile(id, path, token); }; diff --git a/app/routes/jobs/$id/stderr.tsx b/app/routes/jobs/$id/stderr.tsx index 529842ea..49c35eae 100644 --- a/app/routes/jobs/$id/stderr.tsx +++ b/app/routes/jobs/$id/stderr.tsx @@ -1,12 +1,9 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/bartender_token.server"; -import { getJobStderr } from "~/models/job.server"; +import { getBartenderToken } from "~/bartender_token.server"; +import { getJobStderr, jobIdFromParams } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const job_id = params.id || ""; - const access_token = await getAccessToken(request); - if (access_token === undefined) { - throw new Error("Unauthenticated"); - } - return await getJobStderr(parseInt(job_id), access_token); + const id = jobIdFromParams(params); + const token = await getBartenderToken(request); + return await getJobStderr(id, token); }; diff --git a/app/routes/jobs/$id/stdout.tsx b/app/routes/jobs/$id/stdout.tsx index 106aee1a..67185ef2 100644 --- a/app/routes/jobs/$id/stdout.tsx +++ b/app/routes/jobs/$id/stdout.tsx @@ -1,12 +1,9 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getAccessToken } from "~/bartender_token.server"; -import { getJobStdout } from "~/models/job.server"; +import { getBartenderToken } from "~/bartender_token.server"; +import { getJobStdout, jobIdFromParams } from "~/models/job.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const job_id = params.id || ""; - const access_token = await getAccessToken(request); - if (access_token === undefined) { - throw new Error("Unauthenticated"); - } - return await getJobStdout(parseInt(job_id), access_token); + const id = jobIdFromParams(params); + const token = await getBartenderToken(request); + return await getJobStdout(id, token); }; diff --git a/app/routes/jobs/$id/zip.tsx b/app/routes/jobs/$id/zip.tsx index adc126ad..bdd0e1bf 100644 --- a/app/routes/jobs/$id/zip.tsx +++ b/app/routes/jobs/$id/zip.tsx @@ -1,12 +1,9 @@ import { type LoaderArgs } from "@remix-run/node"; -import { getArchive } from "~/models/job.server"; -import { getAccessToken } from "~/bartender_token.server"; +import { getArchive, jobIdFromParams } from "~/models/job.server"; +import { getBartenderToken } from "~/bartender_token.server"; export const loader = async ({ params, request }: LoaderArgs) => { - const job_id = params.id || ""; - const access_token = await getAccessToken(request); - if (access_token === undefined) { - throw new Error("Unauthenticated"); - } - return await getArchive(parseInt(job_id), access_token); + const id = jobIdFromParams(params); + const token = await getBartenderToken(request); + return await getArchive(id, token); }; diff --git a/app/routes/jobs/index.tsx b/app/routes/jobs/index.tsx index 9aaff0e8..46ec65e9 100644 --- a/app/routes/jobs/index.tsx +++ b/app/routes/jobs/index.tsx @@ -1,19 +1,17 @@ import { json, type LoaderArgs } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; -import { getAccessToken } from "~/bartender_token.server"; +import { getBartenderToken } from "~/bartender_token.server"; import { getJobs } from "~/models/job.server"; export const loader = async ({ request }: LoaderArgs) => { - const access_token = await getAccessToken(request); - if (access_token === undefined) { - throw new Error("Unauthenticated"); - } - const jobs = await getJobs(access_token); + const token = await getBartenderToken(request); + const jobs = await getJobs(token); return json({ jobs }); }; export default function JobPage() { const { jobs } = useLoaderData(); + // TODO add pagination return (
    EmailRolesAdministrator?Expertise levelsActions
    diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 61a868ec..1218694b 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -6,10 +6,10 @@ import { } from "@remix-run/node"; import { Form, Link, useLoaderData } from "@remix-run/react"; import { availableSocialLogins } from "~/auth"; -import { authenticator, getUser } from "~/auth.server"; +import { authenticator, getOptionalUser } from "~/auth.server"; export async function loader({ request }: LoaderArgs) { - const user = await getUser(request); + const user = await getOptionalUser(request); if (user) { return redirect("/"); } diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index cdbd114c..c7e15ed9 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -1,33 +1,74 @@ +import type { ActionArgs } from "@remix-run/node"; import { json, type LoaderArgs } from "@remix-run/node"; -import { Link } from "@remix-run/react"; -import { authenticator } from "~/auth.server"; +import { Form, Link, useSubmit } from "@remix-run/react"; +import { mustBeAuthenticated } from "~/auth.server"; import { useUser } from "~/auth"; +import { setPreferredExpertiseLevel } from "~/models/user.server"; export const loader = async ({ request }: LoaderArgs) => { - await authenticator.isAuthenticated(request, { - failureRedirect: "/login", - }); + await mustBeAuthenticated(request); return json({}); }; -export default function JobPage() { +export const action = async ({ request }: ActionArgs) => { + const userId = await mustBeAuthenticated(request); + const formData = await request.formData(); + const preferredExpertiseLevel = formData.get("preferredExpertiseLevel"); + if ( + preferredExpertiseLevel !== null && + typeof preferredExpertiseLevel === "string" + ) { + await setPreferredExpertiseLevel(userId, preferredExpertiseLevel); + } + return null; +}; + +export default function Page() { const user = useUser(); + const submit = useSubmit(); + const handleChangePreferredExpertiseLevel = ( + event: React.ChangeEvent + ) => { + submit(event.currentTarget); + }; return (

    Email: {user.email}

    -

    - Roles:  - {user.roles.length ? ( -

      - {user.roles.map((role) => ( -
    • {role.name}
    • - ))} -
    +
    + Expertise levels + {user.expertiseLevels.length ? ( +
    +
      + {user.expertiseLevels.map((level) => ( +
    • + +
    • + ))} +
    + ) : ( - None + + None assigned. If you just registered wait for administrator to + assign a level to you. If you still don't have a level please + context administrator. + )} -

    - +
    + + {/* TODO add change password form if user is not authenticated with a social login */} + Logout
    diff --git a/app/routes/register.tsx b/app/routes/register.tsx index dcf1278b..d26cdb9e 100644 --- a/app/routes/register.tsx +++ b/app/routes/register.tsx @@ -10,7 +10,9 @@ import { register } from "~/models/user.server"; import { commitSession, getSession } from "~/session.server"; export async function loader({ request }: LoaderArgs) { - // TODO check already logged in + await authenticator.isAuthenticated(request, { + successRedirect: "/", + }); return json({}); } diff --git a/app/routes/upload.tsx b/app/routes/upload.tsx index 7d0eef62..31d1d757 100644 --- a/app/routes/upload.tsx +++ b/app/routes/upload.tsx @@ -7,24 +7,13 @@ import { import { Form } from "@remix-run/react"; import { submitJob } from "~/models/applicaton.server"; -import { getLevel, isSubmitAllowed } from "~/models/user.server"; import { WORKFLOW_CONFIG_FILENAME } from "~/models/constants"; -import { getUser } from "~/auth.server"; -import { getAccessToken } from "~/bartender_token.server"; +import { mustBeAllowedToSubmit } from "~/auth.server"; +import { getBartenderToken } from "~/bartender_token.server"; export const loader = async ({ request }: LoaderArgs) => { - const user = await getUser(request); - if (!user) { - return redirect("/login"); - } - // TODO get roles of current user - const level = await getLevel( - user ? user.roles.map((r) => r.name) : undefined - ); - if (!isSubmitAllowed(level)) { - throw new Error("Forbidden"); - } - return json({ level }); + await mustBeAllowedToSubmit(request); + return json({}); }; export const action = async ({ request }: ActionArgs) => { @@ -35,8 +24,8 @@ export const action = async ({ request }: ActionArgs) => { throw new Error("Bad upload"); } - const accessToken = await getAccessToken(request); - const job = await submitJob(upload, accessToken!); + const token = await getBartenderToken(request); + const job = await submitJob(upload, token!); const job_url = `/jobs/${job.id}`; return redirect(job_url); }; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9186543e..91591a39 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,10 +18,11 @@ model User { // User can have no password if they use OAuth passwordHash String? isAdmin Boolean @default(false) - bartenderToken String? - bartenderTokenExpiresAt DateTime? + bartenderToken String @default("") + bartenderTokenExpiresAt Int @default(0) expertiseLevels ExpertiseLevel[] - preferredExpertiseLevel String? + // TODO preferred level should be one of the expertise levels of the user + preferredExpertiseLevel String @default("") } model ExpertiseLevel { From 4b7f6f2df57b2b117d01d8fe5d007e6b61c6bd20 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Fri, 28 Jul 2023 14:18:17 +0200 Subject: [PATCH 12/45] Use verified email from GH account --- app/auth.server.ts | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/app/auth.server.ts b/app/auth.server.ts index 409a0ace..6fd90000 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -1,5 +1,8 @@ import { Authenticator } from "remix-auth"; -import { GitHubStrategy } from "remix-auth-github"; +import { + type GitHubEmails, + GitHubStrategy, +} from "remix-auth-github"; import { FormStrategy } from "remix-auth-form"; import { json } from "@remix-run/node"; @@ -38,21 +41,52 @@ authenticator.use( "user-pass" ); +/** + * The super class GitHubStrategy returns emails that are not verified. + * This subclass filters out unverified emails. + */ +class GitHubStrategyWithVerifiedEmail extends GitHubStrategy { + // From https://github.com/sergiodxa/remix-auth-github/blob/75cedd281b58523c5d3db5f7bbe92218cb733c46/src/index.ts#L197 + protected async userEmails(accessToken: string): Promise { + // url & agent are private to super class so we have to copy them here + const userEmailsURL = "https://api.github.com/user/emails"; + const userAgent = "Haddock3WebApp"; + let response = await fetch(userEmailsURL, { + headers: { + Accept: "application/vnd.github.v3+json", + Authorization: `token ${accessToken}`, + "User-Agent": userAgent, + }, + }); + + let data: { + email: string; + verified: boolean; + primary: boolean; + visibility: string; + }[] = await response.json(); + let emails: GitHubEmails = data + .filter((e) => e.verified) + .map(({ email }) => ({ value: email })); + return emails; + } +} + if ( process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID && process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET ) { - let gitHubStrategy = new GitHubStrategy( + let gitHubStrategy = new GitHubStrategyWithVerifiedEmail( { clientID: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID, clientSecret: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET!, callbackURL: process.env.HADDOCK3WEBAPP_GITHUB_CALLBACK_URL || "http://localhost:3000/auth/github/callback", + userAgent: "Haddock3WebApp", }, async ({ profile }) => { // TODO store photo or avatar so it can be displayed in NavBar - // TODO fetch verified email not just first email const primaryEmail = profile.emails[0].value; const userId = await oauthregister(primaryEmail); return userId; From a76ac009c0ae106f3040c21c944b3b8456d9fc02 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Fri, 28 Jul 2023 15:34:56 +0200 Subject: [PATCH 13/45] Added untested EGI and Orcid auth strategies --- README.md | 8 +++ app/auth.server.ts | 153 ++++++++++++++++++++++++++++++++++++++++++++- package-lock.json | 22 +++++-- package.json | 4 +- 4 files changed, 180 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1c04a67d..4bff7ca0 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,14 @@ To enable GitHub or Orcid or EGI Check-in login the web apps needs following env HADDOCK3WEBAPP_GITHUB_CLIENT_ID=... HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=... HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback +HADDOCK3WEBAPP_ORCID_CLIENT_ID=... +HADDOCK3WEBAPP_ORCID_CLIENT_SECRET=... +HADDOCK3WEBAPP_ORCID_CALLBACK_URL=http://localhost:3000/auth/orcid/callback +HADDOCK3WEBAPP_ORCID_SANDBOX='something' # When env var is set then sandbox is used instead of production +HADDOCK3WEBAPP_EGI_CLIENT_ID=... +HADDOCK3WEBAPP_EGI_CLIENT_SECRET=... +HADDOCK3WEBAPP_EGI_CALLBACK_URL=http://localhost:3000/auth/egi/callback +HADDOCK3WEBAPP_EGI_ENVIRONMENT=production # could also be 'development' or 'demo' ``` The environment variables can also be stored in a `.env` file. diff --git a/app/auth.server.ts b/app/auth.server.ts index 6fd90000..e3623334 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -1,4 +1,4 @@ -import { Authenticator } from "remix-auth"; +import { Authenticator, StrategyVerifyCallback } from "remix-auth"; import { type GitHubEmails, GitHubStrategy, @@ -14,6 +14,8 @@ import { localLogin, oauthregister, } from "./models/user.server"; +import { OAuth2Profile, OAuth2Strategy, OAuth2StrategyVerifyParams } from "remix-auth-oauth2"; +import { KeycloakExtraParams, KeycloakProfile, KeycloakStrategy } from "remix-auth-keycloak"; // Create an instance of the authenticator, pass a generic with what // strategies will return and will store in the session @@ -76,7 +78,7 @@ if ( process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID && process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET ) { - let gitHubStrategy = new GitHubStrategyWithVerifiedEmail( + const gitHubStrategy = new GitHubStrategyWithVerifiedEmail( { clientID: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID, clientSecret: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET!, @@ -96,6 +98,153 @@ if ( authenticator.use(gitHubStrategy); } +if ( + process.env.HADDOCK3WEBAPP_ORCID_CLIENT_ID && + process.env.HADDOCK3WEBAPP_ORCID_CLIENT_SECRET +) { + + + interface OrcidOptions { + clientID: string; + clientSecret: string; + callbackURL: string; + isSandBox: boolean; + } + + class OrcidStrategy extends OAuth2Strategy { + name = 'orcid' + private profileEndpoint: string; + private emailsEndpoint: string; + constructor( + options: OrcidOptions, + verify: StrategyVerifyCallback< + User, + OAuth2StrategyVerifyParams + > + ) { + const domain = options.isSandBox ? "sandbox.oexid.org" : "orcid.org" + const AUTHORIZE_ENDPOINT = `https://${domain}/oauth/authorize` + const ACCESS_TOKEN_ENDPOINT = `https://${domain}/oauth/token` + const PROFILE_ENDPOINT = `https://${domain}/oauth/userinfo` + const EMAILS_ENDPOINT = `https://pub.${domain}/v3.0/{id}/email` + super({ + clientID: options.clientID, + clientSecret: options.clientSecret, + callbackURL: options.callbackURL, + authorizationURL: AUTHORIZE_ENDPOINT, + tokenURL: ACCESS_TOKEN_ENDPOINT, + + }, verify); + this.profileEndpoint = PROFILE_ENDPOINT; + this.emailsEndpoint = EMAILS_ENDPOINT; + } + + protected authorizationParams() { + return new URLSearchParams({ + scope: 'openid', + }); + } + + protected async userEmails(orcid: string) { + const emailsResponse = await fetch( + this.emailsEndpoint.replace('{id}', orcid), + { + headers: { + Accept: "application/orcid+json", + } + } + ) + const emails: { email: { email: string }[] } = await emailsResponse.json() + if (!emails.email) { + throw new Error('No public email found.') + } + return emails.email.map(e => ({ value: e.email })) + } + + protected async userProfile(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + } + const profileResponse = await fetch(this.profileEndpoint, { headers }) + const profile = await profileResponse.json() + const emails = await this.userEmails(profile.sub) + return { + ...profile, + emails + } + } + } + + const orcidStrategy = new OrcidStrategy({ + clientID: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_ID, + clientSecret: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_SECRET!, + callbackURL: process.env.HADDOCK3WEBAPP_ORCID_CALLBACK_URL || "http://localhost:3000/auth/orcid/callback", + isSandBox: !!process.env.HADDOCK3WEBAPP_ORCID_SANDBOX, + }, async ({ profile }) => { + const primaryEmail = profile.emails![0].value; + const userId = await oauthregister(primaryEmail); + return userId; + }) + + authenticator.use(orcidStrategy); +} + +if ( + process.env.HADDOCK3WEBAPP_EGI_CLIENT_ID && + process.env.HADDOCK3WEBAPP_EGI_CLIENT_SECRET +) { + interface EgiOptions { + clientID: string; + clientSecret: string; + callbackURL: string; + environment: 'production' | 'development' | 'demo' + } + + class EgiStrategy extends KeycloakStrategy { + name = 'egi' + + constructor( + options: EgiOptions, + verify: StrategyVerifyCallback< + User, + OAuth2StrategyVerifyParams + > + ) { + + const domain = { + production: 'aai.egi.eu', + development: 'aai-dev.egi.eu', + demo: 'aai-demoegi.eu', + }[options.environment] + super({ + clientID: options.clientID, + clientSecret: options.clientSecret, + callbackURL: options.callbackURL, + domain, + realm: 'egi', + useSSL: true, + }, verify); + } + } + + const egiStrategy = new EgiStrategy( + { + clientID: process.env.HADDOCK3WEBAPP_EGI_CLIENT_ID, + clientSecret: process.env.HADDOCK3WEBAPP_EGI_CLIENT_SECRET!, + callbackURL: process.env.HADDOCK3WEBAPP_EGI_CALLBACK_URL || "http://localhost:3000/auth/egi/callback", + environment: (process.env.HADDOCK3WEBAPP_EGI_ENVIRONMENT as 'development' | 'production' | 'demo') || 'production', + } + , async ({ profile }) => { + const primaryEmail = profile.emails![0].value; + const userId = await oauthregister(primaryEmail); + return userId; + } + ) + + authenticator.use(egiStrategy) +} + + export async function mustBeAuthenticated(request: Request) { const userId = await authenticator.isAuthenticated(request); if (userId === null) { diff --git a/package-lock.json b/package-lock.json index 58810f9b..6d777c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,9 @@ "react-dom": "^18.2.0", "remix-auth": "^3.4.0", "remix-auth-form": "^1.3.0", - "remix-auth-github": "^1.4.0" + "remix-auth-github": "^1.4.0", + "remix-auth-keycloak": "^1.2.0", + "remix-auth-oauth2": "^1.8.0" }, "devDependencies": { "@ltd/j-toml": "^1.38.0", @@ -14007,10 +14009,22 @@ "remix-auth": "^3.4.0" } }, + "node_modules/remix-auth-keycloak": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/remix-auth-keycloak/-/remix-auth-keycloak-1.2.0.tgz", + "integrity": "sha512-zmv0EZbIejmZBW+456gheo0e8kIKGn2aUahNDZOgtwqGwbswHTmkAfCvzxXW5OkwEXF5PxAlE6igBu5yNYUWug==", + "dependencies": { + "remix-auth": "^3.2.2", + "remix-auth-oauth2": "^1.2.2" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0" + } + }, "node_modules/remix-auth-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/remix-auth-oauth2/-/remix-auth-oauth2-1.7.0.tgz", - "integrity": "sha512-hgSJWz1j13Zf1tcNdSs0OFXikDda24md+E93xtQQKIuuIUZ7YtlujT/pnXeqTjJ0f8DwBrzGyPGNF/lugxT6WA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/remix-auth-oauth2/-/remix-auth-oauth2-1.8.0.tgz", + "integrity": "sha512-39C9bsc5vdHKRhlTCESzNwi5GMwd8p99ypqzZUEN/Kws/GO3o98qBhcSjPbmrZk5jlzXKx8RJU+ygS3d5Anl4A==", "dependencies": { "debug": "^4.3.4", "remix-auth": "^3.4.0", diff --git a/package.json b/package.json index 173b9039..fd78b0c0 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "react-dom": "^18.2.0", "remix-auth": "^3.4.0", "remix-auth-form": "^1.3.0", - "remix-auth-github": "^1.4.0" + "remix-auth-github": "^1.4.0", + "remix-auth-keycloak": "^1.2.0", + "remix-auth-oauth2": "^1.8.0" }, "devDependencies": { "@ltd/j-toml": "^1.38.0", From 3c47a569bd5356b742511011ac558637ff38d78f Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Fri, 28 Jul 2023 15:37:59 +0200 Subject: [PATCH 14/45] Make offering to the lint gods --- app/auth.server.ts | 154 +++++++++++++++++++++++++-------------------- 1 file changed, 86 insertions(+), 68 deletions(-) diff --git a/app/auth.server.ts b/app/auth.server.ts index e3623334..877f5da9 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -1,9 +1,16 @@ -import { Authenticator, StrategyVerifyCallback } from "remix-auth"; -import { - type GitHubEmails, - GitHubStrategy, -} from "remix-auth-github"; +import { Authenticator, type StrategyVerifyCallback } from "remix-auth"; +import { type GitHubEmails, GitHubStrategy } from "remix-auth-github"; import { FormStrategy } from "remix-auth-form"; +import { + type OAuth2Profile, + OAuth2Strategy, + type OAuth2StrategyVerifyParams, +} from "remix-auth-oauth2"; +import { + type KeycloakExtraParams, + KeycloakStrategy, + type KeycloakProfile, +} from "remix-auth-keycloak"; import { json } from "@remix-run/node"; import { sessionStorage } from "./session.server"; @@ -14,8 +21,6 @@ import { localLogin, oauthregister, } from "./models/user.server"; -import { OAuth2Profile, OAuth2Strategy, OAuth2StrategyVerifyParams } from "remix-auth-oauth2"; -import { KeycloakExtraParams, KeycloakProfile, KeycloakStrategy } from "remix-auth-keycloak"; // Create an instance of the authenticator, pass a generic with what // strategies will return and will store in the session @@ -102,8 +107,6 @@ if ( process.env.HADDOCK3WEBAPP_ORCID_CLIENT_ID && process.env.HADDOCK3WEBAPP_ORCID_CLIENT_SECRET ) { - - interface OrcidOptions { clientID: string; clientSecret: string; @@ -112,7 +115,7 @@ if ( } class OrcidStrategy extends OAuth2Strategy { - name = 'orcid' + name = "orcid"; private profileEndpoint: string; private emailsEndpoint: string; constructor( @@ -122,69 +125,77 @@ if ( OAuth2StrategyVerifyParams > ) { - const domain = options.isSandBox ? "sandbox.oexid.org" : "orcid.org" - const AUTHORIZE_ENDPOINT = `https://${domain}/oauth/authorize` - const ACCESS_TOKEN_ENDPOINT = `https://${domain}/oauth/token` - const PROFILE_ENDPOINT = `https://${domain}/oauth/userinfo` - const EMAILS_ENDPOINT = `https://pub.${domain}/v3.0/{id}/email` - super({ - clientID: options.clientID, - clientSecret: options.clientSecret, - callbackURL: options.callbackURL, - authorizationURL: AUTHORIZE_ENDPOINT, - tokenURL: ACCESS_TOKEN_ENDPOINT, - - }, verify); + const domain = options.isSandBox ? "sandbox.oexid.org" : "orcid.org"; + const AUTHORIZE_ENDPOINT = `https://${domain}/oauth/authorize`; + const ACCESS_TOKEN_ENDPOINT = `https://${domain}/oauth/token`; + const PROFILE_ENDPOINT = `https://${domain}/oauth/userinfo`; + const EMAILS_ENDPOINT = `https://pub.${domain}/v3.0/{id}/email`; + super( + { + clientID: options.clientID, + clientSecret: options.clientSecret, + callbackURL: options.callbackURL, + authorizationURL: AUTHORIZE_ENDPOINT, + tokenURL: ACCESS_TOKEN_ENDPOINT, + }, + verify + ); this.profileEndpoint = PROFILE_ENDPOINT; this.emailsEndpoint = EMAILS_ENDPOINT; } protected authorizationParams() { return new URLSearchParams({ - scope: 'openid', + scope: "openid", }); } protected async userEmails(orcid: string) { const emailsResponse = await fetch( - this.emailsEndpoint.replace('{id}', orcid), + this.emailsEndpoint.replace("{id}", orcid), { headers: { Accept: "application/orcid+json", - } + }, } - ) - const emails: { email: { email: string }[] } = await emailsResponse.json() + ); + const emails: { email: { email: string }[] } = + await emailsResponse.json(); if (!emails.email) { - throw new Error('No public email found.') + throw new Error("No public email found."); } - return emails.email.map(e => ({ value: e.email })) + return emails.email.map((e) => ({ value: e.email })); } protected async userProfile(accessToken: string): Promise { const headers = { Authorization: `Bearer ${accessToken}`, - } - const profileResponse = await fetch(this.profileEndpoint, { headers }) - const profile = await profileResponse.json() - const emails = await this.userEmails(profile.sub) + }; + const profileResponse = await fetch(this.profileEndpoint, { headers }); + const profile = await profileResponse.json(); + const emails = await this.userEmails(profile.sub); return { ...profile, - emails - } + emails, + }; } } - const orcidStrategy = new OrcidStrategy({ - clientID: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_ID, - clientSecret: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_SECRET!, - callbackURL: process.env.HADDOCK3WEBAPP_ORCID_CALLBACK_URL || "http://localhost:3000/auth/orcid/callback", - isSandBox: !!process.env.HADDOCK3WEBAPP_ORCID_SANDBOX, - }, async ({ profile }) => { - const primaryEmail = profile.emails![0].value; - const userId = await oauthregister(primaryEmail); - return userId; - }) + const orcidStrategy = new OrcidStrategy( + { + clientID: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_ID, + clientSecret: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_SECRET!, + callbackURL: + process.env.HADDOCK3WEBAPP_ORCID_CALLBACK_URL || + "http://localhost:3000/auth/orcid/callback", + isSandBox: !!process.env.HADDOCK3WEBAPP_ORCID_SANDBOX, + }, + async ({ profile }) => { + const primaryEmail = profile.emails![0].value; + const userId = await oauthregister(primaryEmail); + return userId; + } + ); authenticator.use(orcidStrategy); } @@ -197,11 +208,11 @@ if ( clientID: string; clientSecret: string; callbackURL: string; - environment: 'production' | 'development' | 'demo' + environment: "production" | "development" | "demo"; } class EgiStrategy extends KeycloakStrategy { - name = 'egi' + name = "egi"; constructor( options: EgiOptions, @@ -210,20 +221,22 @@ if ( OAuth2StrategyVerifyParams > ) { - const domain = { - production: 'aai.egi.eu', - development: 'aai-dev.egi.eu', - demo: 'aai-demoegi.eu', - }[options.environment] - super({ - clientID: options.clientID, - clientSecret: options.clientSecret, - callbackURL: options.callbackURL, - domain, - realm: 'egi', - useSSL: true, - }, verify); + production: "aai.egi.eu", + development: "aai-dev.egi.eu", + demo: "aai-demoegi.eu", + }[options.environment]; + super( + { + clientID: options.clientID, + clientSecret: options.clientSecret, + callbackURL: options.callbackURL, + domain, + realm: "egi", + useSSL: true, + }, + verify + ); } } @@ -231,20 +244,25 @@ if ( { clientID: process.env.HADDOCK3WEBAPP_EGI_CLIENT_ID, clientSecret: process.env.HADDOCK3WEBAPP_EGI_CLIENT_SECRET!, - callbackURL: process.env.HADDOCK3WEBAPP_EGI_CALLBACK_URL || "http://localhost:3000/auth/egi/callback", - environment: (process.env.HADDOCK3WEBAPP_EGI_ENVIRONMENT as 'development' | 'production' | 'demo') || 'production', - } - , async ({ profile }) => { + callbackURL: + process.env.HADDOCK3WEBAPP_EGI_CALLBACK_URL || + "http://localhost:3000/auth/egi/callback", + environment: + (process.env.HADDOCK3WEBAPP_EGI_ENVIRONMENT as + | "development" + | "production" + | "demo") || "production", + }, + async ({ profile }) => { const primaryEmail = profile.emails![0].value; const userId = await oauthregister(primaryEmail); return userId; } - ) + ); - authenticator.use(egiStrategy) + authenticator.use(egiStrategy); } - export async function mustBeAuthenticated(request: Request) { const userId = await authenticator.isAuthenticated(request); if (userId === null) { From 840897257126039ba56066cb9bc8ed4dda20823a Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 7 Aug 2023 10:12:35 +0200 Subject: [PATCH 15/45] Move social login docs from bartender to here --- README.md | 32 +------- TODO.md | 4 +- app/models/user.server.ts | 17 ---- docs/auth.md | 158 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 50 deletions(-) create mode 100644 docs/auth.md diff --git a/README.md b/README.md index 4bff7ca0..4cda34ab 100644 --- a/README.md +++ b/README.md @@ -125,37 +125,7 @@ Web application running at http://localhost:8080 . ## Authentication & authorization -A user can only submit jobs when he/she is logged in and has at least one expertise level. -A super user should assign an expertise level to the user at http://localhost:3000/admin/users. -A super user can be made through the admin page or by being the first registered user. - -The sessions will be encrypted with a secret key from an environment variable. - -```shell -SESSION_SECRET=... -``` - -### Social login - -To enable GitHub or Orcid or EGI Check-in login the web apps needs following environment variables. - -```shell -HADDOCK3WEBAPP_GITHUB_CLIENT_ID=... -HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=... -HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback -HADDOCK3WEBAPP_ORCID_CLIENT_ID=... -HADDOCK3WEBAPP_ORCID_CLIENT_SECRET=... -HADDOCK3WEBAPP_ORCID_CALLBACK_URL=http://localhost:3000/auth/orcid/callback -HADDOCK3WEBAPP_ORCID_SANDBOX='something' # When env var is set then sandbox is used instead of production -HADDOCK3WEBAPP_EGI_CLIENT_ID=... -HADDOCK3WEBAPP_EGI_CLIENT_SECRET=... -HADDOCK3WEBAPP_EGI_CALLBACK_URL=http://localhost:3000/auth/egi/callback -HADDOCK3WEBAPP_EGI_ENVIRONMENT=production # could also be 'development' or 'demo' -``` - -The environment variables can also be stored in a `.env` file. - -Only use social logins where the email address has been verified. +See [docs/auth.md](docs/auth.md). ## Bartender web service client diff --git a/TODO.md b/TODO.md index 3290e3b5..196e71f5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -- [ ] store private key for making bartender tokens +- [x] store private key for making bartender tokens - [x] use remix auth for local users with remix-auth-form - [ ] use remix-auth-oauth2 for - [x] github @@ -6,5 +6,5 @@ - [ ] orcid prod - [ ] egi-checkin - [x] associate local user with oauth account aka mail matching -- [ ] dont generate bartender token every time, but store/fetch from db +- [x] dont generate bartender token every time, but store/fetch from db - [ ] store users in postgresql instead of sqlite diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 909c368e..eeb0021b 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -117,23 +117,6 @@ export async function getUserByEmail(email: string) { return user; } -export async function getLevel( - userRoles: string[] | undefined -): Promise { - if (!userRoles) { - return ""; - } - const roles = new Set(userRoles); - if (roles.has("guru")) { - return "guru"; - } else if (roles.has("expert")) { - return "expert"; - } else if (roles.has("easy")) { - return "easy"; - } - return ""; -} - export function isSubmitAllowed(level: string) { return level !== ""; } diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 00000000..49aa81d5 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,158 @@ +# Authentication & authorization + +A user can only submit jobs when he/she is logged in and has at least one expertise level. +A super user can assign an expertise level to users at http://localhost:3000/admin/users. +A super user can be made through the admin page or by being the first registered user. + +The sessions will be encrypted with a secret key from an environment variable. + +```shell +SESSION_SECRET=... +``` + +The environment variables can also be stored in a `.env` file. + +Use `.env.example` as a template: + +```shell +cp .env.example .env +``` + +To enable GitHub or Orcid or EGI Check-in login the web apps needs following environment variables. + +```shell +HADDOCK3WEBAPP_GITHUB_CLIENT_ID=... +HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=... +HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback +HADDOCK3WEBAPP_ORCID_CLIENT_ID=... +HADDOCK3WEBAPP_ORCID_CLIENT_SECRET=... +HADDOCK3WEBAPP_ORCID_CALLBACK_URL=http://localhost:3000/auth/orcid/callback +HADDOCK3WEBAPP_ORCID_SANDBOX='something' # When env var is set then sandbox is used instead of production +HADDOCK3WEBAPP_EGI_CLIENT_ID=... +HADDOCK3WEBAPP_EGI_CLIENT_SECRET=... +HADDOCK3WEBAPP_EGI_CALLBACK_URL=http://localhost:3000/auth/egi/callback +HADDOCK3WEBAPP_EGI_ENVIRONMENT=production # could also be 'development' or 'demo' +``` + +Only use social logins where the email address has been verified. + +### GitHub + +The web app can be configured to login with your +[GitHub](https://gibhub.com) account. + +To enable perform following steps: + +1. Create a GitHub app + + 1. Goto + 2. Set Homepage URL to `http://localhost:8000/` + 3. Set Callback URL to `http://localhost:8000/auth/github/callback` + 4. Check `Request user authorization (OAuth) during installation` + 5. In Webhook section + + * Uncheck `Active` + + 6. In User permissions section + + * Set `Email addresses` to `Read-only` + + 7. Press `Create GitHub App` button + 8. After creation + + * Generate a new client secret + * (Optionally) Restrict app to certain IP addresses + +2. Append GitHub app credentials to `.env` file + + 1. Add `HADDOCK3WEBAPP_GITHUB_CLIENT_ID=` + 2. Add `HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=` + 3. (Optionally) Add external URL of app + `HADDOCK3WEBAPP_GITHUB_REDIRECT_URL=` + +## Orcid sandbox login + +The web app can be configured to login with your [Orcid +sandbox](https://sandbox.orcid.org/) account. + +To enable perform following steps: + +1. Create Orcid account for yourself + + 1. Go to [https://sandbox.orcid.org/](https://sandbox.orcid.org/) + + Use `@mailinator.com` as email, because to register app you + need a verified email and Orcid sandbox only sends mails to + `mailinator.com`. + + 2. Go to + [https://www.mailinator.com/v4/public/inboxes.jsp](https://www.mailinator.com/v4/public/inboxes.jsp) + + Search for `` and verify your email address + + 3. Go to [https://sandbox.orcid.org/account](https://sandbox.orcid.org/account) + + Make email public for everyone + +2. Create application + + Goto + [https://sandbox.orcid.org/developer-tools](https://sandbox.orcid.org/developer-tools) + to register app. + + * Only one app can be registered per orcid account, so use alternate account + when primary account already has an registered app. + + * Your website URL does not allow localhost URL, so use + `https://github.com/i-VRESSE/bartended-haddock3` + + * Redirect URI: for dev deployments set to + `http://localhost:8000/auth/orcidsandbox/callback` + +3. Append Orcid sandbox app credentials to `.env` file + + 1. Add `HADDOCK3WEBAPP_ORCIDSANDBOX_CLIENT_ID=` + 2. Add `HADDOCK3WEBAPP_ORCIDSANDBOX_CLIENT_SECRET=` + 3. (Optionally) Add external URL of app + `HADDOCK3WEBAPP_ORCIDSANDBOX_REDIRECT_URL=` + +The `GET /api/users/profile` route will return the Orcid ID in +`oauth_accounts[oauth_name=sandbox.orcid.org].account_id`. + +## Orcid login + +The web app can be configured to login with your [Orcid](https://orcid.org/) +account. + +Steps are similar to [Orcid sandbox login](#orcid-sandbox-login), but + +* Callback URL must use **https** scheme +* Account emails don't have to be have be from `@mailinator.com` domain. +* In steps + + * Replace `https://sandbox.orcid.org/` with `https://orcid.org/` + * In redirect URL replace `orcidsandbox` with `orcid`. + * In `.env` replace `_ORCIDSANDBOX_` with `_ORCID_` + +## EGI Check-in login + +The web app can be configured to login with your [EGI Check-in](https://aai.egi.eu/) +account. + +To enable perform following steps: + +1. This web service needs to be [registered as a service provider in EGI Check-in](https://docs.egi.eu/providers/check-in/sp/). + * Select protocol: OIDC Service + * Callback should end with `/auth/egi/callback` + * Callback should for non-developement environments use https + * Disable PKCE, as the + [Python library](https://github.com/fastapi-users/fastapi-users) + used for authentication does support PKCE +2. Append EGI SP credentials to `.env` file + 1. Add `HADDOCK3WEBAPP_EGI_CLIENT_ID=` + 2. Add `HADDOCK3WEBAPP_EGI_CLIENT_SECRET=` + 3. (Optionally) Add which integration environment the SP is using, + `HADDOCK3WEBAPP_EGI_ENVIRONMENT=` + 4. (Optionally) Add external URL of app + `HADDOCK3WEBAPP_EGI_REDIRECT_URL=` \ No newline at end of file From 5ae665d0417e2e5ee92233c831f9d6f676bc3d7a Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Tue, 8 Aug 2023 10:26:08 +0200 Subject: [PATCH 16/45] Tested orcid sandbox, orcid, egi development social logins --- TODO.md | 8 ++--- app/auth.server.ts | 8 ++--- docs/auth.md | 73 +++++++++++++++++++++++++++++++--------------- 3 files changed, 58 insertions(+), 31 deletions(-) diff --git a/TODO.md b/TODO.md index 196e71f5..c005bd2e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,10 +1,10 @@ - [x] store private key for making bartender tokens - [x] use remix auth for local users with remix-auth-form -- [ ] use remix-auth-oauth2 for +- [x] use remix-auth-oauth2 for - [x] github - - [ ] orcid sandbox - - [ ] orcid prod - - [ ] egi-checkin + - [x] orcid sandbox + - [x] orcid prod + - [x] egi-checkin - [x] associate local user with oauth account aka mail matching - [x] dont generate bartender token every time, but store/fetch from db - [ ] store users in postgresql instead of sqlite diff --git a/app/auth.server.ts b/app/auth.server.ts index 877f5da9..fd61fae7 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -125,7 +125,7 @@ if ( OAuth2StrategyVerifyParams > ) { - const domain = options.isSandBox ? "sandbox.oexid.org" : "orcid.org"; + const domain = options.isSandBox ? "sandbox.orcid.org" : "orcid.org"; const AUTHORIZE_ENDPOINT = `https://${domain}/oauth/authorize`; const ACCESS_TOKEN_ENDPOINT = `https://${domain}/oauth/token`; const PROFILE_ENDPOINT = `https://${domain}/oauth/userinfo`; @@ -222,9 +222,9 @@ if ( > ) { const domain = { - production: "aai.egi.eu", - development: "aai-dev.egi.eu", - demo: "aai-demoegi.eu", + production: "aai.egi.eu/auth", + development: "aai-dev.egi.eu/auth", + demo: "aai-demoegi.eu/auth", }[options.environment]; super( { diff --git a/docs/auth.md b/docs/auth.md index 49aa81d5..4d4666e9 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -1,8 +1,14 @@ # Authentication & authorization +- [Authentication \& authorization](#authentication--authorization) + - [GitHub login](#github-login) + - [Orcid sandbox login](#orcid-sandbox-login) + - [Orcid login](#orcid-login) + - [EGI Check-in login](#egi-check-in-login) + A user can only submit jobs when he/she is logged in and has at least one expertise level. A super user can assign an expertise level to users at http://localhost:3000/admin/users. -A super user can be made through the admin page or by being the first registered user. +A super user can be made through the admin page (`/admin/users`) or by being the first registered user. The sessions will be encrypted with a secret key from an environment variable. @@ -10,15 +16,15 @@ The sessions will be encrypted with a secret key from an environment variable. SESSION_SECRET=... ``` -The environment variables can also be stored in a `.env` file. +The environment variables can be stored in a `.env` file. -Use `.env.example` as a template: +Use [.env.example](../.env.example) as a template: ```shell cp .env.example .env ``` -To enable GitHub or Orcid or EGI Check-in login the web apps needs following environment variables. +To enable GitHub or Orcid or EGI Check-in login the web app needs following environment variables. ```shell HADDOCK3WEBAPP_GITHUB_CLIENT_ID=... @@ -27,7 +33,7 @@ HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback HADDOCK3WEBAPP_ORCID_CLIENT_ID=... HADDOCK3WEBAPP_ORCID_CLIENT_SECRET=... HADDOCK3WEBAPP_ORCID_CALLBACK_URL=http://localhost:3000/auth/orcid/callback -HADDOCK3WEBAPP_ORCID_SANDBOX='something' # When env var is set then sandbox is used instead of production +HADDOCK3WEBAPP_ORCID_SANDBOX=1 # optional, if unset uses Orcid production HADDOCK3WEBAPP_EGI_CLIENT_ID=... HADDOCK3WEBAPP_EGI_CLIENT_SECRET=... HADDOCK3WEBAPP_EGI_CALLBACK_URL=http://localhost:3000/auth/egi/callback @@ -36,7 +42,7 @@ HADDOCK3WEBAPP_EGI_ENVIRONMENT=production # could also be 'development' or 'dem Only use social logins where the email address has been verified. -### GitHub +## GitHub login The web app can be configured to login with your [GitHub](https://gibhub.com) account. @@ -67,8 +73,7 @@ To enable perform following steps: 1. Add `HADDOCK3WEBAPP_GITHUB_CLIENT_ID=` 2. Add `HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET=` - 3. (Optionally) Add external URL of app - `HADDOCK3WEBAPP_GITHUB_REDIRECT_URL=` + 3. (Optionally) Add `HADDOCK3WEBAPP_GITHUB_CALLBACK_URL=`, URL where GitHub should redirect to after login. ## Orcid sandbox login @@ -107,18 +112,17 @@ To enable perform following steps: `https://github.com/i-VRESSE/bartended-haddock3` * Redirect URI: for dev deployments set to - `http://localhost:8000/auth/orcidsandbox/callback` + `http://127.0.0.1:8000/auth/orcid/callback`. 3. Append Orcid sandbox app credentials to `.env` file - 1. Add `HADDOCK3WEBAPP_ORCIDSANDBOX_CLIENT_ID=` - 2. Add `HADDOCK3WEBAPP_ORCIDSANDBOX_CLIENT_SECRET=` - 3. (Optionally) Add external URL of app - `HADDOCK3WEBAPP_ORCIDSANDBOX_REDIRECT_URL=` + 1. Add `HADDOCK3WEBAPP_ORCID_SANDBOX=1` to use Orcid sandbox, if not set then uses Orcid production. + 1. Add `HADDOCK3WEBAPP_ORCID_CLIENT_ID=` + 2. Add `HADDOCK3WEBAPP_ORCID_CLIENT_SECRET=` + 3. Add + `HADDOCK3WEBAPP_ORCID_CALLBACK_URL=http://127.0.0.1:8000/auth/orcid/callback`, URL where Orcid should redirect to after login. -The `GET /api/users/profile` route will return the Orcid ID in -`oauth_accounts[oauth_name=sandbox.orcid.org].account_id`. +Orcid sandbox does not like `localhost`, use `127.0.0.1` as hostname instead. ## Orcid login @@ -127,13 +131,35 @@ account. Steps are similar to [Orcid sandbox login](#orcid-sandbox-login), but +* Unset `HADDOCK3WEBAPP_ORCID_SANDBOX` environment variable * Callback URL must use **https** scheme * Account emails don't have to be have be from `@mailinator.com` domain. -* In steps - * Replace `https://sandbox.orcid.org/` with `https://orcid.org/` - * In redirect URL replace `orcidsandbox` with `orcid`. - * In `.env` replace `_ORCIDSANDBOX_` with `_ORCID_` +To host web app with https use a revserse proxy like [caddyserver](https://caddyserver.com/) + +``` +# Save as file called Caddyfile +{ + http_port 8081 +} + +:8443 + +reverse_proxy 127.0.0.1:3000 + +# If your hostname is not public then use issuer internal, +# otherwise remove tls block. +tls { + issuer internal +} +``` + +```shell +caddy run +``` + +This will make app available on `https://:8443`. +In Orcid site set the redirect URL to `https://:8443/auth/callback/orcid`. ## EGI Check-in login @@ -147,12 +173,13 @@ To enable perform following steps: * Callback should end with `/auth/egi/callback` * Callback should for non-developement environments use https * Disable PKCE, as the - [Python library](https://github.com/fastapi-users/fastapi-users) + [library](https://github.com/sergiodxa/remix-auth-oauth2/issues/24) used for authentication does support PKCE 2. Append EGI SP credentials to `.env` file 1. Add `HADDOCK3WEBAPP_EGI_CLIENT_ID=` 2. Add `HADDOCK3WEBAPP_EGI_CLIENT_SECRET=` 3. (Optionally) Add which integration environment the SP is using, - `HADDOCK3WEBAPP_EGI_ENVIRONMENT=` + `HADDOCK3WEBAPP_EGI_ENVIRONMENT=`, + defaults to `production` 4. (Optionally) Add external URL of app - `HADDOCK3WEBAPP_EGI_REDIRECT_URL=` \ No newline at end of file + `HADDOCK3WEBAPP_EGI_REDIRECT_URL=` From e47528d91aec722e50371158dba49075d00bcbd0 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Tue, 8 Aug 2023 10:37:00 +0200 Subject: [PATCH 17/45] Run formatter --- docs/auth.md | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index 4d4666e9..ffe857f8 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -57,17 +57,17 @@ To enable perform following steps: 4. Check `Request user authorization (OAuth) during installation` 5. In Webhook section - * Uncheck `Active` + - Uncheck `Active` 6. In User permissions section - * Set `Email addresses` to `Read-only` + - Set `Email addresses` to `Read-only` 7. Press `Create GitHub App` button 8. After creation - * Generate a new client secret - * (Optionally) Restrict app to certain IP addresses + - Generate a new client secret + - (Optionally) Restrict app to certain IP addresses 2. Append GitHub app credentials to `.env` file @@ -105,21 +105,21 @@ To enable perform following steps: [https://sandbox.orcid.org/developer-tools](https://sandbox.orcid.org/developer-tools) to register app. - * Only one app can be registered per orcid account, so use alternate account + - Only one app can be registered per orcid account, so use alternate account when primary account already has an registered app. - * Your website URL does not allow localhost URL, so use + - Your website URL does not allow localhost URL, so use `https://github.com/i-VRESSE/bartended-haddock3` - * Redirect URI: for dev deployments set to + - Redirect URI: for dev deployments set to `http://127.0.0.1:8000/auth/orcid/callback`. 3. Append Orcid sandbox app credentials to `.env` file 1. Add `HADDOCK3WEBAPP_ORCID_SANDBOX=1` to use Orcid sandbox, if not set then uses Orcid production. 1. Add `HADDOCK3WEBAPP_ORCID_CLIENT_ID=` - 2. Add `HADDOCK3WEBAPP_ORCID_CLIENT_SECRET=` - 3. Add + 1. Add `HADDOCK3WEBAPP_ORCID_CLIENT_SECRET=` + 1. Add `HADDOCK3WEBAPP_ORCID_CALLBACK_URL=http://127.0.0.1:8000/auth/orcid/callback`, URL where Orcid should redirect to after login. Orcid sandbox does not like `localhost`, use `127.0.0.1` as hostname instead. @@ -131,9 +131,9 @@ account. Steps are similar to [Orcid sandbox login](#orcid-sandbox-login), but -* Unset `HADDOCK3WEBAPP_ORCID_SANDBOX` environment variable -* Callback URL must use **https** scheme -* Account emails don't have to be have be from `@mailinator.com` domain. +- Unset `HADDOCK3WEBAPP_ORCID_SANDBOX` environment variable +- Callback URL must use **https** scheme +- Account emails don't have to be have be from `@mailinator.com` domain. To host web app with https use a revserse proxy like [caddyserver](https://caddyserver.com/) @@ -147,7 +147,7 @@ To host web app with https use a revserse proxy like [caddyserver](https://caddy reverse_proxy 127.0.0.1:3000 -# If your hostname is not public then use issuer internal, +# If your hostname is not public then use issuer internal, # otherwise remove tls block. tls { issuer internal @@ -169,17 +169,17 @@ account. To enable perform following steps: 1. This web service needs to be [registered as a service provider in EGI Check-in](https://docs.egi.eu/providers/check-in/sp/). - * Select protocol: OIDC Service - * Callback should end with `/auth/egi/callback` - * Callback should for non-developement environments use https - * Disable PKCE, as the + - Select protocol: OIDC Service + - Callback should end with `/auth/egi/callback` + - Callback should for non-developement environments use https + - Disable PKCE, as the [library](https://github.com/sergiodxa/remix-auth-oauth2/issues/24) used for authentication does support PKCE 2. Append EGI SP credentials to `.env` file - 1. Add `HADDOCK3WEBAPP_EGI_CLIENT_ID=` - 2. Add `HADDOCK3WEBAPP_EGI_CLIENT_SECRET=` - 3. (Optionally) Add which integration environment the SP is using, - `HADDOCK3WEBAPP_EGI_ENVIRONMENT=`, - defaults to `production` - 4. (Optionally) Add external URL of app - `HADDOCK3WEBAPP_EGI_REDIRECT_URL=` + 1. Add `HADDOCK3WEBAPP_EGI_CLIENT_ID=` + 2. Add `HADDOCK3WEBAPP_EGI_CLIENT_SECRET=` + 3. (Optionally) Add which integration environment the SP is using, + `HADDOCK3WEBAPP_EGI_ENVIRONMENT=`, + defaults to `production` + 4. (Optionally) Add external URL of app + `HADDOCK3WEBAPP_EGI_REDIRECT_URL=` From 149377129ea34804f3f8a36afe7484249a7031f3 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Tue, 8 Aug 2023 10:39:42 +0200 Subject: [PATCH 18/45] TODO has been moved to https://github.com/i-VRESSE/bartended-haddock3/pull/47 description --- TODO.md | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index c005bd2e..00000000 --- a/TODO.md +++ /dev/null @@ -1,10 +0,0 @@ -- [x] store private key for making bartender tokens -- [x] use remix auth for local users with remix-auth-form -- [x] use remix-auth-oauth2 for - - [x] github - - [x] orcid sandbox - - [x] orcid prod - - [x] egi-checkin -- [x] associate local user with oauth account aka mail matching -- [x] dont generate bartender token every time, but store/fetch from db -- [ ] store users in postgresql instead of sqlite From 07cf17425cbda2d415c429121f64dc67a9a35a2d Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 12:19:03 +0200 Subject: [PATCH 19/45] Upgrade to latest remix Versions and changed code from `npx create-remix@latest` run in temp dir. --- .eslintrc.js | 2 +- app/entry.client.tsx | 32 +- app/entry.server.tsx | 64 ++- app/root.tsx | 18 +- package-lock.json | 1215 ++++++++++++++++++++---------------------- package.json | 24 +- remix.config.js | 9 + 7 files changed, 670 insertions(+), 694 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 0ccf82dc..25e928ad 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,4 @@ -/** @type {import('@types/eslint').Linter.BaseConfig} */ +/** @type {import('eslint').Linter.Config} */ module.exports = { root: true, extends: [ diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 8338545d..94d5dc0d 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,22 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + import { RemixBrowser } from "@remix-run/react"; import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; -function hydrate() { - startTransition(() => { - hydrateRoot( - document, - - - - ); - }); -} - -if (typeof requestIdleCallback === "function") { - requestIdleCallback(hydrate); -} else { - // Safari doesn't support requestIdleCallback - // https://caniuse.com/requestidlecallback - setTimeout(hydrate, 1); -} +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index d349f8e1..7eb9bf52 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,17 +1,25 @@ -import { PassThrough } from "stream"; -import type { EntryContext } from "@remix-run/node"; +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "@remix-run/node"; import { Response } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; import isbot from "isbot"; import { renderToPipeableStream } from "react-dom/server"; -const ABORT_DELAY = 5000; +const ABORT_DELAY = 5_000; export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, - remixContext: EntryContext + remixContext: EntryContext, + loadContext: AppLoadContext ) { return isbot(request.headers.get("user-agent")) ? handleBotRequest( @@ -35,12 +43,16 @@ function handleBotRequest( remixContext: EntryContext ) { return new Promise((resolve, reject) => { - let didError = false; - + let shellRendered = false; const { pipe, abort } = renderToPipeableStream( - , + , { onAllReady() { + shellRendered = true; const body = new PassThrough(); responseHeaders.set("Content-Type", "text/html"); @@ -48,7 +60,7 @@ function handleBotRequest( resolve( new Response(body, { headers: responseHeaders, - status: didError ? 500 : responseStatusCode, + status: responseStatusCode, }) ); @@ -58,9 +70,13 @@ function handleBotRequest( reject(error); }, onError(error: unknown) { - didError = true; - - console.error(error); + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } }, } ); @@ -76,12 +92,16 @@ function handleBrowserRequest( remixContext: EntryContext ) { return new Promise((resolve, reject) => { - let didError = false; - + let shellRendered = false; const { pipe, abort } = renderToPipeableStream( - , + , { onShellReady() { + shellRendered = true; const body = new PassThrough(); responseHeaders.set("Content-Type", "text/html"); @@ -89,19 +109,23 @@ function handleBrowserRequest( resolve( new Response(body, { headers: responseHeaders, - status: didError ? 500 : responseStatusCode, + status: responseStatusCode, }) ); pipe(body); }, - onShellError(err: unknown) { - reject(err); + onShellError(error: unknown) { + reject(error); }, onError(error: unknown) { - didError = true; - - console.error(error); + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } }, } ); diff --git a/app/root.tsx b/app/root.tsx index 8c8b1787..08fe4a4e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,8 +1,9 @@ +import { cssBundleHref } from "@remix-run/css-bundle"; import { json, type LinksFunction, type LoaderArgs, - type MetaFunction, + type V2_MetaFunction, } from "@remix-run/node"; import { Links, @@ -17,13 +18,14 @@ import { Navbar } from "~/components/Navbar"; import { getOptionalClientUser } from "./auth.server"; import styles from "./tailwind.css"; -export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), + { rel: "stylesheet", href: styles }, +]; -export const meta: MetaFunction = () => ({ - charset: "utf-8", - title: "Haddock3", - viewport: "width=device-width,initial-scale=1", -}); +export const meta: V2_MetaFunction = () => { + return [{ title: "Haddock3" }]; +}; export async function loader({ request }: LoaderArgs) { const user = await getOptionalClientUser(request); @@ -34,6 +36,8 @@ export default function App() { return ( + + diff --git a/package-lock.json b/package-lock.json index 6d777c7d..2828b539 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,13 @@ "version": "0.1.0", "dependencies": { "@i-vresse/wb-core": "^1.2.0", - "@prisma/client": "^5.0.0", - "@remix-run/node": "^1.16.1", - "@remix-run/react": "^1.16.1", - "@remix-run/serve": "^1.16.1", + "@prisma/client": "^5.1.1", + "@remix-run/css-bundle": "^1.19.2", + "@remix-run/node": "^1.19.2", + "@remix-run/react": "^1.19.2", + "@remix-run/serve": "^1.19.2", "bcryptjs": "^2.4.3", - "isbot": "^3.6.5", + "isbot": "^3.6.8", "jose": "^4.13.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", @@ -23,13 +24,14 @@ "remix-auth-form": "^1.3.0", "remix-auth-github": "^1.4.0", "remix-auth-keycloak": "^1.2.0", - "remix-auth-oauth2": "^1.8.0" + "remix-auth-oauth2": "^1.8.0", + "valibot": "^0.11.1" }, "devDependencies": { "@ltd/j-toml": "^1.38.0", "@openapitools/openapi-generator-cli": "^2.7.0", - "@remix-run/dev": "^1.16.1", - "@remix-run/eslint-config": "^1.16.1", + "@remix-run/dev": "^1.19.2", + "@remix-run/eslint-config": "^1.19.2", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.9", "@testing-library/jest-dom": "^5.16.5", @@ -39,8 +41,8 @@ "@types/eslint": "^8.37.0", "@types/js-yaml": "^4.0.5", "@types/jszip": "^3.4.1", - "@types/react": "^18.0.25", - "@types/react-dom": "^18.0.8", + "@types/react": "^18.0.35", + "@types/react-dom": "^18.0.11", "@vitest/coverage-c8": "^0.31.2", "daisyui": "^2.51.0", "eslint": "^8.38.0", @@ -48,7 +50,7 @@ "happy-dom": "^9.20.3", "prettier": "^2.8.7", "prettier-plugin-tailwindcss": "^0.2.7", - "prisma": "^5.0.0", + "prisma": "^5.1.1", "tailwindcss": "^3.2.7", "ts-node": "^10.9.1", "typescript": "~4.8.4", @@ -69,6 +71,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -92,6 +95,7 @@ "version": "7.21.9", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.9.tgz", "integrity": "sha512-FUGed8kfhyWvbYug/Un/VPJD41rDIgoVVcR+FuzhzOYyRz5uED+Gd3SLZml0Uw2l2aHFb7ZgdW5mGA3G2cCCnQ==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -100,6 +104,7 @@ "version": "7.21.8", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.8.tgz", "integrity": "sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.21.4", @@ -126,9 +131,10 @@ } }, "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -152,9 +158,9 @@ } }, "node_modules/@babel/eslint-parser/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -164,6 +170,7 @@ "version": "7.21.9", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.9.tgz", "integrity": "sha512-F3fZga2uv09wFdEjEQIJxXALXfz0+JaOb7SabvVMmjHxeVTuGW8wgE8Vp1Hd7O+zMTYtcfEISGRzPkeiaPPsvg==", + "dev": true, "dependencies": { "@babel/types": "^7.21.5", "@jridgewell/gen-mapping": "^0.3.2", @@ -178,6 +185,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -191,6 +199,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -226,6 +235,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.5.tgz", "integrity": "sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==", + "dev": true, "dependencies": { "@babel/compat-data": "^7.21.5", "@babel/helper-validator-option": "^7.21.0", @@ -244,14 +254,16 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "dependencies": { "yallist": "^3.0.2" } }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -259,7 +271,8 @@ "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.21.8", @@ -285,9 +298,9 @@ } }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -311,9 +324,9 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -337,9 +350,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -349,6 +362,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz", "integrity": "sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -357,6 +371,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "dev": true, "dependencies": { "@babel/template": "^7.20.7", "@babel/types": "^7.21.0" @@ -369,6 +384,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -403,6 +419,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz", "integrity": "sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==", + "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.21.5", "@babel/helper-module-imports": "^7.21.4", @@ -433,6 +450,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz", "integrity": "sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -476,6 +494,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz", "integrity": "sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==", + "dev": true, "dependencies": { "@babel/types": "^7.21.5" }, @@ -499,6 +518,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -526,6 +546,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz", "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -549,6 +570,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.5.tgz", "integrity": "sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==", + "dev": true, "dependencies": { "@babel/template": "^7.20.7", "@babel/traverse": "^7.21.5", @@ -639,6 +661,7 @@ "version": "7.21.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.9.tgz", "integrity": "sha512-q5PNg/Bi1OpGgx5jYlvWZwAorZepEudDMCLtj967aeS7WMont7dUZI46M2XwcIQqvUlMxWfdLFu4S/qSxeUu5g==", + "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -1033,6 +1056,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.21.4.tgz", "integrity": "sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -1841,9 +1865,9 @@ } }, "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -1925,6 +1949,7 @@ "version": "7.21.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.21.9.tgz", "integrity": "sha512-MK0X5k8NKOuWRamiEfc3KEJiHMTkGZNUjzMipqCGDDc6ijRl/B7RGSKVGncu4Ro/HdyzzY6cmoXuKI2Gffk7vQ==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.21.4", "@babel/parser": "^7.21.9", @@ -1938,6 +1963,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.5.tgz", "integrity": "sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.21.4", "@babel/generator": "^7.21.5", @@ -2007,9 +2033,9 @@ } }, "node_modules/@dnd-kit/core": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.7.tgz", - "integrity": "sha512-qcLBTVTjmLuLqC0RHQ+dFKN5neWmAI56H9xZ+he9WEJEkAvR76YAcz7DSWDJfjErepfG2H3Fkb9lYiX7cPR62g==", + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", "dependencies": { "@dnd-kit/accessibility": "^3.0.0", "@dnd-kit/utilities": "^3.2.1", @@ -2058,25 +2084,21 @@ } }, "node_modules/@emotion/babel-plugin": { - "version": "11.10.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.10.5.tgz", - "integrity": "sha512-xE7/hyLHJac7D2Ve9dKroBBZqBT7WuPQmWcq7HSGb84sUuP4mlOWoB8dvVfD9yk5DHkU1m6RW7xSoDtnQHNQeA==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", "dependencies": { "@babel/helper-module-imports": "^7.16.7", - "@babel/plugin-syntax-jsx": "^7.17.12", "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.0", - "@emotion/memoize": "^0.8.0", - "@emotion/serialize": "^1.1.1", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", - "stylis": "4.1.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "stylis": "4.2.0" } }, "node_modules/@emotion/babel-plugin/node_modules/source-map": { @@ -2088,98 +2110,94 @@ } }, "node_modules/@emotion/cache": { - "version": "11.10.5", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.10.5.tgz", - "integrity": "sha512-dGYHWyzTdmK+f2+EnIGBpkz1lKc4Zbj2KHd4cX3Wi8/OWr5pKslNjc3yABKH4adRGCvSX4VDC0i04mrrq0aiRA==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", "dependencies": { - "@emotion/memoize": "^0.8.0", - "@emotion/sheet": "^1.2.1", - "@emotion/utils": "^1.2.0", - "@emotion/weak-memoize": "^0.3.0", - "stylis": "4.1.3" + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" } }, "node_modules/@emotion/hash": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", - "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" }, "node_modules/@emotion/memoize": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz", - "integrity": "sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/react": { - "version": "11.10.5", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.10.5.tgz", - "integrity": "sha512-TZs6235tCJ/7iF6/rvTaOH4oxQg2gMAcdHemjwLKIjKz4rRuYe1HJ2TQJKnAcRAfOUDdU8XoDadCe1rl72iv8A==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", + "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.10.5", - "@emotion/cache": "^11.10.5", - "@emotion/serialize": "^1.1.1", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@emotion/utils": "^1.2.0", - "@emotion/weak-memoize": "^0.3.0", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { - "@babel/core": "^7.0.0", "react": ">=16.8.0" }, "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, "@types/react": { "optional": true } } }, "node_modules/@emotion/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-Zl/0LFggN7+L1liljxXdsVSVlg6E/Z/olVWpfxUTxOAmi8NU7YoeWeLfi1RmnB2TATHoaWwIBRoL+FvAJiTUQA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", + "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", "dependencies": { - "@emotion/hash": "^0.9.0", - "@emotion/memoize": "^0.8.0", - "@emotion/unitless": "^0.8.0", - "@emotion/utils": "^1.2.0", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", "csstype": "^3.0.2" } }, "node_modules/@emotion/sheet": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz", - "integrity": "sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" }, "node_modules/@emotion/unitless": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", - "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.0.tgz", - "integrity": "sha512-1eEgUGmkaljiBnRMTdksDV1W4kUnmwgp7X9G8B++9GYwl1lUdqSndSriIrTJ0N7LQaoauY9JJ2yhiOYK5+NI4A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.0.tgz", - "integrity": "sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" }, "node_modules/@emotion/weak-memoize": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz", - "integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==" + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, "node_modules/@esbuild/android-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz", - "integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", "cpu": [ "arm" ], @@ -2193,9 +2211,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz", - "integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", "cpu": [ "arm64" ], @@ -2209,9 +2227,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz", - "integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", "cpu": [ "x64" ], @@ -2225,9 +2243,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz", - "integrity": "sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", "cpu": [ "arm64" ], @@ -2241,9 +2259,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz", - "integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", "cpu": [ "x64" ], @@ -2257,9 +2275,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz", - "integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", "cpu": [ "arm64" ], @@ -2273,9 +2291,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz", - "integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", "cpu": [ "x64" ], @@ -2289,9 +2307,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz", - "integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", "cpu": [ "arm" ], @@ -2305,9 +2323,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz", - "integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", "cpu": [ "arm64" ], @@ -2321,9 +2339,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz", - "integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", "cpu": [ "ia32" ], @@ -2337,9 +2355,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz", - "integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", "cpu": [ "loong64" ], @@ -2353,9 +2371,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz", - "integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", "cpu": [ "mips64el" ], @@ -2369,9 +2387,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz", - "integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", "cpu": [ "ppc64" ], @@ -2385,9 +2403,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz", - "integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", "cpu": [ "riscv64" ], @@ -2401,9 +2419,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz", - "integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", "cpu": [ "s390x" ], @@ -2433,9 +2451,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz", - "integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", "cpu": [ "x64" ], @@ -2449,9 +2467,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz", - "integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", "cpu": [ "x64" ], @@ -2465,9 +2483,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz", - "integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", "cpu": [ "x64" ], @@ -2481,9 +2499,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz", - "integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", "cpu": [ "arm64" ], @@ -2497,9 +2515,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz", - "integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", "cpu": [ "ia32" ], @@ -2513,9 +2531,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz", - "integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", "cpu": [ "x64" ], @@ -2724,9 +2742,15 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/@i-vresse/wb-core/node_modules/nanoid": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.1.tgz", - "integrity": "sha512-udKGtCCUafD3nQtJg9wBhRP3KMbPglUsgV5JVsXhvyBs/oefqb4sqMEhKBBgqZncYowu58p1prsZQBYvAj/Gww==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.js" }, @@ -2825,6 +2849,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -2838,6 +2863,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -2846,6 +2872,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -2853,12 +2880,14 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.17", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/sourcemap-codec": "1.4.14" @@ -3177,21 +3206,21 @@ } }, "node_modules/@popperjs/core": { - "version": "2.11.6", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", - "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, "node_modules/@prisma/client": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.0.0.tgz", - "integrity": "sha512-XlO5ELNAQ7rV4cXIDJUNBEgdLwX3pjtt9Q/RHqDpGf43szpNJx2hJnggfFs7TKNx0cOFsl6KJCSfqr5duEU/bQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.1.1.tgz", + "integrity": "sha512-fxcCeK5pMQGcgCqCrWsi+I2rpIbk0rAhdrN+ke7f34tIrgPwA68ensrpin+9+fZvuV2OtzHmuipwduSY6HswdA==", "hasInstallScript": true, "dependencies": { - "@prisma/engines-version": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584" + "@prisma/engines-version": "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e" }, "engines": { "node": ">=16.13" @@ -3206,16 +3235,16 @@ } }, "node_modules/@prisma/engines": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.0.0.tgz", - "integrity": "sha512-kyT/8fd0OpWmhAU5YnY7eP31brW1q1YrTGoblWrhQJDiN/1K+Z8S1kylcmtjqx5wsUGcP1HBWutayA/jtyt+sg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.1.1.tgz", + "integrity": "sha512-NV/4nVNWFZSJCCIA3HIFJbbDKO/NARc9ej0tX5S9k2EVbkrFJC4Xt9b0u4rNZWL4V+F5LAjvta8vzEUw0rw+HA==", "devOptional": true, "hasInstallScript": true }, "node_modules/@prisma/engines-version": { - "version": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584.tgz", - "integrity": "sha512-HHiUF6NixsldsP3JROq07TYBLEjXFKr6PdH8H4gK/XAoTmIplOJBCgrIUMrsRAnEuGyRoRLXKXWUb943+PFoKQ==" + "version": "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e.tgz", + "integrity": "sha512-owZqbY/wucbr65bXJ/ljrHPgQU5xXTSkmcE/JcbqE1kusuAXV/TLN3/exmz21SZ5rJ7WDkyk70J2G/n68iogbQ==" }, "node_modules/@react-dnd/asap": { "version": "4.0.1", @@ -3227,10 +3256,18 @@ "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" }, + "node_modules/@remix-run/css-bundle": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/css-bundle/-/css-bundle-1.19.2.tgz", + "integrity": "sha512-+gmGzKXwo13vnCMjjfXXSIxJlrN1VVtUvvX4o803ifIyFIJr8/Hp9cuiZDlimcjmKyEKjFiyiVmdKvK7qeOBEA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@remix-run/dev": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-1.16.1.tgz", - "integrity": "sha512-PrCIOa4qkZISW9l2tAX9+KMPSfO9QfMGfBZz6rd79v/GQ9N2bhWgKGWwzWhlGWJoVnnRZT5VRIy6YZsepa52/A==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-1.19.2.tgz", + "integrity": "sha512-wAY3AN0xPJB01wi9RqHG1mV3W0M915XgudcLtJlWDbVppY3kGr7gP0/1sAz5onyEX8Z/+1vvVVU9jn59G8PIcA==", "dev": true, "dependencies": { "@babel/core": "^7.21.8", @@ -3243,7 +3280,7 @@ "@babel/traverse": "^7.21.5", "@babel/types": "^7.21.5", "@npmcli/package-json": "^2.0.0", - "@remix-run/server-runtime": "1.16.1", + "@remix-run/server-runtime": "1.19.2", "@vanilla-extract/integration": "^6.2.0", "arg": "^5.0.1", "cacache": "^15.0.5", @@ -3251,7 +3288,7 @@ "chokidar": "^3.5.1", "dotenv": "^16.0.0", "esbuild": "0.17.6", - "esbuild-plugin-polyfill-node": "^0.2.0", + "esbuild-plugins-node-modules-polyfill": "^1.3.0", "execa": "5.1.1", "exit-hook": "2.2.1", "express": "^4.17.1", @@ -3264,17 +3301,19 @@ "json5": "^2.2.2", "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", - "lru-cache": "^7.14.1", "minimatch": "^9.0.0", "node-fetch": "^2.6.9", "ora": "^5.4.1", + "picocolors": "^1.0.0", + "picomatch": "^2.3.1", + "pidtree": "^0.6.0", "postcss": "^8.4.19", "postcss-discard-duplicates": "^5.1.0", "postcss-load-config": "^4.0.1", "postcss-modules": "^6.0.0", - "prettier": "2.7.1", + "prettier": "^2.7.1", "pretty-ms": "^7.0.1", - "proxy-agent": "^5.0.0", + "proxy-agent": "^6.3.0", "react-refresh": "^0.14.0", "recast": "^0.21.5", "remark-frontmatter": "4.0.1", @@ -3290,10 +3329,10 @@ "remix": "dist/cli.js" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" }, "peerDependencies": { - "@remix-run/serve": "^1.16.1" + "@remix-run/serve": "^1.19.2" }, "peerDependenciesMeta": { "@remix-run/serve": { @@ -3354,21 +3393,6 @@ } } }, - "node_modules/@remix-run/dev/node_modules/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/@remix-run/dev/node_modules/yaml": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", @@ -3379,9 +3403,9 @@ } }, "node_modules/@remix-run/eslint-config": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/eslint-config/-/eslint-config-1.16.1.tgz", - "integrity": "sha512-oVkkpgVrTM3CcKaaw5ZOwVjV+yGi7DzgEnjD+BCnLVj4CHQNc9KsNOwVW1ub9JqW5mnBdly5MHDcocEft2LJBw==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/eslint-config/-/eslint-config-1.19.2.tgz", + "integrity": "sha512-YvB3uulbSdwsvkcQSUPXtZG2AbVq1v49jVf5IsPj0xApNcRMtkk4Y/6cethbtHJOIpHhbAnJyPtoHOVvlRWQHQ==", "dev": true, "dependencies": { "@babel/core": "^7.21.8", @@ -3416,28 +3440,28 @@ } }, "node_modules/@remix-run/express": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/express/-/express-1.16.1.tgz", - "integrity": "sha512-qBIuFiA0qiHCTT36RwnzxhoVaAEwUmg5bihxwSkhj6zIhjM98e1DQHeKJ4f0G9Zf/qxvdl+cRNjzvlauPmocqQ==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/express/-/express-1.19.2.tgz", + "integrity": "sha512-uaKv2Yxfl545QmE2Ou/EYkGUycKYJDZ98kCgI0+bBgX591IE2rzhmSeExCerLXI5rDTKO4YikPTyxiUqZncPiA==", "dependencies": { - "@remix-run/node": "1.16.1" + "@remix-run/node": "1.19.2" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" }, "peerDependencies": { "express": "^4.17.1" } }, "node_modules/@remix-run/node": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-1.16.1.tgz", - "integrity": "sha512-Qp9B2htm0bGG0iuxsqezDIl89uVSBZ8xfwq2aKWgRNm1FCa4/GRXzKmTo+sbBcacj7aYe+1r+0sIS6Q1sgaEnA==", - "dependencies": { - "@remix-run/server-runtime": "1.16.1", - "@remix-run/web-fetch": "^4.3.4", - "@remix-run/web-file": "^3.0.2", - "@remix-run/web-stream": "^1.0.3", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-1.19.2.tgz", + "integrity": "sha512-ceoDsnG4SQHxqV6aq6ZTxiBr90mnAsQBL3D7kfNswsgPS/d0gERV1hIC9svDW2NuDeMI/U1CXCM08XZYrqLfRQ==", + "dependencies": { + "@remix-run/server-runtime": "1.19.2", + "@remix-run/web-fetch": "^4.3.6", + "@remix-run/web-file": "^3.0.3", + "@remix-run/web-stream": "^1.0.4", "@web3-storage/multipart-parser": "^1.0.0", "abort-controller": "^3.0.0", "cookie-signature": "^1.1.0", @@ -3445,83 +3469,90 @@ "stream-slice": "^0.1.2" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, "node_modules/@remix-run/react": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-1.16.1.tgz", - "integrity": "sha512-vmqDXL/cHDIg3iKObtH+FltNwG+rviK1lCYgXSHgY17/95fve07hXRQalOr/ctt1jrGvGgaR4o/nlwlW7QMmpQ==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-1.19.2.tgz", + "integrity": "sha512-Q3abYOR02+8wg3mNGYHafGqZUwVv19Et+2ti/8CKTOvY+/rIRppEhDqPCYaiAvNJ7kMcvTdCYKbCi6bU9ElV0g==", "dependencies": { - "@remix-run/router": "1.6.2", - "react-router-dom": "6.11.2" + "@remix-run/router": "1.7.2", + "react-router-dom": "6.14.2" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, "node_modules/@remix-run/router": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.2.tgz", - "integrity": "sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.2.tgz", + "integrity": "sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==", "engines": { "node": ">=14" } }, "node_modules/@remix-run/serve": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-1.16.1.tgz", - "integrity": "sha512-wpYZcUH6rITI9fnUsHffLhOcq98l6DMQh9MFAiGjUjPpMta7X2c7C/l3xOsL3ioE45b1d7A+tS0F69aV71u+uQ==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-1.19.2.tgz", + "integrity": "sha512-8ypS7zdK8hlAyjD+I8MNwqHV3m+4qek2UryqCFAJU6/nfjRmCNo1TTItR9NuQSqNiWzfW2J6TyA47NYtwGb7Dw==", "dependencies": { - "@remix-run/express": "1.16.1", - "@remix-run/node": "1.16.1", + "@remix-run/express": "1.19.2", + "@remix-run/node": "1.19.2", "compression": "^1.7.4", "express": "^4.17.1", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "source-map-support": "^0.5.21" }, "bin": { "remix-serve": "dist/cli.js" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, "node_modules/@remix-run/server-runtime": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.16.1.tgz", - "integrity": "sha512-HG+f3PGE9kzTTPe5i5Hv7UGrJLmFID1Ae4BMohP5e0xXOxbdlKDPj6NN6yGDgE7OqKFuDVliW2B5LlUdJZgUFw==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.19.2.tgz", + "integrity": "sha512-d+oOaQlBPFHPum1nstJCntRgwgi4fzijW51yPCo3ljUxIrjlDjutX2GCQ72Apd6Ffpd11/6nEq+B90EY+Ala9Q==", "dependencies": { - "@remix-run/router": "1.6.2", + "@remix-run/router": "1.7.2", + "@types/cookie": "^0.4.1", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.4.1", "set-cookie-parser": "^2.4.8", "source-map": "^0.7.3" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, + "node_modules/@remix-run/server-runtime/node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, "node_modules/@remix-run/web-blob": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-blob/-/web-blob-3.0.4.tgz", - "integrity": "sha512-AfegzZvSSDc+LwnXV+SwROTrDtoLiPxeFW+jxgvtDAnkuCX1rrzmVJ6CzqZ1Ai0bVfmJadkG5GxtAfYclpPmgw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@remix-run/web-blob/-/web-blob-3.0.5.tgz", + "integrity": "sha512-Mungj3erqCrq0+5zU/34NkeC2g+U7K6Uwa8uNiZgANvw0Wc64wKglk4MPQJZA0Y2tgPYXyrRn7uw4q75j6Hhww==", "dependencies": { - "@remix-run/web-stream": "^1.0.0", + "@remix-run/web-stream": "^1.0.4", "web-encoding": "1.1.5" } }, "node_modules/@remix-run/web-fetch": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-fetch/-/web-fetch-4.3.4.tgz", - "integrity": "sha512-AUM1XBa4hcgeNt2CD86OlB5aDLlqdMl0uJ+89R8dPGx07I5BwMXnbopCaPAkvSBIoHeT/IoLWIuZrLi7RvXS+Q==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@remix-run/web-fetch/-/web-fetch-4.3.6.tgz", + "integrity": "sha512-ifadyJS+/7W6LhKyA8tIR9fBIPwLCFVpl1YCYg5i0ikykiXxE3IWtPVB1G51AJFghc1YgR7rq8BRJLsJeUbE5Q==", "dependencies": { - "@remix-run/web-blob": "^3.0.4", - "@remix-run/web-form-data": "^3.0.3", - "@remix-run/web-stream": "^1.0.3", + "@remix-run/web-blob": "^3.0.5", + "@remix-run/web-form-data": "^3.0.5", + "@remix-run/web-stream": "^1.0.4", "@web3-storage/multipart-parser": "^1.0.0", "abort-controller": "^3.0.0", "data-uri-to-buffer": "^3.0.1", @@ -3532,25 +3563,25 @@ } }, "node_modules/@remix-run/web-file": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@remix-run/web-file/-/web-file-3.0.2.tgz", - "integrity": "sha512-eFC93Onh/rZ5kUNpCQersmBtxedGpaXK2/gsUl49BYSGK/DvuPu3l06vmquEDdcPaEuXcsdGP0L7zrmUqrqo4A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@remix-run/web-file/-/web-file-3.0.3.tgz", + "integrity": "sha512-yPf6MSXNcaQ4H1vkT/TSgImnqqfvfVKZzjd0vz3wvR0MM1NmrYfLbSbwfFLXdESFnQpXItbyKsgYGeAUEawgBg==", "dependencies": { - "@remix-run/web-blob": "^3.0.3" + "@remix-run/web-blob": "^3.0.5" } }, "node_modules/@remix-run/web-form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-form-data/-/web-form-data-3.0.4.tgz", - "integrity": "sha512-UMF1jg9Vu9CLOf8iHBdY74Mm3PUvMW8G/XZRJE56SxKaOFWGSWlfxfG+/a3boAgHFLTkP7K4H1PxlRugy1iQtw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@remix-run/web-form-data/-/web-form-data-3.0.5.tgz", + "integrity": "sha512-txXJDzjDuTxF8MFvEp9AA2HF3oPcvmlE1I/6HIxeGpX3vpBtrCPw5KQ/nzgBZNuAxyxEm8ps6Ds/UZwoDyfGsQ==", "dependencies": { "web-encoding": "1.1.5" } }, "node_modules/@remix-run/web-stream": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@remix-run/web-stream/-/web-stream-1.0.3.tgz", - "integrity": "sha512-wlezlJaA5NF6SsNMiwQnnAW6tnPzQ5I8qk0Y0pSohm0eHKa2FQ1QhEKLVVcDDu02TmkfHgnux0igNfeYhDOXiA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@remix-run/web-stream/-/web-stream-1.0.4.tgz", + "integrity": "sha512-SVO42pH21I1sAhksGEM8ZBV/jc1mz6knZSg6Qo/2HPy9JTvtUykm3QMHtF2OMCTUXxdRW+4E/rphkPRyGc8WKw==", "dependencies": { "web-streams-polyfill": "^3.1.1" } @@ -3564,11 +3595,11 @@ } }, "node_modules/@restart/hooks": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.8.tgz", - "integrity": "sha512-Ivvp1FZ0Lja80iUTYAhbzy+stxwO7FbPHP95ypCtIh0wyOLiayQywXhVJ2ZYP5S1AjW2GmKHeRU4UglMwTG2sA==", + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.11.tgz", + "integrity": "sha512-Ft/ncTULZN6ldGHiF/k5qt72O8JyRMOeg0tApvCni8LkoiEahO+z3TNxfXIVGy890YtWVDvJAl662dVJSJXvMw==", "dependencies": { - "dequal": "^2.0.2" + "dequal": "^2.0.3" }, "peerDependencies": { "react": ">=16.8.0" @@ -3804,14 +3835,11 @@ "node": ">=14" } }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "engines": { - "node": ">= 6" - } + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true }, "node_modules/@tsconfig/node10": { "version": "1.0.9", @@ -4100,9 +4128,9 @@ "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/react": { - "version": "18.0.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.27.tgz", - "integrity": "sha512-3vtRKHgVxu3Jp9t718R9BuzoD4NcQ8YJ5XRzsSKxNDiDonD2MXIT1TmSkenxuCycZJoQT5d2vE8LwWJxBC1gmA==", + "version": "18.2.19", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.19.tgz", + "integrity": "sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4110,18 +4138,18 @@ } }, "node_modules/@types/react-dom": { - "version": "18.0.10", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.10.tgz", - "integrity": "sha512-E42GW/JA4Qv15wQdqJq8DL4JhNpB3prJgjgapN3qJT9K2zO5IIAQh4VXvCEDupoqAwnz0cY4RlXeC/ajX5SFHg==", + "version": "18.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", + "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", "dev": true, "dependencies": { "@types/react": "*" } }, "node_modules/@types/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", + "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", "dependencies": { "@types/react": "*" } @@ -4620,10 +4648,11 @@ "integrity": "sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==" }, "node_modules/@zip.js/zip.js": { - "version": "2.6.75", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.6.75.tgz", - "integrity": "sha512-WiEdU77n4xJgESpYsqnY+K8ZZHcpmJ8Ho+2scGzgioj7IF8YR4V7tly+a8qL4msNwh67FbFT52zzv9Te2eAByQ==", + "version": "2.7.22", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.22.tgz", + "integrity": "sha512-cb0DeaHD9WnFwofi9nsYtye1Iqu9NbN3YTFbfQJu7qsq2fD3tIjOLwE67rRaNWlQ+3MCmMlGvmGmBVzuztUBuQ==", "engines": { + "bun": ">=0.7.0", "deno": ">=1.0.0", "node": ">=16.5.0" } @@ -4720,15 +4749,15 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", "dev": true, "dependencies": { - "debug": "4" + "debug": "^4.3.4" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/aggregate-error": { @@ -5099,9 +5128,9 @@ } }, "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5179,6 +5208,15 @@ "node": ">= 0.8" } }, + "node_modules/basic-ftp": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.3.tgz", + "integrity": "sha512-QHX8HLlncOLpy54mh+k/sWIFd0ThmRqwe9ZjELybGZK+tZ8rUb9VO0saKJUROTbE+KhzDUT7xziGpGrW8Kmd+g==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -5355,6 +5393,7 @@ "version": "4.21.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5599,6 +5638,7 @@ "version": "1.0.30001450", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001450.tgz", "integrity": "sha512-qMBmvmQmFXaSxexkjjfMvD5rnDL0+m+dUMZKoDYsGG8iZN29RuYh9eRoMvKsT6uMAWlyUUGDEQGJJYjzCIO9ew==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6148,9 +6188,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.27.2", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.27.2.tgz", - "integrity": "sha512-Cf2jqAbXgWH3VVzjyaaFkY1EBazxugUepGymDoeteyYr9ByX51kD2jdHZlsEF/xnJMyN3Prua7mQuzwMg6Zc9A==", + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.32.0.tgz", + "integrity": "sha512-qsev1H+dTNYpDUEURRuOXMvpdtAnNEvQWS/FMJ2Vb5AY8ZP4rAPQldkE27joykZPJTe0+IVgHZYh1P5Xu1/i1g==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -6636,18 +6676,17 @@ } }, "node_modules/degenerator": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-3.0.2.tgz", - "integrity": "sha512-c0mef3SNQo56t6urUU6tdQAs+ThoD0o9B9MJ8HEt7NQcGEILCRFqQb7ZbP9JAv+QF1Ky5plydhMR/IrqWDm+TQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", "dev": true, "dependencies": { - "ast-types": "^0.13.2", - "escodegen": "^1.8.1", - "esprima": "^4.0.0", - "vm2": "^3.9.8" + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/delayed-stream": { @@ -6866,7 +6905,8 @@ "node_modules/electron-to-chromium": { "version": "1.4.288", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.288.tgz", - "integrity": "sha512-8s9aJf3YiokIrR+HOQzNOGmEHFXVUQzXM/JaViVvKdCkNUjS+lEa/uT7xw3nDVG/IgfxiIwUGkwJ6AR1pTpYsQ==" + "integrity": "sha512-8s9aJf3YiokIrR+HOQzNOGmEHFXVUQzXM/JaViVvKdCkNUjS+lEa/uT7xw3nDVG/IgfxiIwUGkwJ6AR1pTpYsQ==", + "dev": true }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -7077,17 +7117,21 @@ "@esbuild/win32-x64": "0.17.6" } }, - "node_modules/esbuild-plugin-polyfill-node": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/esbuild-plugin-polyfill-node/-/esbuild-plugin-polyfill-node-0.2.0.tgz", - "integrity": "sha512-rpCoK4mag0nehBtFlFMLSuL9bNBLEh8h3wZ/FsrJEDompA/AwOqInx6Xow01+CXAcvZYhkoJ0SIZiS37qkecDA==", + "node_modules/esbuild-plugins-node-modules-polyfill": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/esbuild-plugins-node-modules-polyfill/-/esbuild-plugins-node-modules-polyfill-1.3.0.tgz", + "integrity": "sha512-r/aNOvAlIaIzqJwvFHWhDGrPF/Aj5qI1zKVeHbCFpKH+bnKW1BG2LGixMd3s6hyWcZHcfdl2QZRucVuOLzFRrA==", "dev": true, "dependencies": { "@jspm/core": "^2.0.1", - "import-meta-resolve": "^2.2.2" + "local-pkg": "^0.4.3", + "resolve.exports": "^2.0.2" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { - "esbuild": "*" + "esbuild": "^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0" } }, "node_modules/esbuild/node_modules/@esbuild/android-arm": { @@ -7430,6 +7474,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, "engines": { "node": ">=6" } @@ -7451,75 +7496,26 @@ } }, "node_modules/escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, "dependencies": { "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" + "estraverse": "^5.2.0", + "esutils": "^2.0.2" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" }, "engines": { - "node": ">=4.0" + "node": ">=6.0" }, "optionalDependencies": { "source-map": "~0.6.1" } }, - "node_modules/escodegen/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/escodegen/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/escodegen/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7530,18 +7526,6 @@ "node": ">=0.10.0" } }, - "node_modules/escodegen/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint": { "version": "8.41.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", @@ -7819,9 +7803,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -7913,9 +7897,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -7966,9 +7950,9 @@ } }, "node_modules/eslint-plugin-node/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -8045,9 +8029,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -8650,15 +8634,6 @@ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, - "node_modules/file-uri-to-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-2.0.0.tgz", - "integrity": "sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -8886,43 +8861,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/ftp": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", - "integrity": "sha512-faFVML1aBx2UoDStmLwv2Wptt4vw5x03xxX172nhA5Y5HBshW5JweqQ2W4xL4dezQTG8inJsuYcpPHHU3X5OTQ==", - "dev": true, - "dependencies": { - "readable-stream": "1.1.x", - "xregexp": "2.0.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ftp/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - }, - "node_modules/ftp/node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/ftp/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true - }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -8968,6 +8906,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -9053,20 +8992,27 @@ } }, "node_modules/get-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-3.0.2.tgz", - "integrity": "sha512-+5s0SJbGoyiJTZZ2JTpFPLMPSch72KEqGOTvQsBqg0RBWvwhWUSYZFAtz3TPW0GXJuLBJPts1E241iHg+VRfhg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.1.tgz", + "integrity": "sha512-7ZqONUVqaabogsYNWlYj0t3YZaL6dhuEueZXGF+/YVmf6dHmaFg8/6psJKqhx9QykIDKzpGcy2cn4oV4YC7V/Q==", "dev": true, "dependencies": { - "@tootallnate/once": "1", - "data-uri-to-buffer": "3", - "debug": "4", - "file-uri-to-path": "2", - "fs-extra": "^8.1.0", - "ftp": "^0.3.10" + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^5.0.1", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/data-uri-to-buffer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-5.0.1.tgz", + "integrity": "sha512-a9l6T1qqDogvvnw0nKlfZzqsyikEBZBClF39V3TFoKhDtGBqHu2HkuomJc02j5zft8zrUaXEuoicLeW54RkzPg==", + "dev": true, + "engines": { + "node": ">= 14" } }, "node_modules/get-uri/node_modules/fs-extra": { @@ -9146,6 +9092,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, "engines": { "node": ">=4" } @@ -9540,17 +9487,16 @@ } }, "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", + "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", "dev": true, "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/http2-wrapper": { @@ -9567,16 +9513,16 @@ } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.1.tgz", + "integrity": "sha512-Eun8zV0kcYS1g19r78osiQLEFIRspRUDd9tIfBCTBPBeMieF/EsJNL8VI3xOIdYRDEkjQnqOYPsZ2DsWsVsFwQ==", "dev": true, "dependencies": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/human-signals": { @@ -9660,16 +9606,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-meta-resolve": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-2.2.2.tgz", - "integrity": "sha512-f8KcQ1D80V7RnqVm+/lirO9zkOxjGxhaTC1IPrBGd3MEfNgmNG67tSUO9gTi2F3Blr2Az6g1vocaxzkVnWl9MA==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -10308,9 +10244,9 @@ "dev": true }, "node_modules/isbot": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-3.6.5.tgz", - "integrity": "sha512-BchONELXt6yMad++BwGpa0oQxo/uD0keL7N15cYVf0A1oMIoNQ79OqeYdPMFWDrNhCqCbRuw9Y9F3QBjvAxZ5g==", + "version": "3.6.13", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-3.6.13.tgz", + "integrity": "sha512-uoP4uK5Dc2CrabmK+Gue1jTL+scHiCc1c9rblRpJwG8CPxjLIv8jmGyyGRGkbPOweayhkskdZsEQXG6p+QCQrg==", "engines": { "node": ">=12" } @@ -10553,9 +10489,9 @@ } }, "node_modules/jquery": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz", - "integrity": "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz", + "integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ==", "peer": true }, "node_modules/js-string-escape": { @@ -10644,6 +10580,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -10958,9 +10895,9 @@ } }, "node_modules/lru-cache": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz", - "integrity": "sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "dev": true, "engines": { "node": ">=12" @@ -10991,9 +10928,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -12163,9 +12100,15 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -12232,7 +12175,8 @@ "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", - "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==" + "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", + "dev": true }, "node_modules/normalize-path": { "version": "3.0.0", @@ -12577,37 +12521,36 @@ } }, "node_modules/pac-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-5.0.0.tgz", - "integrity": "sha512-CcFG3ZtnxO8McDigozwE3AqAw15zDvGH+OjXO4kzf7IkEKkQ4gxQ+3sdF50WmhQ4P/bVusXcqNE2S3XrNURwzQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.0.tgz", + "integrity": "sha512-t4tRAMx0uphnZrio0S0Jw9zg3oDbz1zVhQ/Vy18FjLfP1XOLNUEjaVxYCYRI6NS+BsMBXKIzV6cTLOkO9AtywA==", "dev": true, "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4", - "get-uri": "3", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "5", - "pac-resolver": "^5.0.0", - "raw-body": "^2.2.0", - "socks-proxy-agent": "5" + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "pac-resolver": "^7.0.0", + "socks-proxy-agent": "^8.0.1" }, "engines": { - "node": ">= 8" + "node": ">= 14" } }, "node_modules/pac-resolver": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-5.0.1.tgz", - "integrity": "sha512-cy7u00ko2KVgBAjuhevqpPeHIkCIqPe1v24cydhWjmeuzaBfmUWFCZJ1iAh5TuVzVZoUzXIW7K8sMYOZ84uZ9Q==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.0.tgz", + "integrity": "sha512-Fd9lT9vJbHYRACT8OhCbZBbxr6KRSawSovFpy8nDGshaK99S/EBhVIHp9+crhxrsZOuvLpgL1n23iyPg6Rl2hg==", "dev": true, "dependencies": { - "degenerator": "^3.0.2", - "ip": "^1.1.5", + "degenerator": "^5.0.0", + "ip": "^1.1.8", "netmask": "^2.0.2" }, "engines": { - "node": ">= 8" + "node": ">= 14" } }, "node_modules/pako": { @@ -12617,9 +12560,9 @@ "dev": true }, "node_modules/papaparse": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.2.tgz", - "integrity": "sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==" + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -12780,7 +12723,8 @@ "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.1", @@ -12794,6 +12738,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -12826,9 +12782,9 @@ } }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.27", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", + "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", "dev": true, "funding": [ { @@ -12838,10 +12794,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -13178,13 +13138,13 @@ } }, "node_modules/prisma": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.0.0.tgz", - "integrity": "sha512-KYWk83Fhi1FH59jSpavAYTt2eoMVW9YKgu8ci0kuUnt6Dup5Qy47pcB4/TLmiPAbhGrxxSz7gsSnJcCmkyPANA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.1.1.tgz", + "integrity": "sha512-WJFG/U7sMmcc6TjJTTifTfpI6Wjoh55xl4AzopVwAdyK68L9/ogNo8QQ2cxuUjJf/Wa82z/uhyh3wMzvRIBphg==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/engines": "5.0.0" + "@prisma/engines": "5.1.1" }, "bin": { "prisma": "build/index.js" @@ -13267,39 +13227,24 @@ } }, "node_modules/proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-5.0.0.tgz", - "integrity": "sha512-gkH7BkvLVkSfX9Dk27W6TyNOWWZWRilRfk1XxGNWOYJ2TuedAv1yFpCaU9QSBmBe716XOTNpYNOzhysyw8xn7g==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.0.tgz", + "integrity": "sha512-0LdR757eTj/JfuU7TL2YCuAZnxWXu3tkJbg4Oq3geW/qFNT/32T0sp2HnZ9O0lMR4q3vwAt0+xCA8SR0WAD0og==", "dev": true, "dependencies": { - "agent-base": "^6.0.0", - "debug": "4", - "http-proxy-agent": "^4.0.0", - "https-proxy-agent": "^5.0.0", - "lru-cache": "^5.1.1", - "pac-proxy-agent": "^5.0.0", - "proxy-from-env": "^1.0.0", - "socks-proxy-agent": "^5.0.0" + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.1" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" + "node": ">= 14" } }, - "node_modules/proxy-agent/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -13423,9 +13368,9 @@ } }, "node_modules/react-bootstrap": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.6.tgz", - "integrity": "sha512-pSzYyJT5u4rc8+5myM8Vid2JG52L8AmYSkpznReH/GM4+FhLqEnxUa0+6HRTaGwjdEixQNGchwY+b3xCdYWrDA==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.7.tgz", + "integrity": "sha512-IzCYXuLSKDEjGFglbFWk0/iHmdhdcJzTmtS6lXxc0kaNFx2PFgrQf5jKnx5sarF2tiXh9Tgx3pSt3pdK7YwkMA==", "dependencies": { "@babel/runtime": "^7.14.0", "@restart/context": "^2.1.4", @@ -13521,11 +13466,11 @@ } }, "node_modules/react-router": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.2.tgz", - "integrity": "sha512-74z9xUSaSX07t3LM+pS6Un0T55ibUE/79CzfZpy5wsPDZaea1F8QkrsiyRnA2YQ7LwE/umaydzXZV80iDCPkMg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.14.2.tgz", + "integrity": "sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ==", "dependencies": { - "@remix-run/router": "1.6.2" + "@remix-run/router": "1.7.2" }, "engines": { "node": ">=14" @@ -13535,12 +13480,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.11.2.tgz", - "integrity": "sha512-JNbKtAeh1VSJQnH6RvBDNhxNwemRj7KxCzc5jb7zvDSKRnPWIFj9pO+eXqjM69gQJ0r46hSz1x4l9y0651DKWw==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.14.2.tgz", + "integrity": "sha512-5pWX0jdKR48XFZBuJqHosX3AAHjRAzygouMTyimnBPOLdY3WjzUSKhus2FVMihUFWzeLebDgr4r8UeQFAct7Bg==", "dependencies": { - "@remix-run/router": "1.6.2", - "react-router": "6.11.2" + "@remix-run/router": "1.7.2", + "react-router": "6.14.2" }, "engines": { "node": ">=14" @@ -14100,6 +14045,15 @@ "node": ">=4" } }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -14151,9 +14105,9 @@ } }, "node_modules/rollup": { - "version": "3.17.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.17.3.tgz", - "integrity": "sha512-p5LaCXiiOL/wrOkj8djsIDFmyU9ysUxcyW+EKRLHb6TKldJzXpImjcRSR+vgo09DBdofGcOoLOsRyxxG2n5/qQ==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.0.tgz", + "integrity": "sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -14267,9 +14221,9 @@ } }, "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -14458,17 +14412,17 @@ } }, "node_modules/socks-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-5.0.1.tgz", - "integrity": "sha512-vZdmnjb9a2Tz6WEQVIurybSwElwPxMZaIc7PzqbJTrezcKNznv6giT7J7tZDZ1BojVaa1jvO/UiUdhDVB0ACoQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.1.tgz", + "integrity": "sha512-59EjPbbgg8U3x62hhKOFVAmySQUcfRQ4C7Q/D5sEHnZTQRrQlNKINks44DMR1gwXp0p4LaVIeccX2KHTTcHVqQ==", "dev": true, "dependencies": { - "agent-base": "^6.0.2", - "debug": "4", - "socks": "^2.3.3" + "agent-base": "^7.0.1", + "debug": "^4.3.4", + "socks": "^2.7.1" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/socks/node_modules/ip": { @@ -14846,9 +14800,9 @@ } }, "node_modules/stylis": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", - "integrity": "sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" }, "node_modules/supports-color": { "version": "7.2.0", @@ -15290,9 +15244,9 @@ } }, "node_modules/tsconfck": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-2.0.3.tgz", - "integrity": "sha512-o3DsPZO1+C98KqHMdAbWs30zpxD30kj8r9OLA4ML1yghx4khNDzaaShNalfluh8ZPPhzKe3qyVCP1HiZszSAsw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-2.1.2.tgz", + "integrity": "sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==", "dev": true, "bin": { "tsconfck": "bin/tsconfck.js" @@ -15301,7 +15255,7 @@ "node": "^14.13.1 || ^16 || >=18" }, "peerDependencies": { - "typescript": "^4.3.5" + "typescript": "^4.3.5 || ^5.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -15702,6 +15656,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -15802,6 +15757,11 @@ "node": ">=10.12.0" } }, + "node_modules/valibot": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.11.1.tgz", + "integrity": "sha512-35v3J5FFHuxr/erQKASwy1VmbI9bc/GJzBd1QZWccXr3lh0xq6a/nzQfI49xBjJeHrAyFfbopr7ZT2TwkMFuPw==" + }, "node_modules/validate.io-array": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", @@ -15887,15 +15847,14 @@ } }, "node_modules/vite": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.1.4.tgz", - "integrity": "sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz", + "integrity": "sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==", "dev": true, "dependencies": { - "esbuild": "^0.16.14", - "postcss": "^8.4.21", - "resolve": "^1.22.1", - "rollup": "^3.10.0" + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" }, "bin": { "vite": "bin/vite.js" @@ -15903,12 +15862,16 @@ "engines": { "node": "^14.18.0 || >=16.0.0" }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@types/node": ">= 14", "less": "*", + "lightningcss": "^1.21.0", "sass": "*", "stylus": "*", "sugarss": "*", @@ -15921,6 +15884,9 @@ "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -15970,9 +15936,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz", - "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", "cpu": [ "x64" ], @@ -15986,9 +15952,9 @@ } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz", - "integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, "bin": { @@ -15998,28 +15964,28 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.16.17", - "@esbuild/android-arm64": "0.16.17", - "@esbuild/android-x64": "0.16.17", - "@esbuild/darwin-arm64": "0.16.17", - "@esbuild/darwin-x64": "0.16.17", - "@esbuild/freebsd-arm64": "0.16.17", - "@esbuild/freebsd-x64": "0.16.17", - "@esbuild/linux-arm": "0.16.17", - "@esbuild/linux-arm64": "0.16.17", - "@esbuild/linux-ia32": "0.16.17", - "@esbuild/linux-loong64": "0.16.17", - "@esbuild/linux-mips64el": "0.16.17", - "@esbuild/linux-ppc64": "0.16.17", - "@esbuild/linux-riscv64": "0.16.17", - "@esbuild/linux-s390x": "0.16.17", - "@esbuild/linux-x64": "0.16.17", - "@esbuild/netbsd-x64": "0.16.17", - "@esbuild/openbsd-x64": "0.16.17", - "@esbuild/sunos-x64": "0.16.17", - "@esbuild/win32-arm64": "0.16.17", - "@esbuild/win32-ia32": "0.16.17", - "@esbuild/win32-x64": "0.16.17" + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" } }, "node_modules/vitest": { @@ -16112,22 +16078,6 @@ "node": ">=12" } }, - "node_modules/vm2": { - "version": "3.9.19", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz", - "integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==", - "dev": true, - "dependencies": { - "acorn": "^8.7.0", - "acorn-walk": "^8.2.0" - }, - "bin": { - "vm2": "bin/vm2" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -16304,9 +16254,9 @@ } }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -16425,15 +16375,6 @@ "node": ">=8.9.0" } }, - "node_modules/xregexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", - "integrity": "sha512-xl/50/Cf32VsGq/1R8jJE5ajH1yMCQkpmoS10QbFZWl2Oor4H0Me64Pu2yxvsRWK3m6soJbmGfzSR7BYmDcWAA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index fd78b0c0..4ae375e3 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,13 @@ ], "dependencies": { "@i-vresse/wb-core": "^1.2.0", - "@prisma/client": "^5.0.0", - "@remix-run/node": "^1.16.1", - "@remix-run/react": "^1.16.1", - "@remix-run/serve": "^1.16.1", + "@prisma/client": "^5.1.1", + "@remix-run/css-bundle": "^1.19.2", + "@remix-run/node": "^1.19.2", + "@remix-run/react": "^1.19.2", + "@remix-run/serve": "^1.19.2", "bcryptjs": "^2.4.3", - "isbot": "^3.6.5", + "isbot": "^3.6.8", "jose": "^4.13.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", @@ -41,13 +42,14 @@ "remix-auth-form": "^1.3.0", "remix-auth-github": "^1.4.0", "remix-auth-keycloak": "^1.2.0", - "remix-auth-oauth2": "^1.8.0" + "remix-auth-oauth2": "^1.8.0", + "valibot": "^0.11.1" }, "devDependencies": { "@ltd/j-toml": "^1.38.0", "@openapitools/openapi-generator-cli": "^2.7.0", - "@remix-run/dev": "^1.16.1", - "@remix-run/eslint-config": "^1.16.1", + "@remix-run/dev": "^1.19.2", + "@remix-run/eslint-config": "^1.19.2", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.9", "@testing-library/jest-dom": "^5.16.5", @@ -57,8 +59,8 @@ "@types/eslint": "^8.37.0", "@types/js-yaml": "^4.0.5", "@types/jszip": "^3.4.1", - "@types/react": "^18.0.25", - "@types/react-dom": "^18.0.8", + "@types/react": "^18.0.35", + "@types/react-dom": "^18.0.11", "@vitest/coverage-c8": "^0.31.2", "daisyui": "^2.51.0", "eslint": "^8.38.0", @@ -66,7 +68,7 @@ "happy-dom": "^9.20.3", "prettier": "^2.8.7", "prettier-plugin-tailwindcss": "^0.2.7", - "prisma": "^5.0.0", + "prisma": "^5.1.1", "tailwindcss": "^3.2.7", "ts-node": "^10.9.1", "typescript": "~4.8.4", diff --git a/remix.config.js b/remix.config.js index f93054a1..ad9a12ce 100644 --- a/remix.config.js +++ b/remix.config.js @@ -6,4 +6,13 @@ module.exports = { // serverBuildPath: "build/index.js", // publicPath: "/build/", tailwind: true, + serverModuleFormat: "cjs", + future: { + v2_dev: true, + v2_errorBoundary: true, + v2_headers: true, + v2_meta: true, + v2_normalizeFormMethod: true, + v2_routeConvention: true, + }, }; From e37c357e2b6aa44ce0b1e7fb7731942ccad81b87 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 12:20:51 +0200 Subject: [PATCH 20/45] Use validot on register form + Write down tech choices --- .gitignore | 4 +- README.md | 4 + app/components/ErrorMessages.tsx | 26 ++++++ app/routes/login.tsx | 2 +- app/routes/profile.tsx | 3 +- app/routes/register.tsx | 140 ++++++++++++++++++++++--------- docs/stack.md | 56 +++++++++++++ 7 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 app/components/ErrorMessages.tsx create mode 100644 docs/stack.md diff --git a/.gitignore b/.gitignore index dc10f472..fad0d964 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ node_modules /prisma/dev.db /private_key.pem -/public_key.pem \ No newline at end of file +/public_key.pem + +Caddyfile diff --git a/README.md b/README.md index 4cda34ab..df5c4035 100644 --- a/README.md +++ b/README.md @@ -185,3 +185,7 @@ To fetch the latest catalogs run ```shell npm run catalogs ``` + +## Stack + +The tech stack is explained in [docs/stack.md](docs/stack.md). diff --git a/app/components/ErrorMessages.tsx b/app/components/ErrorMessages.tsx new file mode 100644 index 00000000..ff1c57ea --- /dev/null +++ b/app/components/ErrorMessages.tsx @@ -0,0 +1,26 @@ +import type { FlatErrors } from "valibot"; + +export function ErrorMessages({ + path, + errors, +}: { + path: string; + errors?: FlatErrors; +}) { + if (!errors) return <>; + let issues: [string, ...string[]] | undefined = undefined; + if (path === "root" && errors.root) { + issues = errors.root; + } else if (errors.nested[path] !== undefined) { + issues = errors.nested[path]; + } + if (!issues) return <>; + + return ( +
    + {issues.map((message: string) => ( +

    {message}

    + ))} +
    + ); +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 1218694b..6dd6fc12 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -45,7 +45,7 @@ export default function LoginPage() { id="email" name="email" type="email" - autoComplete="email" + autoComplete="username" className={inputStyle} required /> diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index c7e15ed9..f8eb6a5f 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -1,5 +1,4 @@ -import type { ActionArgs } from "@remix-run/node"; -import { json, type LoaderArgs } from "@remix-run/node"; +import { type ActionArgs, json, type LoaderArgs } from "@remix-run/node"; import { Form, Link, useSubmit } from "@remix-run/react"; import { mustBeAuthenticated } from "~/auth.server"; import { useUser } from "~/auth"; diff --git a/app/routes/register.tsx b/app/routes/register.tsx index d26cdb9e..d389f4e2 100644 --- a/app/routes/register.tsx +++ b/app/routes/register.tsx @@ -4,10 +4,22 @@ import { type LoaderArgs, redirect, } from "@remix-run/node"; -import { Form, Link } from "@remix-run/react"; +import { Form, Link, useActionData } from "@remix-run/react"; +import { + type FlatErrors, + custom, + email, + flatten, + minLength, + object, + safeParse, + string, +} from "valibot"; +import { Prisma } from "@prisma/client"; import { authenticator } from "~/auth.server"; import { register } from "~/models/user.server"; import { commitSession, getSession } from "~/session.server"; +import { ErrorMessages } from "../components/ErrorMessages"; export async function loader({ request }: LoaderArgs) { await authenticator.isAuthenticated(request, { @@ -16,30 +28,53 @@ export async function loader({ request }: LoaderArgs) { return json({}); } +const RegisterSchema = object( + { + username: string([email()]), + password: string([minLength(8)]), + password2: string([minLength(8)]), + }, + [ + custom( + ({ password, password2 }) => password === password2, + "The passwords do not match." + ), + ] +); + export async function action({ request }: ActionArgs) { const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - if (typeof username !== "string" || typeof password !== "string") { - return json( - { - errors: { - email: "Email is required", - password: "Password is required", - }, - }, - { status: 400 } - ); + const result = safeParse(RegisterSchema, Object.fromEntries(formData)); + if (!result.success) { + const errors = flatten(result.error); + return json({ errors }, { status: 400 }); + } + const { username, password } = result.data; + + try { + const user = await register(username, password); + // Login the just registered user + const session = await getSession(request.headers.get("cookie")); + session.set(authenticator.sessionKey, user.id); + const headers = new Headers({ "Set-Cookie": await commitSession(session) }); + return redirect("/", { headers }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + const uniqueConstraintErrorCode = "P2002"; + if (error.code === uniqueConstraintErrorCode) { + const errors: FlatErrors = { + nested: { username: ["This email is already registered."] }, + }; + return json({ errors }, { status: 400 }); + } + throw error; + } + throw error; } - const user = await register(username, password); - // Make just registered user logged in - const session = await getSession(request.headers.get("cookie")); - session.set(authenticator.sessionKey, user.id); - let headers = new Headers({ "Set-Cookie": await commitSession(session) }); - return redirect("/", { headers }); } export default function RegisterPage() { + const actionData = useActionData(); // Shared style between login and register. Extract if we use it more often const centeredColumn = "flex flex-col items-center gap-4"; const formStyle = @@ -53,27 +88,52 @@ export default function RegisterPage() {

    Register

    - - - {/* TODO add password confirmation */} +
    + + +
    +
    + + +
    + +
    + + +
    + diff --git a/docs/stack.md b/docs/stack.md new file mode 100644 index 00000000..761b4354 --- /dev/null +++ b/docs/stack.md @@ -0,0 +1,56 @@ +# Stack + +We want to make a haddock3 web application that uses + +- [bartender](https://github.com/i-VRESSE/bartender) for job execution. +- [workflow-builder](https://github.com/i-VRESSE/workflow-builder) to construct a Haddock3 workflow config file. +- [haddock3](https://github.com/haddocking/haddock3) to compute + +## OpenAPI client + +The bartender web service provides an OpenAPI specification. +To talk to the bartender web service, we use an OpenAPI client. + +A client can be generated with different tools. + +- [openapi-generator-cli](https://github.com/OpenAPITools/openapi-generator-cli) + - NodeJS wrapper around Java based [openapi-generator](https://github.com/OpenAPITools/openapi-generator) +- [swagger-js](https://github.com/swagger-api/swagger-js), dynamic client + - has no TypeScript support +- [swagger-codegen](https://github.com/swagger-api/swagger-codegen) + - must download jar file self + +Picked `openapi-generator-cli` because it is easy to install and use. + +## Meta framework + +A meta framework is a framework around a UI framework like React to build a web application with server side rendering (SSR). + +As workflow-builder is written in React we need to use a meta framework that is compatible with React. + +Looked at suggestions at + +- [Next.js](https://nextjs.org/) + - unable to intergrate workflow builder as it uses wasm import which webpack wants to resolve at build time instead of runtime. +- [Remix](https://remix.run/) + - workflow builder works without jumping through hoops +- Gatsby + +Picked Remix. + +## ORM + +- [Prisma](https://www.prisma.io/) + - does joins in client instead of database, so if we need to do a lot of joins we will want to switch to something else. +- TypeOrm +- Kysely +- db client + +Remix uses Prisma in their tutorial and blue stack, so we also picked Prisma to have least suprises. + +## Validation + +- zod +- valibot + +Picked valibot as it newer. From b68e0c72cb2f7c82c82d616a2b2453109b649769 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 15:33:25 +0200 Subject: [PATCH 21/45] Switched to remix v2 route file naming + switched to postgresql + docker fixed --- .dockerignore | 4 ++ .env.example | 2 +- .gitignore | 2 + Dockerfile | 8 ++- README.md | 16 +++-- app/auth.server.ts | 2 +- app/bartender_token.server.ts | 2 +- app/catalogs/index.server.ts | 3 +- app/components/admin/UserTableRow.tsx | 5 +- app/models/user.server.ts | 65 +++++++------------ app/routes/{index.tsx => _index.tsx} | 0 .../{admin/index.tsx => admin._index.tsx} | 0 .../{admin/users.tsx => admin.users.tsx} | 6 +- ...thorize.ts => auth.$provider.authorize.ts} | 2 + ...callback.ts => auth.$provider.callback.ts} | 0 app/routes/builder.tsx | 6 +- ...nput.zip].tsx => jobs.$id.[input.zip].tsx} | 0 ...put.zip].tsx => jobs.$id.[output.zip].tsx} | 0 .../{jobs/$id.tsx => jobs.$id._index.tsx} | 0 .../archive/$.ts => jobs.$id.archive.$.ts} | 0 .../{jobs/$id.edit.tsx => jobs.$id.edit.tsx} | 4 +- .../$id/files/$.ts => jobs.$id.files.$.ts} | 0 .../$id/stderr.tsx => jobs.$id.stderr.tsx} | 0 .../$id/stdout.tsx => jobs.$id.stdout.tsx} | 0 .../{jobs/$id/zip.tsx => jobs.$id.zip.tsx} | 0 .../{jobs/index.tsx => jobs._index.tsx} | 0 app/routes/profile.tsx | 30 +++++---- app/routes/upload.tsx | 2 +- docker-compose.dev.yml | 13 ++++ docker-compose.yml | 51 +++++++++++++-- package.json | 9 ++- prisma/Dockerfile | 13 ++++ .../20230809104359_init/migration.sql | 21 ++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 19 +++--- prisma/seed.mts | 27 ++++---- 36 files changed, 206 insertions(+), 109 deletions(-) create mode 100644 .dockerignore rename app/routes/{index.tsx => _index.tsx} (100%) rename app/routes/{admin/index.tsx => admin._index.tsx} (100%) rename app/routes/{admin/users.tsx => admin.users.tsx} (95%) rename app/routes/{auth/$provider/authorize.ts => auth.$provider.authorize.ts} (88%) rename app/routes/{auth/$provider/callback.ts => auth.$provider.callback.ts} (100%) rename app/routes/{jobs/$id/[input.zip].tsx => jobs.$id.[input.zip].tsx} (100%) rename app/routes/{jobs/$id/[output.zip].tsx => jobs.$id.[output.zip].tsx} (100%) rename app/routes/{jobs/$id.tsx => jobs.$id._index.tsx} (100%) rename app/routes/{jobs/$id/archive/$.ts => jobs.$id.archive.$.ts} (100%) rename app/routes/{jobs/$id.edit.tsx => jobs.$id.edit.tsx} (94%) rename app/routes/{jobs/$id/files/$.ts => jobs.$id.files.$.ts} (100%) rename app/routes/{jobs/$id/stderr.tsx => jobs.$id.stderr.tsx} (100%) rename app/routes/{jobs/$id/stdout.tsx => jobs.$id.stdout.tsx} (100%) rename app/routes/{jobs/$id/zip.tsx => jobs.$id.zip.tsx} (100%) rename app/routes/{jobs/index.tsx => jobs._index.tsx} (100%) create mode 100644 docker-compose.dev.yml create mode 100644 prisma/Dockerfile create mode 100644 prisma/migrations/20230809104359_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..36d8451c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +/postgres-data +/node_modules +/public/build +.git diff --git a/.env.example b/.env.example index 01beb7f0..5f5bcf74 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ -DATABASE_URL=file:./dev.db +DATABASE_URL="postgresql://postgres:postgres@localhost:5433/postgres" SESSION_SECRET= diff --git a/.gitignore b/.gitignore index fad0d964..bd245827 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ node_modules /public_key.pem Caddyfile + +postgres-data diff --git a/Dockerfile b/Dockerfile index 30cb8b29..8bfdbee8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ FROM base as deps WORKDIR /myapp -ADD package.json ./ +ADD package.json package-lock.json tsconfig.json ./ RUN npm install --production=false # Setup production node_modules @@ -18,8 +18,8 @@ FROM base as production-deps WORKDIR /myapp COPY --from=deps /myapp/node_modules /myapp/node_modules -ADD package.json ./ -RUN npm prune --production +ADD package.json package-lock.json ./ +RUN npm prune --production # Build the app FROM base as build @@ -29,6 +29,7 @@ WORKDIR /myapp COPY --from=deps /myapp/node_modules /myapp/node_modules ADD . . +RUN npx prisma generate RUN npm run build # Finally, build the production image with minimal footprint @@ -43,6 +44,7 @@ WORKDIR /myapp COPY --from=production-deps /myapp/node_modules /myapp/node_modules +COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma COPY --from=build /myapp/build /myapp/build COPY --from=build /myapp/public /myapp/public COPY --from=build /myapp/package.json /myapp/package.json diff --git a/README.md b/README.md index df5c4035..db515cf9 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ sequenceDiagram ```shell npm install cp .env.example .env -npx prisma db push -npx prisma db seed +# Start postgres database in docker container +npm run docker:dev +npm run setup # Create rsa key pair for signing & verifying JWT tokens for bartender web service openssl genpkey -algorithm RSA -out private_key.pem \ -pkeyopt rsa_keygen_bits:2048 @@ -101,13 +102,16 @@ Make sure to deploy the output of `remix build` ### Docker -The web application can be run inside a Docker container. +The web application can be run inside a Docker container together with all its dependent containers. Requirements: -1. [bartender repo](https://github.com/i-VRESSE/bartender) to be cloned in `../bartender` directory. -2. bartender repo should have [.env file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#environment-variables) -3. bartender repo should have a [config.yaml file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#configuration-file) +1. Private key `./private_key.pem` and public key `./public_key.pem`. +2. `./.env` file for haddock3 web application. +3. [bartender repo](https://github.com/i-VRESSE/bartender) to be cloned in `../bartender` directory. +4. bartender repo should have [.env file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#environment-variables) +5. bartender repo should have a [config.yaml file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#configuration-file) + 1. The `job_root_dir` key should be set to `/tmp/jobs` Build with diff --git a/app/auth.server.ts b/app/auth.server.ts index fd61fae7..e8557959 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -307,7 +307,7 @@ export async function mustBeAdmin(request: Request) { export async function mustBeAllowedToSubmit(request: Request) { const user = await getUser(request); - if (!isSubmitAllowed(user.preferredExpertiseLevel)) { + if (!isSubmitAllowed(user.preferredExpertiseLevel ?? '')) { throw json({ error: "Submit not allowed" }, { status: 403 }); } return user; diff --git a/app/bartender_token.server.ts b/app/bartender_token.server.ts index 9be76533..dc34a834 100644 --- a/app/bartender_token.server.ts +++ b/app/bartender_token.server.ts @@ -17,7 +17,7 @@ export class TokenGenerator { private privateKey: KeyLike | undefined = undefined; constructor( privateKeyFilename: string, - issuer: string = "bartender", + issuer = "bartender", lifespan = "8h" ) { this.privateKeyFilename = privateKeyFilename; diff --git a/app/catalogs/index.server.ts b/app/catalogs/index.server.ts index 1e3d25a2..a66bc92b 100644 --- a/app/catalogs/index.server.ts +++ b/app/catalogs/index.server.ts @@ -3,8 +3,9 @@ import type { ICatalog } from "@i-vresse/wb-core/dist/types"; import easy from "./haddock3.easy.json"; import expert from "./haddock3.expert.json"; import guru from "./haddock3.guru.json"; +import type { ExpertiseLevel } from "@prisma/client"; -export async function getCatalog(level: string) { +export async function getCatalog(level: ExpertiseLevel) { // Tried serverDependenciesToBundle in remix.config.js but it didn't work // Fallback to using dynamic import const { prepareCatalog } = await import("@i-vresse/wb-core/dist/catalog.js"); diff --git a/app/components/admin/UserTableRow.tsx b/app/components/admin/UserTableRow.tsx index ef3623af..e0a7dab9 100644 --- a/app/components/admin/UserTableRow.tsx +++ b/app/components/admin/UserTableRow.tsx @@ -1,8 +1,9 @@ import type { User } from "~/models/user.server"; +import type { ExpertiseLevel } from '@prisma/client' interface IProps { user: User; - expertiseLevels: string[]; + expertiseLevels: ExpertiseLevel[]; onUpdate: (data: FormData) => void; submitting: boolean; } @@ -13,7 +14,7 @@ export const UserTableRow = ({ onUpdate, submitting, }: IProps) => { - const usersExpertiseLevels = user.expertiseLevels.map((r) => r.name); + const usersExpertiseLevels = user.expertiseLevels; return (
    diff --git a/app/models/user.server.ts b/app/models/user.server.ts index eeb0021b..6252e489 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,14 +1,14 @@ import { db } from "~/utils/db.server"; import { compare, hash } from "bcryptjs"; +import { ExpertiseLevel } from '@prisma/client' + export interface User { readonly id: string; readonly email: string; - readonly expertiseLevels: { - readonly name: string; - }[]; + readonly expertiseLevels: ExpertiseLevel[]; readonly isAdmin: boolean; - readonly preferredExpertiseLevel: string; + readonly preferredExpertiseLevel: ExpertiseLevel | null; readonly bartenderToken: string | null; readonly bartenderTokenExpiresAt: number; } @@ -21,11 +21,7 @@ export type TokenLessUser = Omit< const userSelect = { id: true, email: true, - expertiseLevels: { - select: { - name: true, - }, - }, + expertiseLevels: true, isAdmin: true, preferredExpertiseLevel: true, bartenderToken: true, @@ -130,19 +126,16 @@ export async function listUsers(limit = 100, offset = 0) { return users; } -export async function listExpertiseLevels() { - const levels = await db.expertiseLevel.findMany({ - select: { - name: true, - }, - orderBy: { - name: "asc", - }, - }); - return levels.map((level) => level.name); +export function listExpertiseLevels() { + const array = Array.from(Object.values(ExpertiseLevel)); + if (array.length === 0) { + throw new Error("No expertise levels found"); + } + // cast needed for valibot.enumpType + return array as [ExpertiseLevel, ...ExpertiseLevel[]]; } -export async function assignExpertiseLevel(userId: string, level: string) { +export async function assignExpertiseLevel(userId: string, level: ExpertiseLevel) { // set preferred level to the assigned level if no preferred level is set const user = await getUserById(userId); const preferredExpertiseLevel = user.preferredExpertiseLevel @@ -156,9 +149,7 @@ export async function assignExpertiseLevel(userId: string, level: string) { data: { preferredExpertiseLevel, expertiseLevels: { - connect: { - name: level, - }, + push: level }, }, select: { @@ -167,15 +158,15 @@ export async function assignExpertiseLevel(userId: string, level: string) { }); } -export async function unassignExpertiseLevel(userId: string, level: string) { +export async function unassignExpertiseLevel(userId: string, level: ExpertiseLevel) { // set preferred level to the first remaining level if the preferred level is the one being removed const user = await getUserById(userId); + const remainingLevels = user + .expertiseLevels.map((level) => level) + .filter((name) => name !== level); let preferredExpertiseLevel = user.preferredExpertiseLevel; if (preferredExpertiseLevel === level) { - const remainingLevels = user - .expertiseLevels!.map((level) => level.name) - .filter((name) => name !== level); - preferredExpertiseLevel = remainingLevels[0] || ""; + preferredExpertiseLevel = remainingLevels[0] || null; } await db.user.update({ @@ -185,9 +176,7 @@ export async function unassignExpertiseLevel(userId: string, level: string) { data: { preferredExpertiseLevel, expertiseLevels: { - disconnect: { - name: level, - }, + set: remainingLevels, }, }, select: { @@ -198,33 +187,25 @@ export async function unassignExpertiseLevel(userId: string, level: string) { export async function setPreferredExpertiseLevel( userId: string, - level: string + preferredExpertiseLevel: ExpertiseLevel ) { const user = await db.user.findUnique({ where: { id: userId, }, select: { - expertiseLevels: { - select: { - name: true, - }, - }, + expertiseLevels: true, }, }); if (!user) { throw new Error("User not found"); } - const levels = user.expertiseLevels.map((level) => level.name); - if (!levels.includes(level)) { - throw new Error("User does not have this expertise level"); - } await db.user.update({ where: { id: userId, }, data: { - preferredExpertiseLevel: level, + preferredExpertiseLevel, }, select: { id: true, diff --git a/app/routes/index.tsx b/app/routes/_index.tsx similarity index 100% rename from app/routes/index.tsx rename to app/routes/_index.tsx diff --git a/app/routes/admin/index.tsx b/app/routes/admin._index.tsx similarity index 100% rename from app/routes/admin/index.tsx rename to app/routes/admin._index.tsx diff --git a/app/routes/admin/users.tsx b/app/routes/admin.users.tsx similarity index 95% rename from app/routes/admin/users.tsx rename to app/routes/admin.users.tsx index 4ea65dbd..aee9915c 100644 --- a/app/routes/admin/users.tsx +++ b/app/routes/admin.users.tsx @@ -14,7 +14,7 @@ import { mustBeAdmin } from "~/auth.server"; export async function loader({ request }: LoaderArgs) { await mustBeAdmin(request); const users = await listUsers(); - const expertiseLevels = await listExpertiseLevels(); + const expertiseLevels = listExpertiseLevels(); return json({ users, expertiseLevels, @@ -32,7 +32,7 @@ export async function action({ request }: ActionArgs) { if (isAdmin !== null) { await setIsAdmin(userId, isAdmin === "true"); } - const levels = await listExpertiseLevels(); + const levels = listExpertiseLevels(); for (const level of levels) { const levelState = formData.get(level); if (levelState !== null) { @@ -55,7 +55,7 @@ export default function AdminUsersPage() { - + diff --git a/app/routes/auth/$provider/authorize.ts b/app/routes/auth.$provider.authorize.ts similarity index 88% rename from app/routes/auth/$provider/authorize.ts rename to app/routes/auth.$provider.authorize.ts index 9f19c53b..da04cbed 100644 --- a/app/routes/auth/$provider/authorize.ts +++ b/app/routes/auth.$provider.authorize.ts @@ -10,6 +10,8 @@ export async function loader() { export const action = async ({ params, request }: ActionArgs) => { const provider = params.provider || ""; const socials = availableSocialLogins(); + console.log("socials", socials); + console.log("provider", provider); if (!socials.includes(provider)) { throw json("Not found", { status: 404 }); } diff --git a/app/routes/auth/$provider/callback.ts b/app/routes/auth.$provider.callback.ts similarity index 100% rename from app/routes/auth/$provider/callback.ts rename to app/routes/auth.$provider.callback.ts diff --git a/app/routes/builder.tsx b/app/routes/builder.tsx index 77a3d694..cbe904bc 100644 --- a/app/routes/builder.tsx +++ b/app/routes/builder.tsx @@ -18,13 +18,13 @@ export const loader = async ({ archive: string | undefined; }> => { const user = await getOptionalUser(request); - const level = user ? user.preferredExpertiseLevel : ""; + const level = user ? user.preferredExpertiseLevel ?? "" : ""; // When user does not have a level he/she // can still use builder with easy level // but cannot submit only download const catalogLevel = level === "" ? "easy" : level; const catalog = await getCatalog(catalogLevel); - return { catalog, submitAllowed: isSubmitAllowed(level), archive: undefined }; + return { catalog, submitAllowed: isSubmitAllowed(level ?? ''), archive: undefined }; }; export const action = async ({ request }: ActionArgs) => { @@ -36,7 +36,7 @@ export const action = async ({ request }: ActionArgs) => { const user = await mustBeAllowedToSubmit(request); const accessToken = await getBartenderTokenByUser(user); - const job = await submitJob(upload, accessToken!); + const job = await submitJob(upload, accessToken); const job_url = `/jobs/${job.id}`; return redirect(job_url); }; diff --git a/app/routes/jobs/$id/[input.zip].tsx b/app/routes/jobs.$id.[input.zip].tsx similarity index 100% rename from app/routes/jobs/$id/[input.zip].tsx rename to app/routes/jobs.$id.[input.zip].tsx diff --git a/app/routes/jobs/$id/[output.zip].tsx b/app/routes/jobs.$id.[output.zip].tsx similarity index 100% rename from app/routes/jobs/$id/[output.zip].tsx rename to app/routes/jobs.$id.[output.zip].tsx diff --git a/app/routes/jobs/$id.tsx b/app/routes/jobs.$id._index.tsx similarity index 100% rename from app/routes/jobs/$id.tsx rename to app/routes/jobs.$id._index.tsx diff --git a/app/routes/jobs/$id/archive/$.ts b/app/routes/jobs.$id.archive.$.ts similarity index 100% rename from app/routes/jobs/$id/archive/$.ts rename to app/routes/jobs.$id.archive.$.ts diff --git a/app/routes/jobs/$id.edit.tsx b/app/routes/jobs.$id.edit.tsx similarity index 94% rename from app/routes/jobs/$id.edit.tsx rename to app/routes/jobs.$id.edit.tsx index 8ac6bbb6..0e38ae9e 100644 --- a/app/routes/jobs/$id.edit.tsx +++ b/app/routes/jobs.$id.edit.tsx @@ -15,14 +15,14 @@ export const loader = async ({ params, request }: LoaderArgs) => { const jobId = jobIdFromParams(params); const user = await getUser(request); const level = user.preferredExpertiseLevel; - const catalog = await getCatalog(level); + const catalog = await getCatalog(level ?? ''); const token = await getBartenderTokenByUser(user); // Check that user can see job, otherwise throw 404 await getJobById(jobId, token); // return same shape as loader in ~/routes/builder.tsx return { catalog, - submitAllowed: isSubmitAllowed(level), + submitAllowed: isSubmitAllowed(level ?? ''), archive: `/jobs/${jobId}/input.zip`, jobId, }; diff --git a/app/routes/jobs/$id/files/$.ts b/app/routes/jobs.$id.files.$.ts similarity index 100% rename from app/routes/jobs/$id/files/$.ts rename to app/routes/jobs.$id.files.$.ts diff --git a/app/routes/jobs/$id/stderr.tsx b/app/routes/jobs.$id.stderr.tsx similarity index 100% rename from app/routes/jobs/$id/stderr.tsx rename to app/routes/jobs.$id.stderr.tsx diff --git a/app/routes/jobs/$id/stdout.tsx b/app/routes/jobs.$id.stdout.tsx similarity index 100% rename from app/routes/jobs/$id/stdout.tsx rename to app/routes/jobs.$id.stdout.tsx diff --git a/app/routes/jobs/$id/zip.tsx b/app/routes/jobs.$id.zip.tsx similarity index 100% rename from app/routes/jobs/$id/zip.tsx rename to app/routes/jobs.$id.zip.tsx diff --git a/app/routes/jobs/index.tsx b/app/routes/jobs._index.tsx similarity index 100% rename from app/routes/jobs/index.tsx rename to app/routes/jobs._index.tsx diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index f8eb6a5f..7231640d 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -2,24 +2,30 @@ import { type ActionArgs, json, type LoaderArgs } from "@remix-run/node"; import { Form, Link, useSubmit } from "@remix-run/react"; import { mustBeAuthenticated } from "~/auth.server"; import { useUser } from "~/auth"; -import { setPreferredExpertiseLevel } from "~/models/user.server"; +import { listExpertiseLevels, setPreferredExpertiseLevel } from "~/models/user.server"; +import { enumType, object, safeParse } from "valibot"; export const loader = async ({ request }: LoaderArgs) => { await mustBeAuthenticated(request); return json({}); }; + export const action = async ({ request }: ActionArgs) => { const userId = await mustBeAuthenticated(request); const formData = await request.formData(); - const preferredExpertiseLevel = formData.get("preferredExpertiseLevel"); - if ( - preferredExpertiseLevel !== null && - typeof preferredExpertiseLevel === "string" - ) { - await setPreferredExpertiseLevel(userId, preferredExpertiseLevel); + const ActionSchema = object({ + preferredExpertiseLevel: enumType(listExpertiseLevels()) + }); + const result = safeParse(ActionSchema, Object.fromEntries(formData)); + if (result.success) { + await setPreferredExpertiseLevel(userId, result.data.preferredExpertiseLevel); + } else { + const errors = result.error; + console.error(errors); + return json({ errors }, { status: 400 }); } - return null; + return null }; export default function Page() { @@ -39,19 +45,19 @@ export default function Page() {
      {user.expertiseLevels.map((level) => ( -
    • +
    • ))} diff --git a/app/routes/upload.tsx b/app/routes/upload.tsx index 31d1d757..ce1ea291 100644 --- a/app/routes/upload.tsx +++ b/app/routes/upload.tsx @@ -25,7 +25,7 @@ export const action = async ({ request }: ActionArgs) => { } const token = await getBartenderToken(request); - const job = await submitJob(upload, token!); + const job = await submitJob(upload, token); const job_url = `/jobs/${job.id}`; return redirect(job_url); }; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..7b933333 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,13 @@ +version: "3.7" +services: + userdb: + image: postgres:15.2-bullseye + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + ports: + - "5433:5432" + volumes: + - ./postgres-data:/var/lib/postgresql/data diff --git a/docker-compose.yml b/docker-compose.yml index 742d3541..cac5d5e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,10 +9,46 @@ services: depends_on: bartender: condition: service_started + webappdb: + condition: service_healthy ports: - "8080:8080" + env_file: + - ./.env environment: BARTENDER_API_URL: "http://bartender:8000" + DATABASE_URL: postgresql://postgres:postgres@webappdb:5432/postgres + volumes: + - type: bind + source: ./public_key.pem + target: /app/src/public_key.pem + + webappdb: + image: postgres:15.2-bullseye + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + volumes: + - webapp-db-data:/var/lib/postgresql/data + healthcheck: + test: pg_isready -U postgres + interval: 2s + timeout: 3s + retries: 40 + + webappmigrator: + build: + context: . + dockerfile: prisma/Dockerfile + command: npm run setup + restart: "no" + environment: + DATABASE_URL: postgresql://postgres:postgres@webappdb:5432/postgres + depends_on: + webappdb: + condition: service_healthy bartender: build: @@ -26,11 +62,14 @@ services: - type: bind source: ../bartender/config.yaml target: /app/src/config.yaml + - type: bind + source: ./private_key.pem + target: /app/src/private_key.pem # TODO use mount instead of Docker volume # so job root dir can be on a NFS share - bartender-job-data:/tmp/jobs depends_on: - db: + bartenderdb: condition: service_healthy environment: BARTENDER_HOST: 0.0.0.0 @@ -39,8 +78,11 @@ services: BARTENDER_DB_USER: bartender BARTENDER_DB_PASS: bartender BARTENDER_DB_BASE: bartender + BARTENDER_JOB_ROOT: /tmp/jobs - db: + # TODO user same database for webapp and bartender + # the tables do not overlap + bartenderdb: image: postgres:13.6-bullseye hostname: bartender-db environment: @@ -56,7 +98,7 @@ services: timeout: 3s retries: 40 - migrator: + bartendermigrator: build: context: ../bartender dockerfile: ./deploy/Dockerfile @@ -70,9 +112,10 @@ services: BARTENDER_DB_PASS: bartender BARTENDER_DB_BASE: bartender depends_on: - db: + bartenderdb: condition: service_healthy volumes: + webapp-db-data: bartender-db-data: bartender-job-data: diff --git a/package.json b/package.json index 4ae375e3..93fcc245 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "start": "remix-serve build", "test": "vitest", - "typecheck": "tsc" + "typecheck": "tsc", + "setup": "prisma generate && prisma migrate deploy && prisma db seed", + "docker:dev": "docker compose -f docker-compose.dev.yml up", + "psql:dev": "docker compose -f docker-compose.dev.yml exec userdb psql -U postgres -d postgres", + "docker:all": "docker compose -f docker-compose.dev.yml up" }, "prettier": {}, "eslintIgnore": [ @@ -22,7 +26,8 @@ "/build", "/public/build", "/.sessions", - "/coverage" + "/coverage", + "/postgres-data" ], "dependencies": { "@i-vresse/wb-core": "^1.2.0", diff --git a/prisma/Dockerfile b/prisma/Dockerfile new file mode 100644 index 00000000..c4667fd0 --- /dev/null +++ b/prisma/Dockerfile @@ -0,0 +1,13 @@ +# base node image +FROM node:18-bullseye-slim + +# set for base and all layer that inherit from it +ENV NODE_ENV production + +WORKDIR /myapp + +ADD package.json package-lock.json tsconfig.json ./ + +RUN npm install --production=false + +ADD prisma ./prisma diff --git a/prisma/migrations/20230809104359_init/migration.sql b/prisma/migrations/20230809104359_init/migration.sql new file mode 100644 index 00000000..23bd4edd --- /dev/null +++ b/prisma/migrations/20230809104359_init/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +CREATE TYPE "ExpertiseLevel" AS ENUM ('guru', 'expert', 'easy'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "bartenderToken" TEXT NOT NULL DEFAULT '', + "bartenderTokenExpiresAt" INTEGER NOT NULL DEFAULT 0, + "expertiseLevels" "ExpertiseLevel"[] DEFAULT ARRAY[]::"ExpertiseLevel"[], + "preferredExpertiseLevel" "ExpertiseLevel", + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91591a39..3865bdd4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,11 +6,18 @@ generator client { } datasource db { - provider = "sqlite" + provider = "postgresql" url = env("DATABASE_URL") } +enum ExpertiseLevel { + guru + expert + easy +} + model User { + id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -20,12 +27,6 @@ model User { isAdmin Boolean @default(false) bartenderToken String @default("") bartenderTokenExpiresAt Int @default(0) - expertiseLevels ExpertiseLevel[] - // TODO preferred level should be one of the expertise levels of the user - preferredExpertiseLevel String @default("") -} - -model ExpertiseLevel { - name String @id - users User[] + expertiseLevels ExpertiseLevel[] @default([]) + preferredExpertiseLevel ExpertiseLevel? } diff --git a/prisma/seed.mts b/prisma/seed.mts index b9fe11a6..9d338d93 100644 --- a/prisma/seed.mts +++ b/prisma/seed.mts @@ -1,21 +1,16 @@ import { PrismaClient } from "@prisma/client"; -const db = new PrismaClient(); + +const prisma = new PrismaClient(); async function seed() { - await Promise.all( - // TODO use createMany when postgresql is used - getExpertiseLevels().map(async (name) => { - return db.expertiseLevel.create({ - data: { - name, - }, - }); - }) - ); + console.log(`Database has been seeded. 🌱`); } -seed(); - -function getExpertiseLevels() { - return ["guru", "expert", "easy"]; -} +seed() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); From e5b166ed44a0f35deb21c2c7593518e3158743da Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 15:45:49 +0200 Subject: [PATCH 22/45] Format To ignore postgres-data during format had to add ignore in command. See https://github.com/prettier/prettier/issues/11568#issuecomment-1238132646 --- .env.example | 1 + README.md | 20 ++++++++++++++++---- app/auth.server.ts | 2 +- app/components/admin/UserTableRow.tsx | 2 +- app/models/user.server.ts | 20 +++++++++++++------- app/routes/builder.tsx | 6 +++++- app/routes/jobs.$id.edit.tsx | 4 ++-- app/routes/profile.tsx | 19 +++++++++++-------- docker-compose.dev.yml | 2 ++ docker-compose.yml | 2 +- package.json | 5 ++--- 11 files changed, 55 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 5f5bcf74..86638096 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5433/postgres" SESSION_SECRET= +# For social login see docs/auth.md diff --git a/README.md b/README.md index db515cf9..20bbb382 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,6 @@ sequenceDiagram ```shell npm install cp .env.example .env -# Start postgres database in docker container -npm run docker:dev -npm run setup # Create rsa key pair for signing & verifying JWT tokens for bartender web service openssl genpkey -algorithm RSA -out private_key.pem \ -pkeyopt rsa_keygen_bits:2048 @@ -38,6 +35,21 @@ openssl rsa -pubout -in private_key.pem -out public_key.pem ## Development +You need to have a Postgres database running. The easiest way is to use Docker: + +```sh +npm run docker:dev +``` + +(Stores data in `./postgres-data`) +(You can get a psql shell with `npm run psql:dev`) + +The database can be initialized with + +```sh +npm run setup +``` + From your terminal: ```sh @@ -111,7 +123,7 @@ Requirements: 3. [bartender repo](https://github.com/i-VRESSE/bartender) to be cloned in `../bartender` directory. 4. bartender repo should have [.env file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#environment-variables) 5. bartender repo should have a [config.yaml file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#configuration-file) - 1. The `job_root_dir` key should be set to `/tmp/jobs` + 1. The `job_root_dir` key should be set to `/tmp/jobs` Build with diff --git a/app/auth.server.ts b/app/auth.server.ts index e8557959..d9b0b08b 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -307,7 +307,7 @@ export async function mustBeAdmin(request: Request) { export async function mustBeAllowedToSubmit(request: Request) { const user = await getUser(request); - if (!isSubmitAllowed(user.preferredExpertiseLevel ?? '')) { + if (!isSubmitAllowed(user.preferredExpertiseLevel ?? "")) { throw json({ error: "Submit not allowed" }, { status: 403 }); } return user; diff --git a/app/components/admin/UserTableRow.tsx b/app/components/admin/UserTableRow.tsx index e0a7dab9..fcb1b7f6 100644 --- a/app/components/admin/UserTableRow.tsx +++ b/app/components/admin/UserTableRow.tsx @@ -1,5 +1,5 @@ import type { User } from "~/models/user.server"; -import type { ExpertiseLevel } from '@prisma/client' +import type { ExpertiseLevel } from "@prisma/client"; interface IProps { user: User; diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 6252e489..d7b441a7 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,7 +1,7 @@ import { db } from "~/utils/db.server"; import { compare, hash } from "bcryptjs"; -import { ExpertiseLevel } from '@prisma/client' +import { ExpertiseLevel } from "@prisma/client"; export interface User { readonly id: string; @@ -135,7 +135,10 @@ export function listExpertiseLevels() { return array as [ExpertiseLevel, ...ExpertiseLevel[]]; } -export async function assignExpertiseLevel(userId: string, level: ExpertiseLevel) { +export async function assignExpertiseLevel( + userId: string, + level: ExpertiseLevel +) { // set preferred level to the assigned level if no preferred level is set const user = await getUserById(userId); const preferredExpertiseLevel = user.preferredExpertiseLevel @@ -149,7 +152,7 @@ export async function assignExpertiseLevel(userId: string, level: ExpertiseLevel data: { preferredExpertiseLevel, expertiseLevels: { - push: level + push: level, }, }, select: { @@ -158,12 +161,15 @@ export async function assignExpertiseLevel(userId: string, level: ExpertiseLevel }); } -export async function unassignExpertiseLevel(userId: string, level: ExpertiseLevel) { +export async function unassignExpertiseLevel( + userId: string, + level: ExpertiseLevel +) { // set preferred level to the first remaining level if the preferred level is the one being removed const user = await getUserById(userId); - const remainingLevels = user - .expertiseLevels.map((level) => level) - .filter((name) => name !== level); + const remainingLevels = user.expertiseLevels + .map((level) => level) + .filter((name) => name !== level); let preferredExpertiseLevel = user.preferredExpertiseLevel; if (preferredExpertiseLevel === level) { preferredExpertiseLevel = remainingLevels[0] || null; diff --git a/app/routes/builder.tsx b/app/routes/builder.tsx index cbe904bc..b2bf779f 100644 --- a/app/routes/builder.tsx +++ b/app/routes/builder.tsx @@ -24,7 +24,11 @@ export const loader = async ({ // but cannot submit only download const catalogLevel = level === "" ? "easy" : level; const catalog = await getCatalog(catalogLevel); - return { catalog, submitAllowed: isSubmitAllowed(level ?? ''), archive: undefined }; + return { + catalog, + submitAllowed: isSubmitAllowed(level ?? ""), + archive: undefined, + }; }; export const action = async ({ request }: ActionArgs) => { diff --git a/app/routes/jobs.$id.edit.tsx b/app/routes/jobs.$id.edit.tsx index 0e38ae9e..95b829aa 100644 --- a/app/routes/jobs.$id.edit.tsx +++ b/app/routes/jobs.$id.edit.tsx @@ -15,14 +15,14 @@ export const loader = async ({ params, request }: LoaderArgs) => { const jobId = jobIdFromParams(params); const user = await getUser(request); const level = user.preferredExpertiseLevel; - const catalog = await getCatalog(level ?? ''); + const catalog = await getCatalog(level ?? 'easy'); const token = await getBartenderTokenByUser(user); // Check that user can see job, otherwise throw 404 await getJobById(jobId, token); // return same shape as loader in ~/routes/builder.tsx return { catalog, - submitAllowed: isSubmitAllowed(level ?? ''), + submitAllowed: isSubmitAllowed(level ?? ""), archive: `/jobs/${jobId}/input.zip`, jobId, }; diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index 7231640d..e92036f8 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -2,7 +2,10 @@ import { type ActionArgs, json, type LoaderArgs } from "@remix-run/node"; import { Form, Link, useSubmit } from "@remix-run/react"; import { mustBeAuthenticated } from "~/auth.server"; import { useUser } from "~/auth"; -import { listExpertiseLevels, setPreferredExpertiseLevel } from "~/models/user.server"; +import { + listExpertiseLevels, + setPreferredExpertiseLevel, +} from "~/models/user.server"; import { enumType, object, safeParse } from "valibot"; export const loader = async ({ request }: LoaderArgs) => { @@ -10,22 +13,24 @@ export const loader = async ({ request }: LoaderArgs) => { return json({}); }; - export const action = async ({ request }: ActionArgs) => { const userId = await mustBeAuthenticated(request); const formData = await request.formData(); const ActionSchema = object({ - preferredExpertiseLevel: enumType(listExpertiseLevels()) + preferredExpertiseLevel: enumType(listExpertiseLevels()), }); const result = safeParse(ActionSchema, Object.fromEntries(formData)); if (result.success) { - await setPreferredExpertiseLevel(userId, result.data.preferredExpertiseLevel); + await setPreferredExpertiseLevel( + userId, + result.data.preferredExpertiseLevel + ); } else { const errors = result.error; console.error(errors); return json({ errors }, { status: 400 }); } - return null + return null; }; export default function Page() { @@ -53,9 +58,7 @@ export default function Page() { className="radio" name="preferredExpertiseLevel" value={level} - defaultChecked={ - user.preferredExpertiseLevel === level - } + defaultChecked={user.preferredExpertiseLevel === level} />{" "} {level} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7b933333..28e4f5a3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,6 +8,8 @@ services: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=postgres ports: + # Expose on port 5433 instead of 5432 + # as it is used by the bartender database container. - "5433:5432" volumes: - ./postgres-data:/var/lib/postgresql/data diff --git a/docker-compose.yml b/docker-compose.yml index cac5d5e8..9f57e003 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: environment: BARTENDER_API_URL: "http://bartender:8000" DATABASE_URL: postgresql://postgres:postgres@webappdb:5432/postgres - volumes: + volumes: - type: bind source: ./public_key.pem target: /app/src/public_key.pem diff --git a/package.json b/package.json index 93fcc245..2aa2a796 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "catalogs:guru": "curl -L https://github.com/i-VRESSE/workflow-builder/raw/main/packages/haddock3_catalog/public/catalog/haddock3.guru.yaml | npx js-yaml > app/catalogs/haddock3.guru.json", "catalogs": "npm run catalogs:easy && npm run catalogs:expert && npm run catalogs:guru", "dev": "remix dev", - "format": "prettier --write .", + "format": "prettier --write . '!postgres-data'", "generate-client": "openapi-generator-cli generate -g typescript-fetch -i http://localhost:8000/api/openapi.json -o app/bartender-client", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "start": "remix-serve build", @@ -17,8 +17,7 @@ "typecheck": "tsc", "setup": "prisma generate && prisma migrate deploy && prisma db seed", "docker:dev": "docker compose -f docker-compose.dev.yml up", - "psql:dev": "docker compose -f docker-compose.dev.yml exec userdb psql -U postgres -d postgres", - "docker:all": "docker compose -f docker-compose.dev.yml up" + "psql:dev": "docker compose -f docker-compose.dev.yml exec userdb psql -U postgres -d postgres" }, "prettier": {}, "eslintIgnore": [ From 13eff96057485359192214c1655ba714cd688ea1 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 15:51:15 +0200 Subject: [PATCH 23/45] More format --- app/routes/jobs.$id.edit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/jobs.$id.edit.tsx b/app/routes/jobs.$id.edit.tsx index 95b829aa..5f9ba04e 100644 --- a/app/routes/jobs.$id.edit.tsx +++ b/app/routes/jobs.$id.edit.tsx @@ -15,7 +15,7 @@ export const loader = async ({ params, request }: LoaderArgs) => { const jobId = jobIdFromParams(params); const user = await getUser(request); const level = user.preferredExpertiseLevel; - const catalog = await getCatalog(level ?? 'easy'); + const catalog = await getCatalog(level ?? "easy"); const token = await getBartenderTokenByUser(user); // Check that user can see job, otherwise throw 404 await getJobById(jobId, token); From 631959a83babccf8978946c92a36838b4135f845 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 16:33:34 +0200 Subject: [PATCH 24/45] fix todo --- docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9f57e003..5d3e4987 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,8 +65,7 @@ services: - type: bind source: ./private_key.pem target: /app/src/private_key.pem - # TODO use mount instead of Docker volume - # so job root dir can be on a NFS share + # to store jobs on NFS share replace line below with bind mount - bartender-job-data:/tmp/jobs depends_on: bartenderdb: From 177bec27608c7adca4f1afe2da74edfbc51e587a Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 16:33:47 +0200 Subject: [PATCH 25/45] Remove console.log --- app/routes/profile.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index e92036f8..fafb9863 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -27,7 +27,6 @@ export const action = async ({ request }: ActionArgs) => { ); } else { const errors = result.error; - console.error(errors); return json({ errors }, { status: 400 }); } return null; From 335c13d52e213598ca58ff3c0f557cf2418dbdf6 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 16:34:43 +0200 Subject: [PATCH 26/45] Remove console.log --- app/routes/auth.$provider.authorize.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/routes/auth.$provider.authorize.ts b/app/routes/auth.$provider.authorize.ts index da04cbed..9f19c53b 100644 --- a/app/routes/auth.$provider.authorize.ts +++ b/app/routes/auth.$provider.authorize.ts @@ -10,8 +10,6 @@ export async function loader() { export const action = async ({ params, request }: ActionArgs) => { const provider = params.provider || ""; const socials = availableSocialLogins(); - console.log("socials", socials); - console.log("provider", provider); if (!socials.includes(provider)) { throw json("Not found", { status: 404 }); } From b9563c01b30fffe0044eebe64f5c8421ca11cc93 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 16:35:43 +0200 Subject: [PATCH 27/45] Add validation to login form --- app/auth.server.ts | 35 ++++++++-------- app/models/user.server.ts | 16 +++++++- app/routes/login.tsx | 86 +++++++++++++++++++++++++++------------ 3 files changed, 90 insertions(+), 47 deletions(-) diff --git a/app/auth.server.ts b/app/auth.server.ts index d9b0b08b..c9dafa26 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -21,27 +21,26 @@ import { localLogin, oauthregister, } from "./models/user.server"; +import { email, minLength, object, parse, string } from "valibot"; // Create an instance of the authenticator, pass a generic with what // strategies will return and will store in the session -export let authenticator = new Authenticator(sessionStorage, { +export const authenticator = new Authenticator(sessionStorage, { throwOnError: true, }); +const CredentialsSchema = object({ + email: string([email()]), + password: string([minLength(8)]), +}); + // Tell the Authenticator to use the form strategy authenticator.use( new FormStrategy(async ({ form }) => { - let email = form.get("email"); - let password = form.get("password"); - if ( - email === null || - password == null || - typeof email !== "string" || - typeof password !== "string" - ) { - // TODO use zod for validation - throw new Error("Email and password must be filled."); - } + const { email, password } = parse( + CredentialsSchema, + Object.fromEntries(form) + ); const user = await localLogin(email, password); return user.id; }), @@ -58,7 +57,7 @@ class GitHubStrategyWithVerifiedEmail extends GitHubStrategy { // url & agent are private to super class so we have to copy them here const userEmailsURL = "https://api.github.com/user/emails"; const userAgent = "Haddock3WebApp"; - let response = await fetch(userEmailsURL, { + const response = await fetch(userEmailsURL, { headers: { Accept: "application/vnd.github.v3+json", Authorization: `token ${accessToken}`, @@ -66,13 +65,13 @@ class GitHubStrategyWithVerifiedEmail extends GitHubStrategy { }, }); - let data: { + const data: { email: string; verified: boolean; primary: boolean; visibility: string; }[] = await response.json(); - let emails: GitHubEmails = data + const emails: GitHubEmails = data .filter((e) => e.verified) .map(({ email }) => ({ value: email })); return emails; @@ -86,7 +85,7 @@ if ( const gitHubStrategy = new GitHubStrategyWithVerifiedEmail( { clientID: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_ID, - clientSecret: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET!, + clientSecret: process.env.HADDOCK3WEBAPP_GITHUB_CLIENT_SECRET, callbackURL: process.env.HADDOCK3WEBAPP_GITHUB_CALLBACK_URL || "http://localhost:3000/auth/github/callback", @@ -184,7 +183,7 @@ if ( const orcidStrategy = new OrcidStrategy( { clientID: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_ID, - clientSecret: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_SECRET!, + clientSecret: process.env.HADDOCK3WEBAPP_ORCID_CLIENT_SECRET, callbackURL: process.env.HADDOCK3WEBAPP_ORCID_CALLBACK_URL || "http://localhost:3000/auth/orcid/callback", @@ -243,7 +242,7 @@ if ( const egiStrategy = new EgiStrategy( { clientID: process.env.HADDOCK3WEBAPP_EGI_CLIENT_ID, - clientSecret: process.env.HADDOCK3WEBAPP_EGI_CLIENT_SECRET!, + clientSecret: process.env.HADDOCK3WEBAPP_EGI_CLIENT_SECRET, callbackURL: process.env.HADDOCK3WEBAPP_EGI_CALLBACK_URL || "http://localhost:3000/auth/egi/callback", diff --git a/app/models/user.server.ts b/app/models/user.server.ts index d7b441a7..d9c785b2 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -47,6 +47,18 @@ async function firstUserShouldBeAdmin() { return userCount === 0; } +export class UserNotFoundError extends Error { + constructor() { + super("User not found"); + } +} + +export class WrongPasswordError extends Error { + constructor() { + super("Wrong password"); + } +} + export async function localLogin(email: string, password: string) { const user = await db.user.findUnique({ where: { @@ -58,12 +70,12 @@ export async function localLogin(email: string, password: string) { }, }); if (!user) { - throw new Error("User not found"); + throw new UserNotFoundError(); } const { passwordHash, ...userWithoutPasswordHash } = user; const isValid = await compare(password, user.passwordHash || ""); if (!isValid) { - throw new Error("Wrong password"); + throw new WrongPasswordError(); } return userWithoutPasswordHash; diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 6dd6fc12..a09985fd 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -4,9 +4,13 @@ import { redirect, json, } from "@remix-run/node"; -import { Form, Link, useLoaderData } from "@remix-run/react"; +import { Form, Link, useActionData, useLoaderData } from "@remix-run/react"; +import { type FlatErrors, ValiError, flatten } from "valibot"; import { availableSocialLogins } from "~/auth"; +import { AuthorizationError } from "remix-auth"; import { authenticator, getOptionalUser } from "~/auth.server"; +import { ErrorMessages } from "~/components/ErrorMessages"; +import { UserNotFoundError, WrongPasswordError } from "~/models/user.server"; export async function loader({ request }: LoaderArgs) { const user = await getOptionalUser(request); @@ -18,13 +22,34 @@ export async function loader({ request }: LoaderArgs) { } export async function action({ request }: ActionArgs) { - return await authenticator.authenticate("user-pass", request, { - successRedirect: "/", - failureRedirect: "/login", - }); + try { + await authenticator.authenticate("user-pass", request); + return redirect("/"); + } catch (error) { + if (error instanceof AuthorizationError && error.cause) { + let errors: FlatErrors; + if (error.cause instanceof WrongPasswordError) { + errors = { + nested: { password: [error.message] }, + }; + } else if (error.cause instanceof UserNotFoundError) { + errors = { + nested: { email: [error.message] }, + }; + } else if (error.cause instanceof ValiError) { + errors = flatten(error.cause); + } else { + throw error; + } + return json({ errors }, { status: 400 }); + } + + throw error; + } } export default function LoginPage() { + const actionData = useActionData(); const { socials } = useLoaderData(); // Shared style between login and register. Extract if we use it more often? const centeredColumn = "flex flex-col items-center gap-4"; @@ -39,28 +64,35 @@ export default function LoginPage() {

      Log in with username and password

      - - +
      + + +
      +
      + + +
      From b5ffdf4e9bc7cdc987aa89ac4dc565e861bdc452 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 17:11:40 +0200 Subject: [PATCH 28/45] Go over todos --- app/auth.server.ts | 4 ++++ app/components/Navbar.tsx | 15 ++++++++++++--- app/models/applicaton.server.ts | 3 ++- app/models/job.server.ts | 2 +- app/routes/jobs.$id._index.tsx | 7 +++---- app/routes/jobs._index.tsx | 2 +- 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/auth.server.ts b/app/auth.server.ts index c9dafa26..2853017f 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -93,6 +93,7 @@ if ( }, async ({ profile }) => { // TODO store photo or avatar so it can be displayed in NavBar + // TODO store users display name in database for more personal greeting const primaryEmail = profile.emails[0].value; const userId = await oauthregister(primaryEmail); return userId; @@ -173,6 +174,7 @@ if ( const profileResponse = await fetch(this.profileEndpoint, { headers }); const profile = await profileResponse.json(); const emails = await this.userEmails(profile.sub); + // TODO store Orcid id into database return { ...profile, emails, @@ -255,6 +257,8 @@ if ( async ({ profile }) => { const primaryEmail = profile.emails![0].value; const userId = await oauthregister(primaryEmail); + // TODO store egi fields like orcid, eduperson or voperson into database + // in far future could be used to submit job on GRID with users credentials return userId; } ); diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index d38595e1..71b9797a 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -1,14 +1,23 @@ import { Link, NavLink } from "@remix-run/react"; -import { useIsAdmin, useIsLoggedIn } from "~/auth"; +import md5Hex from "md5-hex"; +import { useMemo } from "react"; +import { useIsAdmin, useIsLoggedIn, useUser } from "~/auth"; const LoggedInButton = () => { + const user = useUser(); + const gravatarHash = useMemo( + () => md5Hex(user.email.toLowerCase().trim()), + [user] + ); const isAdmin = useIsAdmin(); return (
        { const jobId = jobIdFromParams(params); const token = await getBartenderToken(request); - const job = await getJobById(jobId, token!); - // TODO check if job belongs to user + const job = await getJobById(jobId, token); let inputFiles: DirectoryItem | undefined = undefined; let outputFiles: DirectoryItem | undefined = undefined; if (CompletedJobs.has(job.state)) { - inputFiles = await listInputFiles(jobId, token!); - outputFiles = await listOutputFiles(jobId, token!); + inputFiles = await listInputFiles(jobId, token); + outputFiles = await listOutputFiles(jobId, token); } return json({ job, inputFiles, outputFiles }); }; diff --git a/app/routes/jobs._index.tsx b/app/routes/jobs._index.tsx index 46ec65e9..763b3479 100644 --- a/app/routes/jobs._index.tsx +++ b/app/routes/jobs._index.tsx @@ -11,7 +11,7 @@ export const loader = async ({ request }: LoaderArgs) => { export default function JobPage() { const { jobs } = useLoaderData(); - // TODO add pagination + // TODO add pagination, usefull for large number of jobs return (
    Batch Email Administrator? Expertise levels
    From ed7aeadeb62a3ab335142866301f155d7362a54f Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Wed, 9 Aug 2023 17:12:47 +0200 Subject: [PATCH 29/45] Install packages we import --- package-lock.json | 30 ++++++++++++++++++++++-------- package.json | 1 + 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2828b539..cc46fbbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "jose": "^4.13.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", + "md5-hex": "^4.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "remix-auth": "^3.4.0", @@ -5280,8 +5281,7 @@ "node_modules/blueimp-md5": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", - "dev": true + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==" }, "node_modules/body-parser": { "version": "1.20.1", @@ -6042,6 +6042,18 @@ "node": ">=10.18.0 <11 || >=12.14.0 <13 || >=14" } }, + "node_modules/concordance/node_modules/md5-hex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", + "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==", + "dev": true, + "dependencies": { + "blueimp-md5": "^2.10.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/concurrently": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-6.5.1.tgz", @@ -10952,15 +10964,17 @@ } }, "node_modules/md5-hex": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", - "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-4.0.0.tgz", + "integrity": "sha512-di38zHPn4Tz8LCb5Lz8SpLb/20Hv23aPXpF4Bq1mR5r9JuCZQ/JpcDUxFfZF9Ur5GiUvqS5NQOkR+fm5cYZ0IQ==", "dependencies": { - "blueimp-md5": "^2.10.0" + "blueimp-md5": "^2.18.0" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mdast-util-definitions": { diff --git a/package.json b/package.json index 2aa2a796..1be7b6a2 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "jose": "^4.13.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", + "md5-hex": "^4.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "remix-auth": "^3.4.0", From bfa4ed1285e73a51e953a14dc6fc825c3477d1ee Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Thu, 10 Aug 2023 12:41:47 +0200 Subject: [PATCH 30/45] generate svg from email as gravatar --- README.md | 2 +- app/auth.server.ts | 13 +++++++---- app/components/Navbar.tsx | 11 +--------- app/models/config.server.ts | 4 ++-- app/models/generatePhoto.test.ts | 22 +++++++++++++++++++ app/models/generatePhoto.ts | 18 +++++++++++++++ app/models/job.server.ts | 2 +- app/models/user.server.ts | 17 +++++++++++++- app/routes/login.tsx | 4 ++-- package-lock.json | 18 ++------------- package.json | 1 - .../20230809104359_init/migration.sql | 1 + prisma/schema.prisma | 1 + 13 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 app/models/generatePhoto.test.ts create mode 100644 app/models/generatePhoto.ts diff --git a/README.md b/README.md index 20bbb382..60ec0dec 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ BARTENDER_API_URL=http://localhost:8000 The haddock3 web application must be trusted by the bartender web service using a JWT token. An RSA private key is used by the haddock3 web application to sign the JWT token. -To tell the bartender web service where to find the private key, use the `BARTENDER_PRIVATE_KEY` environment variable. +To tell the haddock3 web application where to find the private key, use the `BARTENDER_PRIVATE_KEY` environment variable. ```sh BARTENDER_PRIVATE_KEY=private_key.pem diff --git a/app/auth.server.ts b/app/auth.server.ts index 2853017f..33fb30df 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -92,10 +92,10 @@ if ( userAgent: "Haddock3WebApp", }, async ({ profile }) => { - // TODO store photo or avatar so it can be displayed in NavBar // TODO store users display name in database for more personal greeting const primaryEmail = profile.emails[0].value; - const userId = await oauthregister(primaryEmail); + const photo = profile.photos[0].value ?? undefined; + const userId = await oauthregister(primaryEmail, photo); return userId; } ); @@ -193,7 +193,8 @@ if ( }, async ({ profile }) => { const primaryEmail = profile.emails![0].value; - const userId = await oauthregister(primaryEmail); + const photo = profile.photos![0].value ?? undefined; + const userId = await oauthregister(primaryEmail, photo); return userId; } ); @@ -256,7 +257,11 @@ if ( }, async ({ profile }) => { const primaryEmail = profile.emails![0].value; - const userId = await oauthregister(primaryEmail); + if (!profile._json.email_verified) { + throw new Error("Email not verified"); + } + const photo = profile.photos![0].value ?? undefined; + const userId = await oauthregister(primaryEmail, photo); // TODO store egi fields like orcid, eduperson or voperson into database // in far future could be used to submit job on GRID with users credentials return userId; diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 71b9797a..c00f7695 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -1,23 +1,14 @@ import { Link, NavLink } from "@remix-run/react"; -import md5Hex from "md5-hex"; -import { useMemo } from "react"; import { useIsAdmin, useIsLoggedIn, useUser } from "~/auth"; const LoggedInButton = () => { const user = useUser(); - const gravatarHash = useMemo( - () => md5Hex(user.email.toLowerCase().trim()), - [user] - ); const isAdmin = useIsAdmin(); return (
      { + accessToken: (name: string | undefined) => { if (name === "OAuth2PasswordBearer") { return `Bearer ${accessToken}`; } diff --git a/app/models/generatePhoto.test.ts b/app/models/generatePhoto.test.ts new file mode 100644 index 00000000..3a98fef5 --- /dev/null +++ b/app/models/generatePhoto.test.ts @@ -0,0 +1,22 @@ +import {describe, test, expect} from 'vitest' + +import { generatePhoto } from "./generatePhoto"; + +describe('generatePhoto', () => { + test.each([ + ['john@example.com', 'J'], + ['john.doe@example.com', 'JD'], + ['john.van.doe@example.com', 'JVD'], + ])('should generate a photo from %s with %s text', (email, initials) => { + const photo = generatePhoto(email); + expect(photo).toContain('data:image/svg+xml'); + expect(photo).toContain(initials); + }); + + test('should generate a photo with a custom background and foreground color', () => { + const photo = generatePhoto('john@example.com', '#000000', '#ffffff'); + expect(photo).toContain('000000'); + expect(photo).toContain('ffffff'); + }) +}); + \ No newline at end of file diff --git a/app/models/generatePhoto.ts b/app/models/generatePhoto.ts new file mode 100644 index 00000000..40d58f4c --- /dev/null +++ b/app/models/generatePhoto.ts @@ -0,0 +1,18 @@ +function generateInitials(email: string): string { + const names = email.split("@")[0].split("."); + const initials = names.map((name) => name[0].toUpperCase()); + return initials.join(""); +} + +export function generatePhoto(email: string, bg = '#F2F3F9', fg = '#4177C1'): string { + // Default foreground and background colors are from tailwind.config.js + const initials = generateInitials(email); + const svg = ` + + + ${initials} + + `; + const svgData = encodeURIComponent(svg); + return `data:image/svg+xml;charset=UTF-8,${svgData}`; +} diff --git a/app/models/job.server.ts b/app/models/job.server.ts index 44eeae8a..4843e523 100644 --- a/app/models/job.server.ts +++ b/app/models/job.server.ts @@ -12,7 +12,7 @@ const BOOK_KEEPING_FILES = [ "workflow.cfg.orig", ]; -function buildJobApi(bartenderToken = "") { +function buildJobApi(bartenderToken: string) { return new JobApi(buildConfig(bartenderToken)); } diff --git a/app/models/user.server.ts b/app/models/user.server.ts index d9c785b2..35c8614c 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { db } from "~/utils/db.server"; import { compare, hash } from "bcryptjs"; import { ExpertiseLevel } from "@prisma/client"; +import { generatePhoto } from "./generatePhoto"; export interface User { readonly id: string; @@ -11,6 +13,7 @@ export interface User { readonly preferredExpertiseLevel: ExpertiseLevel | null; readonly bartenderToken: string | null; readonly bartenderTokenExpiresAt: number; + readonly photo: string; } export type TokenLessUser = Omit< @@ -26,24 +29,35 @@ const userSelect = { preferredExpertiseLevel: true, bartenderToken: true, bartenderTokenExpiresAt: true, + photo: true, } as const; export async function register(email: string, password: string) { const isAdmin = await firstUserShouldBeAdmin(); const passwordHash = await hash(password, 10); + const photo = generatePhoto(email); const user = await db.user.create({ data: { email, isAdmin, passwordHash, + photo, }, select: userSelect, }); return user; } +let USER_COUNT_CACHE = 0; + async function firstUserShouldBeAdmin() { + if (USER_COUNT_CACHE > 0) { + return false; + } const userCount = await db.user.count(); + if (userCount > 0) { + USER_COUNT_CACHE = 1; + } return userCount === 0; } @@ -81,7 +95,7 @@ export async function localLogin(email: string, password: string) { return userWithoutPasswordHash; } -export async function oauthregister(email: string) { +export async function oauthregister(email: string, photo?: string) { const isAdmin = await firstUserShouldBeAdmin(); const user = await db.user.upsert({ where: { @@ -90,6 +104,7 @@ export async function oauthregister(email: string) { create: { email, isAdmin, + photo: photo ?? generatePhoto(email), }, update: {}, select: { diff --git a/app/routes/login.tsx b/app/routes/login.tsx index a09985fd..03f9469f 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -23,8 +23,8 @@ export async function loader({ request }: LoaderArgs) { export async function action({ request }: ActionArgs) { try { - await authenticator.authenticate("user-pass", request); - return redirect("/"); + return await authenticator.authenticate("user-pass", request, { + successRedirect: "/"}); } catch (error) { if (error instanceof AuthorizationError && error.cause) { let errors: FlatErrors; diff --git a/package-lock.json b/package-lock.json index cc46fbbc..bef71a17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "jose": "^4.13.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", - "md5-hex": "^4.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "remix-auth": "^3.4.0", @@ -5281,7 +5280,8 @@ "node_modules/blueimp-md5": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==" + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", + "dev": true }, "node_modules/body-parser": { "version": "1.20.1", @@ -10963,20 +10963,6 @@ "node": ">=0.10.0" } }, - "node_modules/md5-hex": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-4.0.0.tgz", - "integrity": "sha512-di38zHPn4Tz8LCb5Lz8SpLb/20Hv23aPXpF4Bq1mR5r9JuCZQ/JpcDUxFfZF9Ur5GiUvqS5NQOkR+fm5cYZ0IQ==", - "dependencies": { - "blueimp-md5": "^2.18.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mdast-util-definitions": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", diff --git a/package.json b/package.json index 1be7b6a2..2aa2a796 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "jose": "^4.13.1", "js-yaml": "^4.1.0", "jszip": "^3.10.1", - "md5-hex": "^4.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "remix-auth": "^3.4.0", diff --git a/prisma/migrations/20230809104359_init/migration.sql b/prisma/migrations/20230809104359_init/migration.sql index 23bd4edd..b6cf5798 100644 --- a/prisma/migrations/20230809104359_init/migration.sql +++ b/prisma/migrations/20230809104359_init/migration.sql @@ -13,6 +13,7 @@ CREATE TABLE "User" ( "bartenderTokenExpiresAt" INTEGER NOT NULL DEFAULT 0, "expertiseLevels" "ExpertiseLevel"[] DEFAULT ARRAY[]::"ExpertiseLevel"[], "preferredExpertiseLevel" "ExpertiseLevel", + "photo" TEXT NOT NULL, CONSTRAINT "User_pkey" PRIMARY KEY ("id") ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3865bdd4..3b667b17 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,4 +29,5 @@ model User { bartenderTokenExpiresAt Int @default(0) expertiseLevels ExpertiseLevel[] @default([]) preferredExpertiseLevel ExpertiseLevel? + photo String } From 4a73c29510dabab32631592e8e44cdd232959afb Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Thu, 10 Aug 2023 12:51:15 +0200 Subject: [PATCH 31/45] Redirect messes up type of actionData so specify type instead of infering --- app/routes/login.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 03f9469f..1c9ff1eb 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -49,7 +49,7 @@ export async function action({ request }: ActionArgs) { } export default function LoginPage() { - const actionData = useActionData(); + const actionData = useActionData<{ errors: FlatErrors} | undefined>(); const { socials } = useLoaderData(); // Shared style between login and register. Extract if we use it more often? const centeredColumn = "flex flex-col items-center gap-4"; From c2d6ad2bde23d5d0c2d076377a945ba878f1ba32 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Thu, 10 Aug 2023 12:53:21 +0200 Subject: [PATCH 32/45] Format --- app/models/generatePhoto.test.ts | 33 ++++++++++++++++---------------- app/models/generatePhoto.ts | 6 +++++- app/models/user.server.ts | 1 - app/routes/login.tsx | 5 +++-- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/app/models/generatePhoto.test.ts b/app/models/generatePhoto.test.ts index 3a98fef5..bd625119 100644 --- a/app/models/generatePhoto.test.ts +++ b/app/models/generatePhoto.test.ts @@ -1,22 +1,21 @@ -import {describe, test, expect} from 'vitest' +import { describe, test, expect } from "vitest"; import { generatePhoto } from "./generatePhoto"; -describe('generatePhoto', () => { - test.each([ - ['john@example.com', 'J'], - ['john.doe@example.com', 'JD'], - ['john.van.doe@example.com', 'JVD'], - ])('should generate a photo from %s with %s text', (email, initials) => { - const photo = generatePhoto(email); - expect(photo).toContain('data:image/svg+xml'); - expect(photo).toContain(initials); - }); +describe("generatePhoto", () => { + test.each([ + ["john@example.com", "J"], + ["john.doe@example.com", "JD"], + ["john.van.doe@example.com", "JVD"], + ])("should generate a photo from %s with %s text", (email, initials) => { + const photo = generatePhoto(email); + expect(photo).toContain("data:image/svg+xml"); + expect(photo).toContain(initials); + }); - test('should generate a photo with a custom background and foreground color', () => { - const photo = generatePhoto('john@example.com', '#000000', '#ffffff'); - expect(photo).toContain('000000'); - expect(photo).toContain('ffffff'); - }) + test("should generate a photo with a custom background and foreground color", () => { + const photo = generatePhoto("john@example.com", "#000000", "#ffffff"); + expect(photo).toContain("000000"); + expect(photo).toContain("ffffff"); + }); }); - \ No newline at end of file diff --git a/app/models/generatePhoto.ts b/app/models/generatePhoto.ts index 40d58f4c..b27521d7 100644 --- a/app/models/generatePhoto.ts +++ b/app/models/generatePhoto.ts @@ -4,7 +4,11 @@ function generateInitials(email: string): string { return initials.join(""); } -export function generatePhoto(email: string, bg = '#F2F3F9', fg = '#4177C1'): string { +export function generatePhoto( + email: string, + bg = "#F2F3F9", + fg = "#4177C1" +): string { // Default foreground and background colors are from tailwind.config.js const initials = generateInitials(email); const svg = ` diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 35c8614c..a92cf865 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { db } from "~/utils/db.server"; import { compare, hash } from "bcryptjs"; diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 1c9ff1eb..dbe40854 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -24,7 +24,8 @@ export async function loader({ request }: LoaderArgs) { export async function action({ request }: ActionArgs) { try { return await authenticator.authenticate("user-pass", request, { - successRedirect: "/"}); + successRedirect: "/", + }); } catch (error) { if (error instanceof AuthorizationError && error.cause) { let errors: FlatErrors; @@ -49,7 +50,7 @@ export async function action({ request }: ActionArgs) { } export default function LoginPage() { - const actionData = useActionData<{ errors: FlatErrors} | undefined>(); + const actionData = useActionData<{ errors: FlatErrors } | undefined>(); const { socials } = useLoaderData(); // Shared style between login and register. Extract if we use it more often? const centeredColumn = "flex flex-col items-center gap-4"; From ab344079d17171ff5fda9495109fa4bd1f8eb41e Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 14 Aug 2023 09:21:26 +0200 Subject: [PATCH 33/45] Orcid & EGI lack profile.photos, fallback to generate photo --- app/auth.server.ts | 4 ++-- app/routes/auth.$provider.callback.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/auth.server.ts b/app/auth.server.ts index 33fb30df..fd60cc4b 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -193,7 +193,7 @@ if ( }, async ({ profile }) => { const primaryEmail = profile.emails![0].value; - const photo = profile.photos![0].value ?? undefined; + const photo = profile.photos ? profile.photos![0].value : undefined; const userId = await oauthregister(primaryEmail, photo); return userId; } @@ -260,7 +260,7 @@ if ( if (!profile._json.email_verified) { throw new Error("Email not verified"); } - const photo = profile.photos![0].value ?? undefined; + const photo = profile.photos ? profile.photos![0].value : undefined; const userId = await oauthregister(primaryEmail, photo); // TODO store egi fields like orcid, eduperson or voperson into database // in far future could be used to submit job on GRID with users credentials diff --git a/app/routes/auth.$provider.callback.ts b/app/routes/auth.$provider.callback.ts index b1513a44..ffafe956 100644 --- a/app/routes/auth.$provider.callback.ts +++ b/app/routes/auth.$provider.callback.ts @@ -7,4 +7,5 @@ export const loader = async ({ request, params }: LoaderArgs) => { successRedirect: "/", failureRedirect: "/login", }); + // TODO when auth fails, show message to user }; From fcc7c68f3bc6eccaa58526f84f5055f490222ec6 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 08:47:52 +0200 Subject: [PATCH 34/45] Remove unimplemented batch and action columns --- app/components/admin/UserTableRow.tsx | 8 -------- app/routes/admin.users.tsx | 2 -- 2 files changed, 10 deletions(-) diff --git a/app/components/admin/UserTableRow.tsx b/app/components/admin/UserTableRow.tsx index fcb1b7f6..5bdf9cb9 100644 --- a/app/components/admin/UserTableRow.tsx +++ b/app/components/admin/UserTableRow.tsx @@ -17,9 +17,6 @@ export const UserTableRow = ({ const usersExpertiseLevels = user.expertiseLevels; return (
    - - ); }; diff --git a/app/routes/admin.users.tsx b/app/routes/admin.users.tsx index aee9915c..80baef16 100644 --- a/app/routes/admin.users.tsx +++ b/app/routes/admin.users.tsx @@ -55,11 +55,9 @@ export default function AdminUsersPage() {
    - - {user.email} - -
    - - From 38210523d80ccc0b4cbf0fdffecfa42758692286 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 09:00:24 +0200 Subject: [PATCH 35/45] Add confirmation when toggling admin --- app/components/admin/UserTableRow.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/admin/UserTableRow.tsx b/app/components/admin/UserTableRow.tsx index 5bdf9cb9..3beb5209 100644 --- a/app/components/admin/UserTableRow.tsx +++ b/app/components/admin/UserTableRow.tsx @@ -25,6 +25,9 @@ export const UserTableRow = ({ className="checkbox" disabled={submitting} onChange={() => { + if (window.confirm("Are you sure?") === false) { + return; + } const data = new FormData(); data.set("isAdmin", user.isAdmin ? "false" : "true"); onUpdate(data); From 466663e2074761fce595e0a1d41f0a8805e5b697 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 09:04:59 +0200 Subject: [PATCH 36/45] Bind keys to right service --- docker-compose.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5d3e4987..0c27fb18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,10 +18,11 @@ services: environment: BARTENDER_API_URL: "http://bartender:8000" DATABASE_URL: postgresql://postgres:postgres@webappdb:5432/postgres + BARTENDER_PRIVATE_KEY: /app/src/private_key.pem volumes: - type: bind - source: ./public_key.pem - target: /app/src/public_key.pem + source: ./private_key.pem + target: /app/src/private_key.pem webappdb: image: postgres:15.2-bullseye @@ -63,8 +64,8 @@ services: source: ../bartender/config.yaml target: /app/src/config.yaml - type: bind - source: ./private_key.pem - target: /app/src/private_key.pem + source: ./public_key.pem + target: /app/src/public_key.pem # to store jobs on NFS share replace line below with bind mount - bartender-job-data:/tmp/jobs depends_on: @@ -78,6 +79,7 @@ services: BARTENDER_DB_PASS: bartender BARTENDER_DB_BASE: bartender BARTENDER_JOB_ROOT: /tmp/jobs + BARTENDER_PUBLIC_KEY: /app/src/public_key.pem # TODO user same database for webapp and bartender # the tables do not overlap From a477c3548e717129e1e81c0c141746692bb09849 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 10:25:45 +0200 Subject: [PATCH 37/45] Use same postgres image --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0c27fb18..dd2631a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: target: /app/src/private_key.pem webappdb: - image: postgres:15.2-bullseye + image: postgres:15.4-bullseye restart: always environment: - POSTGRES_USER=postgres @@ -84,7 +84,7 @@ services: # TODO user same database for webapp and bartender # the tables do not overlap bartenderdb: - image: postgres:13.6-bullseye + image: postgres:15.4-bullseye hostname: bartender-db environment: POSTGRES_PASSWORD: "bartender" From 441c87d551595bbcbd024de87e2ed47d517ad958 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 12:35:21 +0200 Subject: [PATCH 38/45] Haddock3 web app is issuer See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 --- app/bartender_token.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/bartender_token.server.ts b/app/bartender_token.server.ts index dc34a834..d7916893 100644 --- a/app/bartender_token.server.ts +++ b/app/bartender_token.server.ts @@ -17,7 +17,7 @@ export class TokenGenerator { private privateKey: KeyLike | undefined = undefined; constructor( privateKeyFilename: string, - issuer = "bartender", + issuer = "haddock3-webapp", lifespan = "8h" ) { this.privateKeyFilename = privateKeyFilename; From 055807a2f12dfc294a163fc82ebf997ff10437b5 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 12:35:55 +0200 Subject: [PATCH 39/45] Improve docs --- .env.example | 4 ++ README.md | 144 ++++++++++++---------------------------------- docs/auth.md | 10 +++- docs/bartender.md | 70 ++++++++++++++++++++++ docs/docker.md | 26 +++++++++ docs/stack.md | 3 + 6 files changed, 150 insertions(+), 107 deletions(-) create mode 100644 docs/bartender.md create mode 100644 docs/docker.md diff --git a/.env.example b/.env.example index 86638096..961ce2ae 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5433/postgres" +# See docs/bartender.md how to configure job submission +BARTENDER_API_URL=http://localhost:8000 +BARTENDER_PRIVATE_KEY=private_key.pem +# See docs/auth.md#session how generate a better secret SESSION_SECRET= # For social login see docs/auth.md diff --git a/README.md b/README.md index 60ec0dec..ae295119 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7990850.svg)](https://doi.org/10.5281/zenodo.7990850) [![fair-software.eu](https://img.shields.io/badge/fair--software.eu-%E2%97%8F%20%20%E2%97%8F%20%20%E2%97%8F%20%20%E2%97%8F%20%20%E2%97%8B-yellow)](https://fair-software.eu) +[Haddock3](https://github.com/haddocking/haddock3) (High Ambiguity Driven protein-protein DOCKing) is a an information-driven flexible docking approach for the modeling of biomolecular complexes. This software wraps the the haddock3 command line tool in a web application. The web application makes it easy to make a configuration file, run it and show the results. + Uses - [bartender](https://github.com/i-VRESSE/bartender) for job execution. @@ -20,22 +22,42 @@ sequenceDiagram Web app->>+Bartender: Result of job ``` -- [Remix Docs](https://remix.run/docs) - ## Setup +The web app is written in [Node.js](https://nodejs.org/) to install dependencies run: + ```shell npm install +``` + +Configuration of the web application is done via `.env` file or environment variables. +See [docs/auth.md](docs/auth.md) and [docs/bartender.md] for details. + +```shell cp .env.example .env -# Create rsa key pair for signing & verifying JWT tokens for bartender web service +``` + +Create rsa key pair for signing & verifying JWT tokens for bartender web service with: + +```shell openssl genpkey -algorithm RSA -out private_key.pem \ -pkeyopt rsa_keygen_bits:2048 openssl rsa -pubout -in private_key.pem -out public_key.pem ``` +## Bartender web service + +The bartender web service should be running if you want to submit jobs. +See [docs/bartender.md](docs/bartender.md) how to configure. + +## Authentication & authorization + +The web application comes with its own user management and social logins +See [docs/auth.md](docs/auth.md) how to configure. + ## Development -You need to have a Postgres database running. The easiest way is to use Docker: +You need to have a PostgreSQL database running. The easiest way is to use Docker: ```sh npm run docker:dev @@ -48,15 +70,24 @@ The database can be initialized with ```sh npm run setup +# This will generate prisma client, create tables and insert seed data ``` +(You can reset database with `npx prisma migrate reset`.) -From your terminal: +The database setup should be run only once for a fresh database. +Whenever you change the `prisma/schema.prisma` file you need to +1. Use [prisma migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate) to generate a migration and to update the database. +2. Run `npx prisma generate` to generate the prisma client. + +Start [remix](https://remix.run) development server from your terminal with: ```sh npm run dev ``` -This starts your app in development mode, rebuilding assets on file changes. +This will refresh & rebuild assets on file changes. + +## Other development commands To format according to [prettier](https://prettier.io) run @@ -101,106 +132,7 @@ Then run the app in production mode: npm start ``` -Now you'll need to pick a host to deploy it to. - -### DIY - -If you're familiar with deploying node applications, the built-in Remix app server is production-ready. - -Make sure to deploy the output of `remix build` - -- `build/` -- `public/build/` - -### Docker - -The web application can be run inside a Docker container together with all its dependent containers. - -Requirements: - -1. Private key `./private_key.pem` and public key `./public_key.pem`. -2. `./.env` file for haddock3 web application. -3. [bartender repo](https://github.com/i-VRESSE/bartender) to be cloned in `../bartender` directory. -4. bartender repo should have [.env file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#environment-variables) -5. bartender repo should have a [config.yaml file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#configuration-file) - 1. The `job_root_dir` key should be set to `/tmp/jobs` - -Build with - -```sh -docker compose build -``` - -Run with - -```sh -docker compose up -``` - -Web application running at http://localhost:8080 . - -## Authentication & authorization - -See [docs/auth.md](docs/auth.md). - -## Bartender web service client - -This web app uses a client to consume the bartender web service. - -The client can be (re-)generated with - -```shell -npm run generate-client -``` - -(This command requires that the bartender webservice is running at http://localhost:8000) - -## Bartender web service configuration - -The haddock3 web application needs to know where the [Bartender web service](https://github.com/i-VRESSE/bartender) is running. -Configure bartender location with `BARTENDER_API_URL` environment variable. - -```sh -BARTENDER_API_URL=http://localhost:8000 -``` - -The haddock3 web application must be trusted by the bartender web service using a JWT token. -An RSA private key is used by the haddock3 web application to sign the JWT token. -To tell the haddock3 web application where to find the private key, use the `BARTENDER_PRIVATE_KEY` environment variable. - -```sh -BARTENDER_PRIVATE_KEY=private_key.pem -``` - -An RSA public key is used by the bartender web service to verify the JWT token. -To tell the bartender web service where to find the public key, use the `BARTENDER_PUBLIC_KEY` environment variable. - -```sh -BARTENDER_PUBLIC_KEY=public_key.pem -``` - -## Haddock3 application - -This web app expects that the following application is registered in bartender web service. - -```yaml -applications: - haddock3: - command: haddock3 $config - config: workflow.cfg -``` - -This allows the archive generated with the workflow builder to be submitted. - -## Catalogs - -This repo has a copy (`./app/catalogs/*.yaml`) of the [haddock3 workflow build catalogs](https://github.com/i-VRESSE/workflow-builder/tree/main/packages/haddock3_catalog/public/catalog). - -To fetch the latest catalogs run - -```shell -npm run catalogs -``` +The web application can be run inside a Docker container together with all its dependent containers, see [docs/docker.md](docs/docker.md). ## Stack diff --git a/docs/auth.md b/docs/auth.md index ffe857f8..feafbfb6 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -1,6 +1,8 @@ # Authentication & authorization - [Authentication \& authorization](#authentication--authorization) + - [Session](#session) + - [Social logins](#social-logins) - [GitHub login](#github-login) - [Orcid sandbox login](#orcid-sandbox-login) - [Orcid login](#orcid-login) @@ -10,11 +12,14 @@ A user can only submit jobs when he/she is logged in and has at least one expert A super user can assign an expertise level to users at http://localhost:3000/admin/users. A super user can be made through the admin page (`/admin/users`) or by being the first registered user. +## Session + The sessions will be encrypted with a secret key from an environment variable. ```shell SESSION_SECRET=... ``` +(A random string can be generated with `openssl rand -base64 32`) The environment variables can be stored in a `.env` file. @@ -24,6 +29,8 @@ Use [.env.example](../.env.example) as a template: cp .env.example .env ``` +## Social logins + To enable GitHub or Orcid or EGI Check-in login the web app needs following environment variables. ```shell @@ -40,7 +47,8 @@ HADDOCK3WEBAPP_EGI_CALLBACK_URL=http://localhost:3000/auth/egi/callback HADDOCK3WEBAPP_EGI_ENVIRONMENT=production # could also be 'development' or 'demo' ``` -Only use social logins where the email address has been verified. +Only use social logins where the email address has been verified. +Otherwise someone could create an social account with your email address and impersonate you. ## GitHub login diff --git a/docs/bartender.md b/docs/bartender.md new file mode 100644 index 00000000..56816355 --- /dev/null +++ b/docs/bartender.md @@ -0,0 +1,70 @@ +# Bartender + +The bartender web service is used to submit jobs to the Haddock3 application. + +See bartender [documentation](https://i-vresse-bartender.readthedocs.io/en/latest/) how to setup, configure and run the bartender web service. + +## API Client + +This web app uses a client to consume the bartender web service. + +The client can be (re-)generated with + +```shell +npm run generate-client +``` + +(This command requires that the bartender webservice is running at http://localhost:8000) + +## Configuration + +The haddock3 web application needs to know where the bartender web service is running. +Configure bartender location with `BARTENDER_API_URL` environment variable. + +```sh +BARTENDER_API_URL=http://localhost:8000 +``` + +Bartender uses asymmetric [JWT tokens](https://jwt.io) for authentication and authorization. +The tokens are signed with a private key and verified with a public key. +Here the haddock3 web application signs the token and the bartender web service verifies the token. +In this way the bartender web service only accepts tokens signed by the haddock3 web application. +The haddock3 web application will generate a short lifetime token for a user when he/she needs to submit a job or get info on jobs. + +The haddock3 web application needs the location the private key file, it is by default `private_key.pem` or the value of the `BARTENDER_PRIVATE_KEY` environment variable. + +```sh +BARTENDER_PRIVATE_KEY=private_key.pem +``` + +The bartender web service needs the location of the public key file, it is by default `public_key.pem` or the value of the `BARTENDER_PUBLIC_KEY` environment variable. + +```sh +BARTENDER_PUBLIC_KEY=public_key.pem +``` + +## Haddock3 application + +This web app expects that the following application is registered in bartender web service. + +```yaml +applications: + haddock3: + command: haddock3 $config + config: workflow.cfg +``` + +This allows the archive generated with the workflow builder to be submitted. + +## Haddock3 catalogs + +To show which modules and parameters are available in the workflow builder, the haddock3 web application uses the catalogs from the [workflow builder](https://github.com/i-VRESSE/workflow-builder/tree/main/packages/haddock3_catalog/public/catalog). +, which in turn are generated from the [haddock3 defaults.yaml](https://github.com/haddocking/haddock3/blob/main/src/haddock/modules/defaults.yaml). + +This repo has a copy of the catalogs, stored at `./app/catalogs/*.yaml`. + +To fetch the latest catalogs run + +```shell +npm run catalogs +``` diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 00000000..f30bb83e --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,26 @@ +# Deployment with docker + +The web application can be run inside a Docker container together with all its dependent containers. + +Requirements: + +1. Private key `./private_key.pem` and public key `./public_key.pem`. +2. `./.env` file for haddock3 web application. +3. [bartender repo](https://github.com/i-VRESSE/bartender) to be cloned in `../bartender` directory. +4. bartender repo should have [.env file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#environment-variables) +5. bartender repo should have a [config.yaml file](https://github.com/i-VRESSE/bartender/blob/main/docs/configuration.md#configuration-file) + 1. The `job_root_dir` key should be set to `/tmp/jobs` + +Build with + +```sh +docker compose build +``` + +Run with + +```sh +docker compose up +``` + +Web application running at http://localhost:8080 . diff --git a/docs/stack.md b/docs/stack.md index 761b4354..add00f37 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -6,6 +6,9 @@ We want to make a haddock3 web application that uses - [workflow-builder](https://github.com/i-VRESSE/workflow-builder) to construct a Haddock3 workflow config file. - [haddock3](https://github.com/haddocking/haddock3) to compute +As the workflow-builder is written in TypeScript, so we will use TypeScript for the web application. +We are using NodeJS for runtime. + ## OpenAPI client The bartender web service provides an OpenAPI specification. From 0cfa3526b257dfdd62a698bc8dec50e6ed8630cf Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 13:48:04 +0200 Subject: [PATCH 40/45] Added secret generate command --- README.md | 14 ++++++-------- docs/auth.md | 12 +++--------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ae295119..a92f13c1 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,14 @@ The web app is written in [Node.js](https://nodejs.org/) to install dependencies npm install ``` -Configuration of the web application is done via `.env` file or environment variables. -See [docs/auth.md](docs/auth.md) and [docs/bartender.md] for details. +Configuration of the web application is done via `.env` file or environment variables. +For configuration of authentication & authorization see [docs/auth.md](docs/auth.md). +For configuration of job submission see [docs/bartender.md#configuration](docs/bartender.md#configuration). +Use [.env.example](../.env.example) as a template: ```shell cp .env.example .env +# Edit .env file ``` Create rsa key pair for signing & verifying JWT tokens for bartender web service with: @@ -48,12 +51,7 @@ openssl rsa -pubout -in private_key.pem -out public_key.pem ## Bartender web service The bartender web service should be running if you want to submit jobs. -See [docs/bartender.md](docs/bartender.md) how to configure. - -## Authentication & authorization - -The web application comes with its own user management and social logins -See [docs/auth.md](docs/auth.md) how to configure. +See [docs/bartender.md](docs/bartender.md) how to set it up. ## Development diff --git a/docs/auth.md b/docs/auth.md index feafbfb6..0c8ab069 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -14,20 +14,14 @@ A super user can be made through the admin page (`/admin/users`) or by being the ## Session +Sessions are used to remember when a user is logged in. + The sessions will be encrypted with a secret key from an environment variable. ```shell SESSION_SECRET=... ``` -(A random string can be generated with `openssl rand -base64 32`) - -The environment variables can be stored in a `.env` file. - -Use [.env.example](../.env.example) as a template: - -```shell -cp .env.example .env -``` +A random secret string can be generated with `openssl rand -base64 32`. ## Social logins From a2c50c95bbed2267c7d0814fa091d1c290dcf213 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 13:51:44 +0200 Subject: [PATCH 41/45] Add prune --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a92f13c1..4c09b5ef 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ npm run docker:dev (Stores data in `./postgres-data`) (You can get a psql shell with `npm run psql:dev`) +(On CTRL-C the database is stopped. To remove container use `docker system prune`) The database can be initialized with From 5627bb4d788b671315d930b7b5399dbbcef796e2 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 14:03:45 +0200 Subject: [PATCH 42/45] Move useful info to docs --- app/bartender_token.server.ts | 2 -- docs/bartender.md | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/bartender_token.server.ts b/app/bartender_token.server.ts index d7916893..8146a5bf 100644 --- a/app/bartender_token.server.ts +++ b/app/bartender_token.server.ts @@ -38,8 +38,6 @@ export class TokenGenerator { if (!this.privateKey) { throw new Error("private key not initialized"); } - // If bartender has been configured with allowed_roles for an application, - // then the a role claim should be in the JWT. const jwt = await new SignJWT({ email: email, }) diff --git a/docs/bartender.md b/docs/bartender.md index 56816355..61dc255a 100644 --- a/docs/bartender.md +++ b/docs/bartender.md @@ -56,6 +56,8 @@ applications: This allows the archive generated with the workflow builder to be submitted. +The application should not have `allowed_roles` configured as the haddock3 web application will only allow users to submit jobs that have an expertise level and generate a bartender token without roles. + ## Haddock3 catalogs To show which modules and parameters are available in the workflow builder, the haddock3 web application uses the catalogs from the [workflow builder](https://github.com/i-VRESSE/workflow-builder/tree/main/packages/haddock3_catalog/public/catalog). From 6cfcd2e47f10f8aabb9646f069030e954d4a863b Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 14:07:53 +0200 Subject: [PATCH 43/45] Remove password length check during login Already comparing with bcrypt --- app/auth.server.ts | 4 ++-- app/routes/login.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/auth.server.ts b/app/auth.server.ts index fd60cc4b..81a3c053 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -21,7 +21,7 @@ import { localLogin, oauthregister, } from "./models/user.server"; -import { email, minLength, object, parse, string } from "valibot"; +import { email, object, parse, string } from "valibot"; // Create an instance of the authenticator, pass a generic with what // strategies will return and will store in the session @@ -31,7 +31,7 @@ export const authenticator = new Authenticator(sessionStorage, { const CredentialsSchema = object({ email: string([email()]), - password: string([minLength(8)]), + password: string(), }); // Tell the Authenticator to use the form strategy diff --git a/app/routes/login.tsx b/app/routes/login.tsx index dbe40854..520e511b 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -86,7 +86,6 @@ export default function LoginPage() { id="password" name="password" type="password" - minLength={8} autoComplete="current-password" className={inputStyle} required From 78ec5951b1ea9b54d6685e756b45fbc2b7200ba3 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 14:11:28 +0200 Subject: [PATCH 44/45] When password was never set then locallogin always fails --- app/models/user.server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/user.server.ts b/app/models/user.server.ts index a92cf865..f8771771 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -86,7 +86,10 @@ export async function localLogin(email: string, password: string) { throw new UserNotFoundError(); } const { passwordHash, ...userWithoutPasswordHash } = user; - const isValid = await compare(password, user.passwordHash || ""); + if (!passwordHash) { + throw new WrongPasswordError(); + } + const isValid = await compare(password, passwordHash); if (!isValid) { throw new WrongPasswordError(); } From b63c372cf4d7ad881730072f6e3e8ec537153fb4 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 21 Aug 2023 14:21:28 +0200 Subject: [PATCH 45/45] Format --- README.md | 8 +++++--- docs/auth.md | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4c09b5ef..30ef4aec 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,13 @@ The database can be initialized with npm run setup # This will generate prisma client, create tables and insert seed data ``` + (You can reset database with `npx prisma migrate reset`.) -The database setup should be run only once for a fresh database. -Whenever you change the `prisma/schema.prisma` file you need to -1. Use [prisma migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate) to generate a migration and to update the database. +The database setup should be run only once for a fresh database. +Whenever you change the `prisma/schema.prisma` file you need to + +1. Use [prisma migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate) to generate a migration and to update the database. 2. Run `npx prisma generate` to generate the prisma client. Start [remix](https://remix.run) development server from your terminal with: diff --git a/docs/auth.md b/docs/auth.md index 0c8ab069..38730b82 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -21,6 +21,7 @@ The sessions will be encrypted with a secret key from an environment variable. ```shell SESSION_SECRET=... ``` + A random secret string can be generated with `openssl rand -base64 32`. ## Social logins @@ -41,7 +42,7 @@ HADDOCK3WEBAPP_EGI_CALLBACK_URL=http://localhost:3000/auth/egi/callback HADDOCK3WEBAPP_EGI_ENVIRONMENT=production # could also be 'development' or 'demo' ``` -Only use social logins where the email address has been verified. +Only use social logins where the email address has been verified. Otherwise someone could create an social account with your email address and impersonate you. ## GitHub login
    Batch Email Administrator? Expertise levelsActions