diff --git a/ckan-backend-dev/.env.example b/ckan-backend-dev/.env.example index 2723f2950..037f448fb 100644 --- a/ckan-backend-dev/.env.example +++ b/ckan-backend-dev/.env.example @@ -104,3 +104,7 @@ CKAN___SCHEMING__PRESETS=ckanext.wri.schema:presets.json # auth CKANEXT__AUTH__INCLUDE_FRONTEND_LOGIN_TOKEN=True + +# custom auth +CKANEXT__WRI__ODP_URL=http://localhost:3000 + diff --git a/ckan-backend-dev/docker-compose.dev.yml b/ckan-backend-dev/docker-compose.dev.yml index 514db5159..cc6a53eb7 100755 --- a/ckan-backend-dev/docker-compose.dev.yml +++ b/ckan-backend-dev/docker-compose.dev.yml @@ -46,6 +46,7 @@ services: - S3_SECRET_KEY_ID=${MINIO_ROOT_PASSWORD} - S3_BUCKET_NAME=ckan - S3_BUCKET_REGION=us-east-1 + - SYS_ADMIN_API_KEY=${CKAN__DATAPUSHER__API_TOKEN} environment: - NEXTAUTH_SECRET=secret - NEXTAUTH_URL=http://localhost:3000 @@ -54,6 +55,7 @@ services: - S3_SECRET_KEY_ID=${MINIO_ROOT_PASSWORD} - S3_BUCKET_NAME=ckan - S3_BUCKET_REGION=us-east-1 + - SYS_ADMIN_API_KEY=${CKAN__DATAPUSHER__API_TOKEN} ports: - "0.0.0.0:3000:3000" healthcheck: diff --git a/ckan-backend-dev/docker-compose.test.yml b/ckan-backend-dev/docker-compose.test.yml index 0712228e5..96060410c 100755 --- a/ckan-backend-dev/docker-compose.test.yml +++ b/ckan-backend-dev/docker-compose.test.yml @@ -52,6 +52,7 @@ services: - S3_SECRET_KEY_ID=${MINIO_ROOT_PASSWORD} - S3_BUCKET_NAME=ckan - S3_BUCKET_REGION=us-east-1 + - SYS_ADMIN_API_KEY=${CKAN__DATAPUSHER__API_TOKEN} ports: - "0.0.0.0:3000:3000" healthcheck: diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/lib/mailer.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/lib/mailer.py new file mode 100644 index 000000000..35a8b5f00 --- /dev/null +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/lib/mailer.py @@ -0,0 +1,31 @@ +from ckan.lib.mailer import create_reset_key, mail_user, MailerException +from ckan.lib.base import render +from ckan.common import config +import ckan.model as model + +def get_reset_link(user: model.User) -> str: + odp_url = config.get('ckanext.wri.odp_url') + return "{}/auth/password-reset?token={}&user_id={}".format(odp_url, user.reset_key, user.id) + +def get_reset_link_body(user: model.User) -> str: + extra_vars = { + 'reset_link': get_reset_link(user), + 'site_title': "WRI Open Data Portal", + 'site_url': config.get('ckanext.wri.odp_url'), + 'user_name': user.name, + } + # NOTE: This template is translated + return render('emails/reset_password.txt', extra_vars) + +def send_reset_link(user: model.User) -> None: + create_reset_key(user) + body = get_reset_link_body(user) + extra_vars = { + 'site_title': config.get('ckan.site_title') + } + subject = render('emails/reset_password_subject.txt', extra_vars) + + # Make sure we only use the first line + subject = subject.split('\n')[0] + + mail_user(user, subject, body, body) diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action.py new file mode 100644 index 000000000..33c7d50de --- /dev/null +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action.py @@ -0,0 +1,35 @@ +import ckan.plugins.toolkit as tk +import ckan.logic as logic +import ckanext.wri.lib.mailer as mailer +from ckan.types import Context +from ckan.common import _ + + +ValidationError = logic.ValidationError + +import logging + +log = logging.getLogger(__name__) + +def password_reset(context: Context, data_dict: [str, any]): + email = data_dict.get("email", False) + + if not email: + raise ValidationError({"email": [_("Please provide an email address")]}) + model = context['model'] + session = context['session'] + + user = session.query(model.User).filter_by(email=email).all() + + if not user: + # Do not leak whether the email is registered or not + return "Password reset link sent to email address" + + try: + mailer.send_reset_link(user[0]) + return "Password reset link sent to email address" + except mailer.MailerException as e: + log.exception(e) + return "Password reset link sent to email address" + + diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/plugin.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/plugin.py index b93ab5250..ee82da28e 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/plugin.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/plugin.py @@ -1,12 +1,14 @@ import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit +import ckanext.wri.logic.action as action from ckanext.wri.logic.validators import iso_language_code class WriPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IValidators) + plugins.implements(plugins.IActions) # IConfigurer @@ -21,3 +23,12 @@ def get_validators(self): return { "iso_language_code": iso_language_code } + + # IActions + + def get_actions(self): + return { + "password_reset": action.password_reset + + } + diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/templates/emails/reset_password.txt b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/templates/emails/reset_password.txt new file mode 100644 index 000000000..259d72d22 --- /dev/null +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/templates/emails/reset_password.txt @@ -0,0 +1,23 @@ +{% trans %} +

+Dear {{ user_name }}, +
+
+You have requested your password on {{ site_title }} to be reset. +
+
+Please click the following link to confirm this request: +
+
+ Reset password +
+
+Have a nice day. +
+
+-- +Message sent by {{ site_title }} ({{ site_url }}) +

+{% endtrans %} + + diff --git a/deployment/frontend/.env.example b/deployment/frontend/.env.example index ca4b64994..b6df911ee 100644 --- a/deployment/frontend/.env.example +++ b/deployment/frontend/.env.example @@ -24,3 +24,4 @@ S3_ACCESS_KEY_ID="minioadmin" S3_SECRET_KEY_ID="minioadmin" S3_BUCKET_NAME="ckan" S3_BUCKET_REGION="us-east-1" +SYS_ADMIN_API_KEY="1111" diff --git a/deployment/frontend/package-lock.json b/deployment/frontend/package-lock.json index 85c5db9b8..5406946ff 100644 --- a/deployment/frontend/package-lock.json +++ b/deployment/frontend/package-lock.json @@ -1714,66 +1714,6 @@ "glob": "7.1.7" } }, - "node_modules/@next/swc-darwin-arm64": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz", - "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz", - "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz", - "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz", - "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@next/swc-linux-x64-gnu": { "version": "13.5.6", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz", @@ -1804,51 +1744,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz", - "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz", - "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz", - "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6803,19 +6698,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -12584,6 +12466,111 @@ "optional": true } } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz", + "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz", + "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz", + "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz", + "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz", + "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz", + "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz", + "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/deployment/frontend/src/components/_shared/Header.tsx b/deployment/frontend/src/components/_shared/Header.tsx index b2fd53b6a..4fa4d0367 100644 --- a/deployment/frontend/src/components/_shared/Header.tsx +++ b/deployment/frontend/src/components/_shared/Header.tsx @@ -1,175 +1,191 @@ -import React, { Fragment, useState } from "react"; -import Image from "next/image"; -import { Menu, Transition, Dialog } from "@headlessui/react"; -import { Bars3Icon } from "@heroicons/react/20/solid"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import Login from "./Login"; -import UserMenu from "./UserMenu"; +import React, { Fragment, useState } from 'react' +import Image from 'next/image' +import { Menu, Transition, Dialog } from '@headlessui/react' +import { Bars3Icon } from '@heroicons/react/20/solid' +import Link from 'next/link' +import { useRouter } from 'next/router' +import Login from './Login' +import UserMenu from './UserMenu' +import {useSession} from 'next-auth/react' export default function Header() { - const { asPath } = useRouter(); - const [isOpen, setIsOpen] = useState(false) + const { asPath } = useRouter() + const [isOpen, setIsOpen] = useState(false) + const session = useSession() - function closeModal() { - setIsOpen(false) - } + function closeModal() { + setIsOpen(false) + } - function openModal() { - setIsOpen(true) - } + function openModal() { + setIsOpen(true) + } + const navigation = [ + { + title: 'Search', + href: '/search', + active: false, + }, + { + title: 'Teams', + href: '/teams', + active: false, + }, + { + title: 'Topics', + href: '/topics', + active: false, + }, + { + title: 'About', + href: '/about', + active: false, + }, + ] - const navigation = [ - { - title: "Search", - href: "/search", - active: false, - }, - { - title: "Teams", - href: "/teams", - active: false, - }, - { - title: "Topics", - href: "/topics", - active: false, - }, - { - title: "About", - href: "/about", - active: false, - }, - ]; - - navigation.forEach((item) => { - item.active = asPath.startsWith(item.href); - }); - - return ( - + ) } diff --git a/deployment/frontend/src/components/_shared/Login.tsx b/deployment/frontend/src/components/_shared/Login.tsx index 957f28468..e15eca4be 100644 --- a/deployment/frontend/src/components/_shared/Login.tsx +++ b/deployment/frontend/src/components/_shared/Login.tsx @@ -1,73 +1,280 @@ -import React from "react"; +import React, { + Dispatch, + FormEvent, + SetStateAction, + useEffect, + useState, +} from 'react' +import { zodResolver } from '@hookform/resolvers/zod' + +import { + ExclamationCircleIcon, + EnvelopeIcon, + LockClosedIcon, + UserIcon, + InformationCircleIcon, +} from '@heroicons/react/24/outline' +import Image from 'next/image' +import { getCsrfToken, signIn } from 'next-auth/react' +import { useRouter } from 'next/router' +import { useForm } from 'react-hook-form' import { - ExclamationCircleIcon, - EnvelopeIcon, - LockClosedIcon, -} from "@heroicons/react/24/outline"; -import Image from "next/image"; -import Link from "next/link"; - -export default function Login() { - return ( -
-
-
- -

- Registration Not Available Yet! Login for WRI Members Only.{" "} - You Can Still Use All Portal Features. -

-

Log In

-
-
-
-
- -
- -
+ RequestResetPasswordFormType, + RequestResetPasswordSchema, + SignInFormType, + SignInSchema, +} from '@/schema/auth.schema' +import { ErrorAlert } from './Alerts' +import { api } from '@/utils/api' +import notify from '@/utils/notify' + +export default function Login({ + onSignIn = () => {}, +}: { + onSignIn?: () => void +}) { + const [isPasswordReset, setIsPasswordReset] = useState(false) + + return ( +
+
+ {!isPasswordReset ? ( + + ) : ( + + )} +
+
+ ) +} + +function SignInForm({ + onSignIn, + setIsPasswordReset, +}: { + onSignIn: () => void + setIsPasswordReset: Dispatch> +}) { + const router = useRouter() + const [errorMessage, setErrorMessage] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(SignInSchema), + }) + + const error = + errorMessage || errors.username?.message || errors.password?.message + + return ( + <> +
+ +

+ Registration Not Available Yet!{' '} + Login for WRI Members Only. You Can Still Use All + Portal Features. +

+

Log In

-
- -
- -
+
+ { + setErrorMessage('') + handleSubmit(async (data) => { + setIsLoading(true) + const signInStatus = await signIn('credentials', { + callbackUrl: '/dashboard', + redirect: false, + ...data, + }) + + setIsLoading(false) + if (signInStatus?.error) { + // TODO: we should get the error from the response + console.log(signInStatus) + setErrorMessage(signInStatus.error) + } else { + notify("Sign in successful") + onSignIn ? onSignIn() : router.reload() + } + })(data) + }} + > +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {error ? ( + + ) : null} + + +
+
+
+
or
+
+
+
+
+ comment +
+
+ Sign In with your WRI Credentials +
+
+ + ) +} + +function ResetPasswordForm({ + setIsPasswordReset, +}: { + setIsPasswordReset: Dispatch> +}) { + const [errorMessage, setErrorMessage] = useState('') + const [result, setResult] = useState('') + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(RequestResetPasswordSchema), + }) + + const requestPasswordReset = api.auth.requestPasswordReset.useMutation({ + onSuccess: (data) => { + setResult(data.result) + }, + onError: (e) => { + setErrorMessage(e.message) + }, + }) + + const error = errorMessage || errors.email?.message + + return ( + <> +
+

+ Password Reset +

+
+
+
{ + setErrorMessage('') + setResult('') + handleSubmit(async (data) => { + requestPasswordReset.mutate(data) + })(data) + }} + > +
+
+ +
+
+ +
+
+ + + + {result ? ( +

{result}

+ ) : null} -
- Forgot password? + {error ? ( + + ) : null} + +
- - -
-
-
-
or
-
-
-
-
- comment -
-
- Sign In with your WRI Credentials -
-
-
-
- ); + + ) } diff --git a/deployment/frontend/src/components/_shared/UserMenu.tsx b/deployment/frontend/src/components/_shared/UserMenu.tsx index 94f917f44..7bddaa76b 100644 --- a/deployment/frontend/src/components/_shared/UserMenu.tsx +++ b/deployment/frontend/src/components/_shared/UserMenu.tsx @@ -1,66 +1,84 @@ -import React, { Fragment } from "react"; -import { Menu, Transition } from "@headlessui/react"; -import { UserCircleIcon } from "@heroicons/react/20/solid"; +import React, { Fragment } from 'react' +import { Menu, Transition } from '@headlessui/react' +import { UserCircleIcon } from '@heroicons/react/20/solid' +import { signOut, useSession } from 'next-auth/react' const navigation = [ - { - title: "Dashboard", - href: "/dashboard", - active: false, - }, - { - title: "Settings", - href: "/settings", - active: false, - }, - { - title: "Log Out", - href: "/logout", - active: false, - }, -]; + { + title: 'Dashboard', + href: '/dashboard', + active: false, + }, + { + title: 'Settings', + href: '/settings', + active: false, + }, + { + title: 'Log Out', + onClick: () => + signOut({ redirect: true, callbackUrl: window.location.href }), + }, +] -export default function UserMenu() { - return ( -
- -
- -
- -
John Doe
-
-
-
- - - {navigation.map((item) => { - return ( -
-
- +export default function UserMenu({ + colors = 'dark', +}: { + colors?: 'dark' | 'light' +}) { + const session = useSession() - - {item.title} - - -
+ return ( + - ) + + + {navigation.map((item) => { + return ( +
+
+ + {item.onClick ? ( + + ) : ( + + {item.title} + + )} + +
+
+ ) + })} +
+
+
+
+ ) } diff --git a/deployment/frontend/src/components/home/Hero.tsx b/deployment/frontend/src/components/home/Hero.tsx index ede443e77..00a86e215 100644 --- a/deployment/frontend/src/components/home/Hero.tsx +++ b/deployment/frontend/src/components/home/Hero.tsx @@ -1,226 +1,245 @@ -import { Fragment, useState } from "react"; -import { Menu, Transition, Dialog } from "@headlessui/react"; -import { Bars3Icon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import Link from "next/link"; -import Image from "next/image"; -import { useRouter } from "next/router"; -import Login from "../_shared/Login"; +import { Fragment, useState } from 'react' +import { Dialog, Transition } from '@headlessui/react' +import { + Bars3Icon, + MagnifyingGlassIcon, + XMarkIcon, +} from '@heroicons/react/24/outline' +import Link from 'next/link' +import Image from 'next/image' +import { useRouter } from 'next/router' +import Login from '../_shared/Login' +import { useSession } from 'next-auth/react' +import UserMenu from '../_shared/UserMenu' export function Hero() { - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const { asPath } = useRouter(); - const navigation = [ - { - title: "Search", - href: "/search", - active: false, - }, - { - title: "Teams", - href: "/teams", - active: false, - }, - { - title: "Topics", - href: "/topics", - active: false, - }, - { - title: "About", - href: "/about", - active: false, - }, - ]; - navigation.forEach((item) => { - item.active = asPath.startsWith(item.href); - }); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const session = useSession() - const [isOpen, setIsOpen] = useState(false) + const { asPath } = useRouter() + const navigation = [ + { + title: 'Search', + href: '/search', + active: false, + }, + { + title: 'Teams', + href: '/teams', + active: false, + }, + { + title: 'Topics', + href: '/topics', + active: false, + }, + { + title: 'About', + href: '/about', + active: false, + }, + ] + navigation.forEach((item) => { + item.active = asPath.startsWith(item.href) + }) - function closeModal() { - setIsOpen(false) - } + return ( +
+
+ + +
+ +
+ + Picture of the author + Picture of the author + + +
+
+
+
+ {navigation.map((item) => ( + + {item.title} + + ))} +
+
+
+
+
+
- function openModal() { - setIsOpen(true) - } - return ( - <> - - - -
- +
+ -
- - - - - -
-
-
-
-
-
- - -
- -
- - Picture of the author - Picture of the author - - -
-
-
-
- {navigation.map((item) => ( - - {item.title} - - ))} +
+
+
+

+ Welcome to the WRI Open Data Catalog. Neque porro + quisquam est qui dolorem... +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing + elit, sed do eiusmod tempor incididunt ut labore et + dolore. +

+
+ + +
+
-
- -
-
+ + + setIsOpen(false)} + > + +
+ -
- -
-
-
-

- Welcome to the WRI Open Data Catalog. Neque porro quisquam est qui - dolorem... -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore. -

-
- - -
-
+
+
+ + + setIsOpen(false)} /> + + +
+
+
+
-
- - - ); + ) } diff --git a/deployment/frontend/src/env.mjs b/deployment/frontend/src/env.mjs index 9f1e67458..d6076499b 100644 --- a/deployment/frontend/src/env.mjs +++ b/deployment/frontend/src/env.mjs @@ -1,63 +1,67 @@ -import { createEnv } from "@t3-oss/env-nextjs"; -import { z } from "zod"; +import { createEnv } from '@t3-oss/env-nextjs' +import { z } from 'zod' export const env = createEnv({ - /** - * Specify your server-side environment variables schema here. This way you can ensure the app - * isn't built with invalid env vars. - */ - server: { - NODE_ENV: z - .enum(["development", "test", "production"]) - .default("development"), - NEXTAUTH_SECRET: - process.env.NODE_ENV === "production" - ? z.string() - : z.string().optional(), - NEXTAUTH_URL: z.preprocess( - // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL - // Since NextAuth.js automatically uses the VERCEL_URL if present. - (str) => process.env.VERCEL_URL ?? str, - // VERCEL_URL doesn't include `https` so it cant be validated as a URL - process.env.VERCEL ? z.string() : z.string().url() - ), - CKAN_URL: z.string(), - S3_BUCKET_NAME: z.string(), - S3_BUCKET_REGION: z.string(), - S3_ACCESS_KEY_ID: z.string(), - S3_SECRET_KEY_ID: z.string(), - }, + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + NODE_ENV: z + .enum(['development', 'test', 'production']) + .default('development'), + NEXTAUTH_SECRET: + process.env.NODE_ENV === 'production' + ? z.string() + : z.string().optional(), + NEXTAUTH_URL: z.preprocess( + // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL + // Since NextAuth.js automatically uses the VERCEL_URL if present. + (str) => process.env.VERCEL_URL ?? str, + // VERCEL_URL doesn't include `https` so it cant be validated as a URL + process.env.VERCEL ? z.string() : z.string().url() + ), + CKAN_URL: z.string(), + S3_BUCKET_NAME: z.string(), + S3_BUCKET_REGION: z.string(), + S3_ACCESS_KEY_ID: z.string(), + S3_SECRET_KEY_ID: z.string(), + SYS_ADMIN_API_KEY: z.preprocess( + (str) => process.env.SYS_ADMIN_API_KEY ?? str, + z.string() + ), + }, - /** - * Specify your client-side environment variables schema here. This way you can ensure the app - * isn't built with invalid env vars. To expose them to the client, prefix them with - * `NEXT_PUBLIC_`. - */ - client: { - }, + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: {}, - /** - * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. - * middlewares) or client-side so we need to destruct manually. - */ - runtimeEnv: { - NODE_ENV: process.env.NODE_ENV, - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, - NEXTAUTH_URL: process.env.NEXTAUTH_URL, - CKAN_URL: process.env.CKAN_URL, - S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID, - S3_SECRET_KEY_ID: process.env.S3_SECRET_KEY_ID, - S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, - S3_BUCKET_REGION: process.env.S3_BUCKET_REGION, - }, - /** - * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. - * This is especially useful for Docker builds. - */ - skipValidation: !!process.env.SKIP_ENV_VALIDATION, - /** - * Makes it so that empty strings are treated as undefined. - * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error. - */ - emptyStringAsUndefined: true, -}); + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + NODE_ENV: process.env.NODE_ENV, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + CKAN_URL: process.env.CKAN_URL, + S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID, + S3_SECRET_KEY_ID: process.env.S3_SECRET_KEY_ID, + S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, + S3_BUCKET_REGION: process.env.S3_BUCKET_REGION, + SYS_ADMIN_API_KEY: process.env.SYS_ADMIN_API_KEY, + }, + /** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. + * This is especially useful for Docker builds. + */ + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + /** + * Makes it so that empty strings are treated as undefined. + * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error. + */ + emptyStringAsUndefined: true, +}) diff --git a/deployment/frontend/src/interfaces/user.interface.ts b/deployment/frontend/src/interfaces/user.interface.ts new file mode 100644 index 000000000..afeb48be6 --- /dev/null +++ b/deployment/frontend/src/interfaces/user.interface.ts @@ -0,0 +1,18 @@ +export interface User { + id?: string; + name?: string; + fullname?: string; + created?: string; + about?: string; + activity_streams_email_notifications?: boolean; + sysadmin?: boolean; + state?: "active" | "inactive" | "deleted"; + image_url?: string; + display_name?: string; + email_hash?: string; + number_created_packages?: number; + apikey?: string; + email?: string; + image_display_url?: string; +} + diff --git a/deployment/frontend/src/middleware.ts b/deployment/frontend/src/middleware.ts new file mode 100644 index 000000000..23b9b5da8 --- /dev/null +++ b/deployment/frontend/src/middleware.ts @@ -0,0 +1,20 @@ +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { authOptions } from './server/auth' +import { getToken } from 'next-auth/jwt' + +export async function middleware(request: NextRequest, response: NextResponse) { + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + }) + + if (!token) return NextResponse.redirect(new URL('/', request.url)) + + return NextResponse.next() +} + +export const config = { + matcher: '/dashboard/:path*', +} diff --git a/deployment/frontend/src/pages/auth/password-reset.tsx b/deployment/frontend/src/pages/auth/password-reset.tsx new file mode 100644 index 000000000..df2071af4 --- /dev/null +++ b/deployment/frontend/src/pages/auth/password-reset.tsx @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { zodResolver } from '@hookform/resolvers/zod' +import { + ResetPasswordSchema, + type ResetPasswordFormType, +} from '@/schema/auth.schema' +import type { GetServerSideProps } from 'next' +import { getCsrfToken, signIn } from 'next-auth/react' +import { useForm } from 'react-hook-form' +import { api } from '@/utils/api' +import { useState } from 'react' +import { getServerAuthSession } from '@/server/auth' +import Spinner from '@/components/_shared/Spinner' +import { match } from 'ts-pattern' +import { ErrorMessage } from '@hookform/error-message' +import ky from 'ky' +import { env } from '@/env.mjs' +import type { CkanResponse } from '@/schema/ckan.schema' +import type { User } from '@portaljs/ckan' +import { NextSeo } from 'next-seo' +import { ErrorAlert } from '@/components/_shared/Alerts' +import { LockClosedIcon } from '@heroicons/react/24/outline' +import Image from 'next/image' +import notify from '@/utils/notify' + +export const getServerSideProps: GetServerSideProps = async (context) => { + const session = await getServerAuthSession(context) + + if (session) { + return { + redirect: { + destination: '/', + permanent: false, + }, + } + } + const csrfToken = await getCsrfToken(context) + if (!context.query.user_id || !context.query.token) { + return { + redirect: { + destination: '/', + permanent: false, + }, + } + } + + return { + props: { + csrfToken: csrfToken ? csrfToken : '', + user: { + id: context.query.user_id, + reset_key: context.query.token, + }, + }, + } +} + +export default function ResetUserPage({ + csrfToken, + user, +}: { + csrfToken: string + user: { id: string; reset_key: string } +}) { + const [errorMessage, setErrorMessage] = useState(null) + const [isResetSuccessful, setIsResetSuccessful] = useState(false) + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues: { + ...user, + }, + }) + + const resetPassword = api.auth.resetPassword.useMutation({ + onSuccess: async () => { + notify('Your password has been reset') + setIsResetSuccessful(true) + setTimeout( + () => + signIn('credentials', { + callbackUrl: '/dashboard/datasets', + username: watch('id'), + password: watch('password'), + }), + 3000 + ) + }, + onError: (error) => setErrorMessage(error.message), + }) + + const error = + errorMessage ?? + errors.password?.message ?? + errors.confirm_password?.message ?? + errors.confirm?.message + + return ( + <> + +
+
{ + resetPassword.mutate(data) + })} + > + +
+
+ Picture of the author +
+
+

+ Reset your password +

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {error ? ( + + ) : null} + + +
+ + ) +} diff --git a/deployment/frontend/src/pages/auth/signin.tsx b/deployment/frontend/src/pages/auth/signin.tsx deleted file mode 100644 index 5643d5a7a..000000000 --- a/deployment/frontend/src/pages/auth/signin.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { ErrorAlert } from "@/components/_shared/Alerts"; -import Spinner from "@/components/_shared/Spinner"; -import type { GetServerSidePropsContext } from "next"; -import { getCsrfToken, signIn } from "next-auth/react"; -import { NextSeo } from "next-seo"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { match } from "ts-pattern"; - -export async function getServerSideProps(context: GetServerSidePropsContext) { - return { - props: { - csrfToken: await getCsrfToken(context), - }, - }; -} - -export default function LoginPage({ csrfToken }: { csrfToken: string }) { - const [errorMessage, setErrorMessage] = useState(null); - const [loggingIn, setLogin] = useState(false); - const { register, handleSubmit } = useForm<{ - username: string; - password: string; - }>(); - return ( - <> - -
-
-
- - WRI - ODP - -

- Sign in to your account -

-
-
-
- void handleSubmit(async (data) => { - setLogin(true); - const signInStatus = await signIn("credentials", { - callbackUrl: "/", - redirect: true, - ...data, - }); - if (signInStatus?.error) { - setLogin(false); - setErrorMessage( - "Could not find user please check your login and password", - ); - } - })(event) - } - > - -
- -
- -
-
- -
-
- -
-
- -
-
-
-
- {match(loggingIn) - .with(false, () => ( - - )) - .otherwise(() => ( - - ))} -
-
-
-
- {errorMessage && } -
-
- - ); -} diff --git a/deployment/frontend/src/schema/auth.schema.ts b/deployment/frontend/src/schema/auth.schema.ts new file mode 100644 index 000000000..391830c85 --- /dev/null +++ b/deployment/frontend/src/schema/auth.schema.ts @@ -0,0 +1,27 @@ +import z from 'zod' + +export const SignInSchema = z.object({ + username: z.string().min(1, 'Please, provide an username'), + password: z.string().min(1, 'Please, provide a password'), +}) + +export type SignInFormType = z.infer + +export const RequestResetPasswordSchema = z.object({ email: z.string().email() }) + +export type RequestResetPasswordFormType = z.infer + +export const ResetPasswordSchema = z + .object({ + password: z.string().min(8, "Password must be at least 8 characters"), + confirm_password: z.string(), + reset_key: z.string(), + id: z.string(), + }) + .refine((data) => data.password === data.confirm_password, { + message: "Passwords don't match", + path: ["confirm"], + }); + +export type ResetPasswordFormType = z.infer; + diff --git a/deployment/frontend/src/schema/ckan.schema.ts b/deployment/frontend/src/schema/ckan.schema.ts index a824fa9c5..8ea57de79 100644 --- a/deployment/frontend/src/schema/ckan.schema.ts +++ b/deployment/frontend/src/schema/ckan.schema.ts @@ -1,12 +1,21 @@ import type { Dataset, Group, Organization, User as CkanUser } from "@portaljs/ckan"; + +type Only = { + [P in keyof T]: T[P] +} & { + [P in keyof U]?: never +} + +type Either = Only | Only + export interface CkanResponse { - help: string; - success: boolean; - error?: { - __type: string; - message: string; - }; - result: T; + help: string + success: boolean + error?: { + __type: string + message: string + } + result: T } export interface User { @@ -78,4 +87,4 @@ export interface GroupTree { highlighted: boolean; children: GroupTree[]; image_display_url?: string; -} \ No newline at end of file +} diff --git a/deployment/frontend/src/server/api/root.ts b/deployment/frontend/src/server/api/root.ts index a65bf5b0c..bf9b6bfd7 100644 --- a/deployment/frontend/src/server/api/root.ts +++ b/deployment/frontend/src/server/api/root.ts @@ -6,6 +6,7 @@ import { OrganizationRouter } from "./routers/organization"; import { TopicRouter } from "./routers/topics"; import { teamRouter } from './routers/teams' import { uploadsRouter } from './routers/uploads' +import { authRouter } from './routers/auth.router' /** * This is the primary router for your server. @@ -14,6 +15,7 @@ import { uploadsRouter } from './routers/uploads' */ export const appRouter = createTRPCRouter({ dashboardActivity: activityStreamRouter, + auth: authRouter, user: UserRouter, dataset: DatasetRouter, organization: OrganizationRouter, diff --git a/deployment/frontend/src/server/api/routers/auth.router.ts b/deployment/frontend/src/server/api/routers/auth.router.ts new file mode 100644 index 000000000..6c7d15a1d --- /dev/null +++ b/deployment/frontend/src/server/api/routers/auth.router.ts @@ -0,0 +1,65 @@ +import { env } from '@/env.mjs' +import { User } from '@/interfaces/user.interface' +import { + RequestResetPasswordSchema, + ResetPasswordSchema, +} from '@/schema/auth.schema' +import { CkanResponse } from '@/schema/ckan.schema' +import { createTRPCRouter, publicProcedure } from '@/server/api/trpc' +import ky from 'ky' + +export const authRouter = createTRPCRouter({ + requestPasswordReset: publicProcedure + .input(RequestResetPasswordSchema) + .mutation(async ({ input }) => { + try { + const userUpdate: CkanResponse = await ky + .post(`${env.CKAN_URL}/api/3/action/password_reset`, { + json: { + email: input.email, + }, + }) + .json() + + return userUpdate + } catch (e) { + console.log(e) + throw new Error( + 'Failed to request password reset. Try again in a few seconds. If the error persists, please contact the system administrator.' + ) + } + }), + resetPassword: publicProcedure + .input(ResetPasswordSchema) + .mutation(async ({ input }) => { + try { + const userShow: CkanResponse = await ky + .post( + `${env.CKAN_URL}/api/3/action/user_show?id=${input.id}`, + { + headers: { + Authorization: env.SYS_ADMIN_API_KEY, + }, + } + ) + .json() + + const userUpdate: CkanResponse = await ky + .post(`${env.CKAN_URL}/api/3/action/user_update`, { + json: { + ...userShow.result, + password: input.password, + reset_key: input.reset_key, + }, + }) + .json() + + return userUpdate + } catch (e) { + console.log(e) + throw new Error( + 'Failed to reset password. Try again in a few seconds. If the error persists, please contact the system administrator.' + ) + } + }), +}) diff --git a/deployment/frontend/src/server/auth.ts b/deployment/frontend/src/server/auth.ts index 87db74b5a..1ba64e663 100644 --- a/deployment/frontend/src/server/auth.ts +++ b/deployment/frontend/src/server/auth.ts @@ -1,16 +1,16 @@ /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ -import { type GetServerSidePropsContext } from "next"; +import { type GetServerSidePropsContext } from 'next' import { - getServerSession, - type NextAuthOptions, - type DefaultSession, - User, -} from "next-auth"; -import CredentialsProvider from "next-auth/providers/credentials"; -import { env } from "@/env.mjs"; -import ky from "ky"; -import type { CkanResponse } from "@/schema/ckan.schema"; -import { Organization } from "@portaljs/ckan"; + getServerSession, + type NextAuthOptions, + type DefaultSession, + User, +} from 'next-auth' +import CredentialsProvider from 'next-auth/providers/credentials' +import { env } from '@/env.mjs' +import ky from 'ky' +import type { CkanResponse } from '@/schema/ckan.schema' +import { Organization } from '@portaljs/ckan' /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -18,23 +18,23 @@ import { Organization } from "@portaljs/ckan"; * * @see https://next-auth.js.org/getting-started/typescript#module-augmentation */ -declare module "next-auth" { - interface Session extends DefaultSession { - user: DefaultSession["user"] & { - id: string; - email: string; - username: string; - apikey: string; - teams: { name: string; id: string }[]; - }; - } +declare module 'next-auth' { + interface Session extends DefaultSession { + user: DefaultSession['user'] & { + id: string + email: string + username: string + apikey: string + teams: { name: string; id: string }[] + } + } - interface User { - email: string; - username: string; - apikey: string; - teams: { name: string; id: string }[]; - } + interface User { + email: string + username: string + apikey: string + teams: { name: string; id: string }[] + } } /** @@ -43,93 +43,115 @@ declare module "next-auth" { * @see https://next-auth.js.org/configuration/options */ export const authOptions: NextAuthOptions = { - callbacks: { - jwt({ token, user }) { - if (user) { - token.apikey = user.apikey; - token.teams = user.teams; - } - return token; + pages: { + signIn: '/', + signOut: '/', + error: '/', + verifyRequest: '/', + newUser: '/', }, - session: ({ session, token }) => { - return { - ...session, - user: { - ...session.user, - apikey: token.apikey ? token.apikey : "", - teams: token.teams ? token.teams : { name: "", id: "" }, - id: token.sub, + callbacks: { + jwt({ token, user }) { + if (user) { + token.apikey = user.apikey + token.teams = user.teams + } + return token + }, + session: ({ session, token }) => { + return { + ...session, + user: { + ...session.user, + apikey: token.apikey ? token.apikey : '', + teams: token.teams ? token.teams : { name: '', id: '' }, + id: token.sub, + }, + } }, - }; }, - }, - pages: { + /*pages: { signIn: "/auth/signin", - }, - providers: [ - CredentialsProvider({ - // The name to display on the sign in form (e.g. "Sign in with...") - name: "Credentials", - // `credentials` is used to generate a form on the sign in page. - // You can specify which fields should be submitted, by adding keys to the `credentials` object. - // e.g. domain, username, password, 2FA token, etc. - // You can pass any HTML attribute to the tag through the object. - credentials: { - username: { label: "Username", type: "text", placeholder: "jsmith" }, - password: { label: "Password", type: "password" }, - }, - async authorize(credentials, _req) { - try { - if (!credentials) return null; - const userRes = await fetch( - `${env.CKAN_URL}/api/3/action/user_login`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: credentials.username, - password: credentials.password, - }), + },*/ + providers: [ + CredentialsProvider({ + // The name to display on the sign in form (e.g. "Sign in with...") + name: 'Credentials', + // `credentials` is used to generate a form on the sign in page. + // You can specify which fields should be submitted, by adding keys to the `credentials` object. + // e.g. domain, username, password, 2FA token, etc. + // You can pass any HTML attribute to the tag through the object. + credentials: { + username: { + label: 'Username', + type: 'text', + placeholder: 'jsmith', + }, + password: { label: 'Password', type: 'password' }, }, - ); - const user: CkanResponse = await userRes.json(); - if (user.result.id) { - const orgListRes = await fetch( - `${env.CKAN_URL}/api/3/action/organization_list_for_user`, - { - method: "POST", - body: JSON.stringify({ id: user.result.id }), - headers: { Authorization: user.result.apikey, "Content-Type": "application/json" }, - }, - ); - const orgList: CkanResponse = - await orgListRes.json(); + async authorize(credentials, _req) { + try { + console.log('Login credentials', credentials) + console.log('Ckan URL', env.CKAN_URL) + if (!credentials) return null + const userRes = await fetch( + `${env.CKAN_URL}/api/3/action/user_login`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: credentials.username, + password: credentials.password, + }), + } + ) + const user: CkanResponse< + User & { frontend_token: string } + > = await userRes.json() - return { - ...user.result, - image: "", - apikey: user.result.frontend_token ?? '', - teams: orgList.result.map((org) => ({ - name: org.name, - id: org.id, - })), - }; - } else { - return Promise.reject( - "/auth/signin?error=Could%20not%20login%20user%20please%20check%20your%20password%20and%20username", - ); - } - } catch (e) { - return Promise.reject( - "/auth/signin?error=Could%20not%20login%20user%20please%20check%20your%20password%20and%20username", - ); - } - }, - }), - ], -}; + if ((user.result as any).errors) { + // TODO: error from the response should be sent to the client, but it's not working + throw new Error((user.result as any).error_summary.auth) + } + + if (user.result.id) { + const orgListRes = await fetch( + `${env.CKAN_URL}/api/3/action/organization_list_for_user`, + { + method: 'POST', + body: JSON.stringify({ id: user.result.id }), + headers: { + Authorization: user.result.frontend_token, + 'Content-Type': 'application/json', + }, + } + ) + const orgList: CkanResponse = + await orgListRes.json() + + console.log('Org list', orgList) + return { + ...user.result, + image: '', + apikey: user.result.frontend_token, + teams: orgList.result.map((org) => ({ + name: org?.name ?? '', + id: org?.id ?? '', + })), + } + } else { + throw 'An unexpected error occurred while signing in. Please, try again.' + } + } catch (e) { + console.log('Error', e) + throw e + } + }, + }), + ], +} /** * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. @@ -137,8 +159,8 @@ export const authOptions: NextAuthOptions = { * @see https://next-auth.js.org/configuration/nextjs */ export const getServerAuthSession = (ctx: { - req: GetServerSidePropsContext["req"]; - res: GetServerSidePropsContext["res"]; + req: GetServerSidePropsContext['req'] + res: GetServerSidePropsContext['res'] }) => { - return getServerSession(ctx.req, ctx.res, authOptions); -}; + return getServerSession(ctx.req, ctx.res, authOptions) +} diff --git a/docs/auth/README.md b/docs/auth/README.md new file mode 100644 index 000000000..32d450e7a --- /dev/null +++ b/docs/auth/README.md @@ -0,0 +1,18 @@ +# Auth + +## Log in + +To log in, users can find the "Login" button on the navbar on any page. Note that after logging in, this button changes to the name of the user, which when clicked opens a menu that allows users to access the dashboard and log out. + +## Dashboard page + +If a user tries to access the dashboard page without being logged in, he will get redirected to the homepage. + +## Password reset + +On the same login modal used for logging in, users can also reset their password. The password reset process is: + +1) The users requests the password reset for the email address associated with his account +2) The user receives an email with a reset password link +3) Upon accessing the link, the user can choose a new password +4) After the password is successfuly reset, the user is automatically signed in diff --git a/e2e-tests/cypress/e2e/auth.cy.js b/e2e-tests/cypress/e2e/auth.cy.js new file mode 100644 index 000000000..a4cad3498 --- /dev/null +++ b/e2e-tests/cypress/e2e/auth.cy.js @@ -0,0 +1,71 @@ +const ckanUserName = Cypress.env("CKAN_USERNAME"); +const ckanUserPassword = Cypress.env("CKAN_PASSWORD"); + +describe("Login modal", () => { + it("can be found on the homepage", () => { + cy.visit({ url: "/" }); + cy.get("#nav-login-button").click(); + cy.get("#login-modal").as("login-modal").should("be.visible"); + }); + + it("can be found on the topics page", () => { + cy.visit({ url: "/topics" }); + cy.get("#nav-login-button").click(); + cy.get("#login-modal").as("login-modal").should("be.visible"); + }); + + it("can be used to sign in", () => { + cy.visit({ url: "/" }); + cy.get("#nav-login-button").click(); + cy.get("#login-modal").as("login-modal"); + + cy.get("@login-modal").get('input[name="username"]').type(ckanUserName); + cy.get("@login-modal").get('input[name="password"]').type(ckanUserPassword); + + cy.get("button#login-button").click({ force: true }); + + cy.get("#nav-user-menu").should("be.visible"); + }); + + it("can be used to request a password reset link", () => { + cy.visit({ url: "/" }); + cy.get("#nav-login-button").click(); + cy.get("#login-modal").as("login-modal"); + + cy.get("#forgot-password-button").click(); + + cy.get('input[name="email"]').type("datopian@gmail.com"); + + cy.get("#request-reset-button").click(); + + // cy.contains("Password reset link sent to email address"); + }); +}); + +describe("reset password form", () => { + it("cannot be accessed unauthorized users", () => { + cy.visit("/auth/password-reset"); + cy.url().should("not.include", "password-reset"); + }); +}); + +describe("dashboard page", () => { + it("can only be accessed by signed in users", () => { + cy.visit("/dashboard"); + + cy.url().should("not.include", "dashboard"); + + cy.get("#nav-login-button").click(); + cy.get("#login-modal").as("login-modal"); + + cy.get("@login-modal").get('input[name="username"]').type(ckanUserName); + cy.get("@login-modal").get('input[name="password"]').type(ckanUserPassword); + + cy.get("button#login-button").click({ force: true }); + + cy.get("#nav-user-menu").should("be.visible"); + + cy.visit("/dashboard/datasets"); + cy.url().should("include", "dashboard"); + }); +}); diff --git a/e2e-tests/cypress/support/e2e.js b/e2e-tests/cypress/support/e2e.js index 3614fba1e..4c0b1668f 100644 --- a/e2e-tests/cypress/support/e2e.js +++ b/e2e-tests/cypress/support/e2e.js @@ -24,185 +24,200 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) -const cypressUpload = require('cypress-file-upload') -const headers = { Authorization: Cypress.env('API_KEY') } +const cypressUpload = require("cypress-file-upload"); +const headers = { Authorization: Cypress.env("API_KEY") }; -const getRandomDatasetName = () => Math.random().toString(36).slice(2) + Cypress.env('DATASET_NAME_SUFFIX') -const getRandomOrganizationName = () => Math.random().toString(36).slice(2) + Cypress.env('ORG_NAME_SUFFIX') +const getRandomDatasetName = () => + Math.random().toString(36).slice(2) + Cypress.env("DATASET_NAME_SUFFIX"); +const getRandomOrganizationName = () => + Math.random().toString(36).slice(2) + Cypress.env("ORG_NAME_SUFFIX"); const apiUrl = (path) => { - return `${Cypress.config().apiUrl}/api/3/action/${path}` -} - -Cypress.Commands.add('login', (email, password) => { - cy.visit({url: '/auth/signin'}).then(() => { - cy.get('#username').type(email) - cy.get('#password').type(password) - cy.get('button').click({force: true}) - cy.url().should('eq', 'http://localhost:3000/', {timeout: 10000}) - }) -}); + return `${Cypress.config().apiUrl}/api/3/action/${path}`; +}; +Cypress.Commands.add("login", (username, password) => { + cy.session([username, password], () => { + cy.visit("/"); + cy.get("#nav-login-button").click(); + cy.get("#login-modal").as("login-modal"); -Cypress.Commands.add('createDatasetWithoutFile', (name) => { - cy.visit({url: '/dataset'}).then((resp) => { - const datasetName = name || getRandomDatasetName() - cy.get('.page_primary_action > .btn').click() - cy.get('#field-title').type(datasetName) - cy.get('.btn-xs').click() - cy.get('#field-name').clear().type(datasetName) - cy.get('button.btn-primary[type=submit]').click() - cy.wrap(datasetName) - }) -}) + cy.get("@login-modal").get('input[name="username"]').type(username); + cy.get("@login-modal").get('input[name="password"]').type(password); + + cy.get("button#login-button").click({ force: true }); + + cy.get("#nav-user-menu").should("be.visible"); + }); +}); -Cypress.Commands.add('createDataset', (dataset = false, private_vis = true) => { - let datasetName = dataset - let is_private = private_vis - cy.visit({url: '/dataset'}).then((resp) => { - if (!datasetName){ - datasetName = getRandomDatasetName() +Cypress.Commands.add("createDatasetWithoutFile", (name) => { + cy.visit({ url: "/dataset" }).then((resp) => { + const datasetName = name || getRandomDatasetName(); + cy.get(".page_primary_action > .btn").click(); + cy.get("#field-title").type(datasetName); + cy.get(".btn-xs").click(); + cy.get("#field-name").clear().type(datasetName); + cy.get("button.btn-primary[type=submit]").click(); + cy.wrap(datasetName); + }); +}); + +Cypress.Commands.add("createDataset", (dataset = false, private_vis = true) => { + let datasetName = dataset; + let is_private = private_vis; + cy.visit({ url: "/dataset" }).then((resp) => { + if (!datasetName) { + datasetName = getRandomDatasetName(); } - cy.get('.page_primary_action > .btn').click() - cy.get('#field-title').type(datasetName) - cy.get('.btn-xs').click() - cy.get('#field-name').clear().type(datasetName) - if(!is_private){ - cy.get('#field-private').select('False') + cy.get(".page_primary_action > .btn").click(); + cy.get("#field-title").type(datasetName); + cy.get(".btn-xs").click(); + cy.get("#field-name").clear().type(datasetName); + if (!is_private) { + cy.get("#field-private").select("False"); } - cy.get('button.btn-primary[type=submit]').click() - cy.get('#field-image-upload').attachFile({ filePath: 'sample.csv', fileName: 'sample.csv' }) - cy.get('.btn-primary').click() - cy.get('.content_action > .btn') - cy.wrap(datasetName) - }) -}) + cy.get("button.btn-primary[type=submit]").click(); + cy.get("#field-image-upload").attachFile({ + filePath: "sample.csv", + fileName: "sample.csv", + }); + cy.get(".btn-primary").click(); + cy.get(".content_action > .btn"); + cy.wrap(datasetName); + }); +}); -Cypress.Commands.add('createLinkedDataset', () => { - cy.visit({url: '/dataset'}).then((resp) => { - const datasetName = getRandomDatasetName() - cy.get('.page_primary_action > .btn').click() - cy.get('#field-title').type(datasetName) - cy.get('.btn-xs').click() - cy.get('#field-name').clear().type(datasetName) - cy.get('button.btn-primary[type=submit]').click({force: true}) - cy.get('[title="Link to a URL on the internet (you can also link to an API)"]').click() - cy.get('#field-image-url').clear().type('https://raw.githubusercontent.com/datapackage-examples/sample-csv/master/sample.csv') - cy.get('.btn-primary').click() - cy.get('.content_action > .btn') - cy.wrap(datasetName) - }) -}) +Cypress.Commands.add("createLinkedDataset", () => { + cy.visit({ url: "/dataset" }).then((resp) => { + const datasetName = getRandomDatasetName(); + cy.get(".page_primary_action > .btn").click(); + cy.get("#field-title").type(datasetName); + cy.get(".btn-xs").click(); + cy.get("#field-name").clear().type(datasetName); + cy.get("button.btn-primary[type=submit]").click({ force: true }); + cy.get( + '[title="Link to a URL on the internet (you can also link to an API)"]' + ).click(); + cy.get("#field-image-url") + .clear() + .type( + "https://raw.githubusercontent.com/datapackage-examples/sample-csv/master/sample.csv" + ); + cy.get(".btn-primary").click(); + cy.get(".content_action > .btn"); + cy.wrap(datasetName); + }); +}); -Cypress.Commands.add('updatePackageMetadata', (datasetName) => { +Cypress.Commands.add("updatePackageMetadata", (datasetName) => { const request = cy.request({ - method: 'POST', - url: apiUrl('package_patch'), + method: "POST", + url: apiUrl("package_patch"), headers: headers, body: { id: datasetName, - notes: "Update notes" + notes: "Update notes", }, - }) -}) - + }); +}); -Cypress.Commands.add('updateResourceMetadata', (datasetName) => { +Cypress.Commands.add("updateResourceMetadata", (datasetName) => { const request = cy.request({ - method: 'POST', - url: apiUrl('resource_patch'), + method: "POST", + url: apiUrl("resource_patch"), headers: headers, body: { id: datasetName, - description: "Update description" + description: "Update description", }, - }) -}) + }); +}); -Cypress.Commands.add('deleteDataset', (datasetName) => { - cy.visit({url: '/dataset/delete/' + datasetName}).then(() => { - cy.get('form#confirm-dataset-delete-form > .btn-primary').click() - cy.contains('Dataset has been deleted.') - }) -}) +Cypress.Commands.add("deleteDataset", (datasetName) => { + cy.visit({ url: "/dataset/delete/" + datasetName }).then(() => { + cy.get("form#confirm-dataset-delete-form > .btn-primary").click(); + cy.contains("Dataset has been deleted."); + }); +}); -Cypress.Commands.add('purgeDataset', (datasetName) => { +Cypress.Commands.add("purgeDataset", (datasetName) => { const request = cy.request({ - method: 'POST', - url: apiUrl('dataset_purge'), + method: "POST", + url: apiUrl("dataset_purge"), headers: headers, body: { - id: datasetName + id: datasetName, }, - }) -}) + }); +}); -Cypress.Commands.add('createOrganization', () => { - const organizationName = getRandomOrganizationName() - cy.get('.nav > :nth-child(2) > a').first().click() - cy.get('.page_primary_action > .btn').click() - cy.get('#field-name').type(organizationName) - cy.get('.btn-xs').click() - cy.get('#field-url').clear().type(organizationName) - cy.get('.form-actions > .btn').click() - cy.location('pathname').should('eq', '/organization/' + organizationName) - cy.wrap(organizationName) -}) +Cypress.Commands.add("createOrganization", () => { + const organizationName = getRandomOrganizationName(); + cy.get(".nav > :nth-child(2) > a").first().click(); + cy.get(".page_primary_action > .btn").click(); + cy.get("#field-name").type(organizationName); + cy.get(".btn-xs").click(); + cy.get("#field-url").clear().type(organizationName); + cy.get(".form-actions > .btn").click(); + cy.location("pathname").should("eq", "/organization/" + organizationName); + cy.wrap(organizationName); +}); -Cypress.Commands.add('deleteOrganization', (orgName) => { - cy.visit({url: '/organization/' + orgName}).then(() => { - cy.get('.content_action > .btn').click() - cy.get('.form-actions > .btn-danger').click() - cy.get('.btn-primary').click() - cy.contains('Organization has been deleted.') - }) -}) +Cypress.Commands.add("deleteOrganization", (orgName) => { + cy.visit({ url: "/organization/" + orgName }).then(() => { + cy.get(".content_action > .btn").click(); + cy.get(".form-actions > .btn-danger").click(); + cy.get(".btn-primary").click(); + cy.contains("Organization has been deleted."); + }); +}); // Command for frontend test sepecific -Cypress.Commands.add('createOrganizationAPI', (name) => { +Cypress.Commands.add("createOrganizationAPI", (name) => { cy.request({ - method: 'POST', - url: apiUrl('organization_create'), + method: "POST", + url: apiUrl("organization_create"), headers: headers, - body: { + body: { name: name, title: name, - description: "Some organization description" + description: "Some organization description", }, - }) -}) + }); +}); // Command for frontend test sepecific -Cypress.Commands.add('createGroupAPI', (name) => { +Cypress.Commands.add("createGroupAPI", (name) => { cy.request({ - method: 'POST', - url: apiUrl('group_create'), + method: "POST", + url: apiUrl("group_create"), headers: headers, - body: { + body: { name: name, title: name, - description: "Some group description" + description: "Some group description", }, - }) -}) + }); +}); -Cypress.Commands.add('deleteGroupAPI', (name) => { +Cypress.Commands.add("deleteGroupAPI", (name) => { cy.request({ - method: 'POST', - url: apiUrl('group_delete'), + method: "POST", + url: apiUrl("group_delete"), headers: headers, body: { id: name }, - }) -}) + }); +}); -Cypress.Commands.add('deleteOrganizationAPI', (name) => { +Cypress.Commands.add("deleteOrganizationAPI", (name) => { cy.request({ - method: 'POST', - url: apiUrl('organization_delete'), + method: "POST", + url: apiUrl("organization_delete"), headers: headers, body: { id: name }, - }) -}) + }); +}); Cypress.Commands.add('createDatasetAPI', (organization, name, isSubscribable) => { const request = cy.request({ @@ -230,145 +245,156 @@ Cypress.Commands.add('createDatasetAPI', (organization, name, isSubscribable) => } }) -Cypress.Commands.add('createResourceAPI', (dataset, resource) => { +Cypress.Commands.add("createResourceAPI", (dataset, resource) => { const request = cy.request({ - method: 'POST', - url: apiUrl('datastore_create'), + method: "POST", + url: apiUrl("datastore_create"), headers: headers, body: { resource: { package_id: dataset, name: resource, - format: 'CSV', + format: "CSV", }, records: [ { - name: ' Jhon Mayer', + name: " Jhon Mayer", age: 29, }, ], - force: 'True', + force: "True", }, - }) -}) - + }); +}); -Cypress.Commands.add('updateResourceRecord', (resource) => { +Cypress.Commands.add("updateResourceRecord", (resource) => { const request = cy.request({ - method: 'POST', - url: apiUrl('datastore_upsert'), + method: "POST", + url: apiUrl("datastore_upsert"), headers: headers, body: { - resource_id : resource, + resource_id: resource, records: [ { - name: 'Jhon lenon', - age: 60 + name: "Jhon lenon", + age: 60, }, ], - method:"insert", - force: true + method: "insert", + force: true, }, - }) -}) - + }); +}); -Cypress.Commands.add('deleteDatasetAPI', (name) => { +Cypress.Commands.add("deleteDatasetAPI", (name) => { const request = cy.request({ - method: 'POST', - url: apiUrl('package_delete'), + method: "POST", + url: apiUrl("package_delete"), headers: headers, body: { - id: name + id: name, }, - }) -}) + }); +}); -Cypress.Commands.add('datasetCount', (name) => { - return cy.request({ - method: 'GET', - url: apiUrl('package_search'), - headers: headers, - body: { - rows: 1, - }, - }).then ((res) => { - return res.body.result.count - }) -}) +Cypress.Commands.add("datasetCount", (name) => { + return cy + .request({ + method: "GET", + url: apiUrl("package_search"), + headers: headers, + body: { + rows: 1, + }, + }) + .then((res) => { + return res.body.result.count; + }); +}); -Cypress.Commands.add('groupCount', (name) => { - return cy.request({ - method: 'GET', - url: apiUrl('organization_list'), - headers: headers, - }).then ((res) => { - return res.body.result.length - }) -}) +Cypress.Commands.add("groupCount", (name) => { + return cy + .request({ + method: "GET", + url: apiUrl("organization_list"), + headers: headers, + }) + .then((res) => { + return res.body.result.length; + }); +}); -Cypress.Commands.add('facetFilter', (facetType,facetValue ) => { - return cy.request({ - method: 'GET', - url: apiUrl('package_search'), - headers: headers, - qs: { - fq: `${facetType}:${facetValue}` - } - }).then ((res) => { - return res.body.result.count - }) -}) +Cypress.Commands.add("facetFilter", (facetType, facetValue) => { + return cy + .request({ + method: "GET", + url: apiUrl("package_search"), + headers: headers, + qs: { + fq: `${facetType}:${facetValue}`, + }, + }) + .then((res) => { + return res.body.result.count; + }); +}); -Cypress.Commands.add('prepareFile', (dataset, file, format) => { - cy.fixture(`${file}`, 'binary') - .then(Cypress.Blob.binaryStringToBlob).then((blob) => { +Cypress.Commands.add("prepareFile", (dataset, file, format) => { + cy.fixture(`${file}`, "binary") + .then(Cypress.Blob.binaryStringToBlob) + .then((blob) => { var data = new FormData(); data.append("package_id", `${dataset}`); data.append("name", `${file}`); data.append("format", `${format}`); - data.append("description", "Lorem Ipsum is simply dummy text of the printing and type"); + data.append( + "description", + "Lorem Ipsum is simply dummy text of the printing and type" + ); data.append("upload", blob, `${file}`); var xhr = new XMLHttpRequest(); xhr.withCredentials = true; - xhr.open("POST", apiUrl('resource_create')); - xhr.setRequestHeader( - "Authorization", headers.Authorization, - ); + xhr.open("POST", apiUrl("resource_create")); + xhr.setRequestHeader("Authorization", headers.Authorization); xhr.send(data); - }) -}) - -Cypress.Commands.add('datasetMetadata', (dataset) => { - return cy.request({ - method: 'GET', - url: apiUrl('package_show'), - headers: headers, - qs: { - id: dataset - } - }).then ((res) => { - return res.body.result - }) -}) + }); +}); -Cypress.Commands.add('orgMetadata', (org) => { - return cy.request({ - method: 'GET', - url: apiUrl('organization_show'), - headers: headers, - qs: { - id: org - } - }).then ((res) => { - return res.body.result - }) -}) +Cypress.Commands.add("datasetMetadata", (dataset) => { + return cy + .request({ + method: "GET", + url: apiUrl("package_show"), + headers: headers, + qs: { + id: dataset, + }, + }) + .then((res) => { + return res.body.result; + }); +}); +Cypress.Commands.add("orgMetadata", (org) => { + return cy + .request({ + method: "GET", + url: apiUrl("organization_show"), + headers: headers, + qs: { + id: org, + }, + }) + .then((res) => { + return res.body.result; + }); +}); -Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe) => { - const $iframeDoc = $iframe.contents() - const findBody = () => $iframeDoc.find('body') - if ($iframeDoc.prop('readyState') === 'complete') return findBody() - return Cypress.Promise((resolve) => $iframe.on('load', () => resolve(findBody()))) -}) +Cypress.Commands.add("iframe", { prevSubject: "element" }, ($iframe) => { + const $iframeDoc = $iframe.contents(); + const findBody = () => $iframeDoc.find("body"); + if ($iframeDoc.prop("readyState") === "complete") return findBody(); + return Cypress.Promise((resolve) => + $iframe.on("load", () => resolve(findBody())) + ); +});