Merged PR 849: Passwordless auth poc

Related work items: #7608
This commit is contained in:
Robert Bond
2022-05-06 09:12:43 +00:00
26 changed files with 4745 additions and 2567 deletions
+131
View File
@@ -0,0 +1,131 @@
ACCESS_TOKEN_ENDPOINT = https://login.microsoftonline.com/
//ESNR Tenant
TENANT = a8d860de-d5e1-45af-8376-d93f02e6b502
//WGO Tenant
//TENANT = 9ac1a5ab-cd58-409e-a1b6-6b10422a68f5
GRANT_TYPE = "client_credentials"
HASHKEY = 028ffbae928d7191c0017f298260995f901d78eabde1436c0af0423459cc3715832136d84f68818d3a96399467b52143e273d4f3bf4ae190848891535421cd6c
NEXT_PUBLIC_HASHKEY = 028ffbae928d7191c0017f298260995f901d78eabde1436c0af0423459cc3715832136d84f68818d3a96399467b52143e273d4f3bf4ae190848891535421cd6c
//Dev oauth ESNR
CLIENT_ID = e082def0-3453-461f-9a5e-f4ab127dc015
CLIENT_SECRET = qW.7Q~TSr3sZmLEnJQJPXEdOckb_yo2t1yJmY
RELAY_ROOT = https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/
RELAYURI = "dev-pedw-ns.servicebus.windows.net"
RELAYPATH = "dev-pedw-hc"
//Test Oauth ESNR
//CLIENT_ID = 6619ad85-c908-490d-811b-de8c93c0f2aa
//CLIENT_SECRET = qKo7Q~5QvLEplbwnBbint_I5lXqx_-kDm8Vj-
//RELAY_ROOT = https://test-pedw-ns.servicebus.windows.net/test-pedw-hc/
//RELAYURI = "test-pedw-ns.servicebus.windows.net"
//RELAYPATH = "test-pedw-hc"
//Preprod Oauth WGO
//CLIENT_ID = ab6c4678-b31a-4eb3-b429-e039120e488a
//CLIENT_SECRET = Ioo7Q~RJfhWsE45wPsuHm1CzRk1SppnNN3lZF
//RELAY_ROOT = https://pp-pedw-ns.servicebus.windows.net/pp-pedw-hc/
//RELAYURI = "pp-pedw-ns.servicebus.windows.net"
//RELAYPATH = "pp-pedw-hc"
//Prod Oauth WGO
//CLIENT_ID = 3e4141a3-1fbd-454e-98f0-7f3a4f14a3cc
//CLIENT_SECRET = mJN7Q~lFu.xFGa5KCc.zwIm_fJAWxajyQaFo~
//RELAY_ROOT = https://prod-pedw-ns.servicebus.windows.net/prod-pedw-hc/
//RELAYURI = "prod-pedw-ns.servicebus.windows.net"
//RELAYPATH = "prod-pedw-hc"
GOOGLE_TAG_MANAGER = GTM-T78CBC3
SHOWLOGIN = "true"
SHOWREPRESENTATIONS = false
SHOWFILEUPLOAD = "false"
SHOWMAPS = "true"
//BASIC_AUTH_CREDENTIALS = pinswg:password|pinswg2:password2
PORT= "3000"
DEFAULT_DOMAIN = "localhost"
I18N_DOMAIN = "cymru.local"
//I18N_DOMAIN ="dev-gwaithcynllunio.gwasanaeth.llyw.cymru"
//I18N_DOMAIN ="stage-gwaithcynllunio.gwasanaeth.llyw.cymru"
//I18N_DOMAIN ="pp-gwaithcynllunio.gwasanaeth.llyw.cymru"s
//I18N_DOMAIN ="gwaithcynllunio.gwasanaeth.llyw.cymru"
//gov gateway
GG_PROVIDER_CONFIG_ENDPOINT = "https://api.ete.access.service.gov.uk/.well-known/openid-configuration"
GG_CLIENT_ID = 5vTukeSpN7d6uBV7YLb7A5wOctzzkZ
GG_CLIENT_SECRET = z1PGLAKPxIhjMBegTdzX22AayIORc1GsCbMQZ6mieBn7zChjzzxup5lkIByEmsYS
//GG_REDIRECT_URI = "http://localhost:3000"
GG_REDIRECT_URI = "https://dev-planningcasework.service.gov.wales"
NEXT_PUBLIC_GG_CLIENT_ID = 5vTukeSpN7d6uBV7YLb7A5wOctzzkZ
NEXT_PUBLIC_GG_REDIRECT_URI = "http://localhost:3000"
API_ROOT = "http://localhost:3000"
GOOGLE_MAP_API_KEY = AIzaSyCzeVrHt544bp_72YCFkw21eDsmI2JEsQo
//EMAIL_SERVER_HOST=smtp.livemail.co.uk
//EMAIL_SERVER_PORT=465
//EMAIL_SERVER_USER=rob@rdbsolutions.co.uk
//EMAIL_SERVER_PASSWORD=R0b3rt72
//EMAIL_FROM=rob@rdbsolutions.co.uk
EMAIL_SERVER_HOST=smtp.sendgrid.net
EMAIL_SERVER_PORT=465
EMAIL_SERVER_USER=apikey
EMAIL_SERVER_PASSWORD=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
EMAIL_FROM=rob@rdbsolutions.co.uk
NEXT_PUBLIC_MAGIC_PK=pk_live_674E13244D1BB0A4
MAGIC_SK=sk_live_F0E74A9557051A65
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET= # Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
APPLE_ID=
APPLE_TEAM_ID=
APPLE_PRIVATE_KEY=
APPLE_KEY_ID=
AUTH0_ID=
AUTH0_SECRET=
AUTH0_ISSUER=
FACEBOOK_ID=
FACEBOOK_SECRET=
GITHUB_ID=
GITHUB_SECRET=
GOOGLE_ID=
GOOGLE_SECRET=
TWITTER_ID=
TWITTER_SECRET=
//EMAIL_SERVER=smtp://username:password@smtp.example.com:587
//EMAIL_FROM=NextAuth <noreply@example.com>
//DATABASE_URL=sqlite://localhost/:memory:?synchronize=true
DATABASE_URL=mysql://nextAuthAdmin:!Sky9fish1972@77.68.17.157:3306/nextauthDB?synchronise=true
SECRET=SuperSecret
+15 -1
View File
@@ -74,4 +74,18 @@ NEXT_PUBLIC_GG_REDIRECT_URI = "http://localhost:3000"
API_ROOT = "http://localhost:3000" API_ROOT = "http://localhost:3000"
GOOGLE_MAP_API_KEY = AIzaSyCzeVrHt544bp_72YCFkw21eDsmI2JEsQo GOOGLE_MAP_API_KEY = AIzaSyCzeVrHt544bp_72YCFkw21eDsmI2JEsQo
//Next Auth vars
EMAIL_SERVER_HOST=smtp.sendgrid.net
EMAIL_SERVER_PORT=465
EMAIL_SERVER_USER=apikey
EMAIL_SERVER_PASSWORD=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
EMAIL_FROM=rob@rdbsolutions.co.uk
NEXTAUTH_URL=http://localhost:3000
DATABASE_URL=mysql://nextAuthAdmin:!Sky9fish1972@77.68.17.157:3306/nextauthDB?synchronise=true
SECRET=SuperSecret
+2 -2
View File
@@ -195,7 +195,7 @@ const RegisterForm = (props) => {
)} )}
/> />
<Field {/* <Field
name="pinswg_custom_password" name="pinswg_custom_password"
id="pinswg_custom_password" id="pinswg_custom_password"
component={RenderTextfield} component={RenderTextfield}
@@ -226,7 +226,7 @@ const RegisterForm = (props) => {
errorMsg={t( errorMsg={t(
"account:register-new-account-confirm-password-label-errormsg" "account:register-new-account-confirm-password-label-errormsg"
)} )}
/> /> */}
</fieldset> </fieldset>
<fieldset className="govuk-fieldset"> <fieldset className="govuk-fieldset">
<legend className="govuk-fieldset__legend govuk-fieldset__legend--m"> <legend className="govuk-fieldset__legend govuk-fieldset__legend--m">
+18
View File
@@ -741,6 +741,24 @@ const Breadcrumbs = (props) => {
) : ( ) : (
"" ""
)} )}
{router.pathname == "/auth/signin" ? (
<>
<li className="govuk-breadcrumbs__list-item">
Sign in
</li>
</>
) : (
""
)}
{router.pathname == "/auth/verify-request" ? (
<>
<li className="govuk-breadcrumbs__list-item">
Check your email
</li>
</>
) : (
""
)}
{/* {!_.isEmpty(slug) ? ( {/* {!_.isEmpty(slug) ? (
<> <>
<li className="govuk-breadcrumbs__list-item"> <li className="govuk-breadcrumbs__list-item">
+19 -5
View File
@@ -27,6 +27,7 @@ import RepAgent from "./representationAgent";
import RepInterestedPartyPerson from "./representationInterestedPartyPerson"; import RepInterestedPartyPerson from "./representationInterestedPartyPerson";
import RepLandOwner from "./representationLandowner"; import RepLandOwner from "./representationLandowner";
import RepCompleteSubmit from "./representationCompleteSubmit"; import RepCompleteSubmit from "./representationCompleteSubmit";
import { useSession, signIn, signOut } from "next-auth/react";
import { import {
RenderPickList, RenderPickList,
@@ -39,6 +40,7 @@ let MakeRepresentation = (props) => {
const { acceptedFiles, getRootProps, getInputProps } = useDropzone(); const { acceptedFiles, getRootProps, getInputProps } = useDropzone();
const [clearRepForm, setClearRepForm] = useState(false); const [clearRepForm, setClearRepForm] = useState(false);
const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const { locale } = router; const { locale } = router;
@@ -250,11 +252,23 @@ let MakeRepresentation = (props) => {
/> />
) : ( ) : (
<> <>
<h1 className="govuk-heading-l"> {!session ? (
Make a representation on:{" "} <button
{currentView.caseReference.currentReference} className="govuk-button withChevron govuk-button-cta"
</h1> type="button"
{whichControl()} onClick={() => signIn("email")}
>
Sign in to raise representation
</button>
) : (
<>
<h1 className="govuk-heading-l">
Make a representation on:{" "}
{currentView.caseReference.currentReference}
</h1>
{whichControl()}
</>
)}
</> </>
)} )}
</form> </form>
+1 -1
View File
@@ -65,7 +65,7 @@ const CaseSummary = (props) => {
} = props; } = props;
const noRepresentationLink = [ const noRepresentationLink = [
"/case", //"/case",
"/dnsdetails", "/dnsdetails",
"/dns/[developmentName]", "/dns/[developmentName]",
]; ];
+8 -1
View File
@@ -7,6 +7,9 @@ import { useDispatch, useSelector, shallowEqual, connect } from "react-redux";
import { setLogout } from "../store/accountDetails/action"; import { setLogout } from "../store/accountDetails/action";
import _ from "lodash"; import _ from "lodash";
import { parseCookies, setCookie, destroyCookie } from "nookies"; import { parseCookies, setCookie, destroyCookie } from "nookies";
import { useSession, signIn, signOut } from "next-auth/react";
const Header = (props) => { const Header = (props) => {
let { t, lang } = useTranslation(); let { t, lang } = useTranslation();
@@ -33,6 +36,9 @@ const Header = (props) => {
"/myportal/error", "/myportal/error",
"/accessibility", "/accessibility",
"/details-about-cookies", "/details-about-cookies",
"/auth/verify-request",
"/auth/signin",
"/auth/error",
]; ];
const hasContactLink = [ const hasContactLink = [
"/dns/application-process", "/dns/application-process",
@@ -214,7 +220,8 @@ const Header = (props) => {
<a <a
onClick={() => { onClick={() => {
destroyCookie({}, "pinsUser"), destroyCookie({}, "pinsUser"),
window.localStorage.clear(); window.localStorage.clear(),
signOut({ callbackUrl: "/logout" });
}} }}
className="govuk-header__link govuk-!-margin-right-3" className="govuk-header__link govuk-!-margin-right-3"
> >
+198 -147
View File
@@ -9,6 +9,8 @@ import { getLogin } from "../../actions";
import { setLoggedInUserId } from "../../store/accountDetails/action"; import { setLoggedInUserId } from "../../store/accountDetails/action";
import { parseCookies, setCookie, destroyCookie } from "nookies"; import { parseCookies, setCookie, destroyCookie } from "nookies";
import { useSession, signIn, signOut } from "next-auth/react";
const required = (errorMsg) => (value) => const required = (errorMsg) => (value) =>
value || typeof value === "number" ? undefined : errorMsg; value || typeof value === "number" ? undefined : errorMsg;
@@ -118,6 +120,8 @@ let Login = (props) => {
GG_REDIRECT_URI, GG_REDIRECT_URI,
} = props; } = props;
const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const { locale } = router; const { locale } = router;
@@ -201,163 +205,210 @@ let Login = (props) => {
router.replace(ggUrl); router.replace(ggUrl);
}; };
if (session === "loading") {
return <h1>loading...</h1>;
}
if (session) {
<>
<div>Signed in as {session.user?.email}</div>
<br />
<button type="button" onClick={() => signOut()}>
Signout
</button>
</>;
}
return ( return (
<div className="card" id="login-card"> <>
<div className="card-body active"> <div className="card" id="login-card">
<h3 className="heading-small card-heading"> <div className="card-body active">
{t("home:login-card-title")} <h3 className="heading-small card-heading">
</h3>{" "} {t("home:login-card-title")}
<form onSubmit={handleSubmit(onHandleSubmit)}> </h3>{" "}
{validLoginID == false && ( <div className="govuk-form-group govuk-!-margin-top-4">
<div className="govuk-error-message govuk-form-group--error"> <div className="govuk-body">
Incorrect username or password {session && "Signed in as " + session.user?.email}
</div> </div>
)} <br />
<Field {!session && (
// validate={[ <button
// required( className="govuk-button withChevron govuk-button-cta"
// t("home:casesearch-search-validation-required") type="button"
// ), onClick={() => signIn("email")}
// minLength( >
// t("home:casesearch-search-validation-minlength") Sign in with email address
// ), </button>
// ]} )}
className="govuk-input" {session && (
component={RenderTextfield} <button
id="loginUserID" className="govuk-button"
name="loginUserID" type="button"
type="text" onClick={() => signOut()}
aria-describedby="basicSearch-hint" >
hint1={t("home:login-card-id-label")} Magic Link Sign Out
label={t("home:login-card-id-hint")} </button>
/> )}
<Field
// validate={[
// required(
// t("home:casesearch-search-validation-required")
// ),
// minLength(
// t("home:casesearch-search-validation-minlength")
// ),
// ]}
className="govuk-input"
component={RenderPasswordfield}
id="loginUserPassword"
name="loginUserPassword"
type="password"
label="Password"
/>
<div className="govuk-form-group">
<button type="submit" className="govuk-button">
{t("home:login-card-button")}
</button>
</div> </div>
</form> <form onSubmit={handleSubmit(onHandleSubmit)}>
<div className="govuk-form-group govuk-!-margin-top-4"> {validLoginID == false && (
<p className="govuk-body-xs"> <div className="govuk-error-message govuk-form-group--error">
<a Incorrect username or password
onClick={() => { </div>
govGatwayLoginRedirect(); )}
}} <Field
className="govuk-button withChevron govuk-button-cta" // validate={[
> // required(
{t("home:login-via-gov-gateway-label")} // t("home:casesearch-search-validation-required")
</a> // ),
</p> // minLength(
<p className="govuk-body-s"> // t("home:casesearch-search-validation-minlength")
{t("home:login-card-register-label")}{" "} // ),
<a // ]}
href={ className="govuk-input"
router.locale == "cy" component={RenderTextfield}
? "https://www.ete.access.service.gov.uk/login/create-account?lang=cy" id="loginUserID"
: "https://www.ete.access.service.gov.uk/login/create-account" name="loginUserID"
} type="text"
className="govuk-link--no-underline" aria-describedby="basicSearch-hint"
> hint1={t("home:login-card-id-label")}
{t("home:login-card-register-link")} label={t("home:login-card-id-hint")}
</a> />
</p>
<p className="govuk-body-s">
<a
onClick={() => {
govGatwayForgottenLink();
}}
className="govuk-link--no-underline"
>
{t("home:login-card-forgotten-label")}{" "}
</a>
</p>
<hr className="govuk-!-margin-bottom-4" />
<p className="govuk-body-s">
{t("home:login-comingsoon-temp-2")}
</p>
<ul>
<li>
<a
className="govuk-link--no-underline"
target="_blank"
href={t("home:login-comingsoon-forms-link-1")}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-1")}
</a>
</li>
<li>
{" "}
<a
className="govuk-link--no-underline"
target="_blank"
href={t("home:login-comingsoon-forms-link-2")}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-2")}
</a>
</li>
<li>
{" "}
<a
className="govuk-link--no-underline"
target="_blank"
href={t("home:login-comingsoon-forms-link-3")}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-3")}
</a>
</li>
<li>
{" "}
<a
className="govuk-link--no-underline"
target="_blank"
href={t("home:login-comingsoon-forms-link-4")}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-4")}
</a>
</li>
</ul>
<br />
<p className="govuk-body-s"> <Field
{t("home:login-comingsoon-email-link-label")}{" "} // validate={[
<a // required(
href={"mailto:" + t("home:login-contact-email")} // t("home:casesearch-search-validation-required")
className="govuk-link--no-underline" // ),
> // minLength(
{t("home:login-contact-email")} // t("home:casesearch-search-validation-minlength")
</a> // ),
</p> // ]}
className="govuk-input"
component={RenderPasswordfield}
id="loginUserPassword"
name="loginUserPassword"
type="password"
label="Password"
/>
<div className="govuk-form-group">
<button type="submit" className="govuk-button">
{t("home:login-card-button")}
</button>
</div>
</form>
{/* <div className="govuk-form-group govuk-!-margin-top-4">
<p className="govuk-body-xs">
<a
onClick={() => {
govGatwayLoginRedirect();
}}
className="govuk-button withChevron govuk-button-cta"
>
{t("home:login-via-gov-gateway-label")}
</a>
</p>
<p className="govuk-body-s">
{t("home:login-card-register-label")}{" "}
<a
href={
router.locale == "cy"
? "https://www.ete.access.service.gov.uk/login/create-account?lang=cy"
: "https://www.ete.access.service.gov.uk/login/create-account"
}
className="govuk-link--no-underline"
>
{t("home:login-card-register-link")}
</a>
</p>
<p className="govuk-body-s">
<a
onClick={() => {
govGatwayForgottenLink();
}}
className="govuk-link--no-underline"
>
{t("home:login-card-forgotten-label")}{" "}
</a>
</p>
<hr className="govuk-!-margin-bottom-4" />
<p className="govuk-body-s">
{t("home:login-comingsoon-temp-2")}
</p>
<ul>
<li>
<a
className="govuk-link--no-underline"
target="_blank"
href={t(
"home:login-comingsoon-forms-link-1"
)}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-1")}
</a>
</li>
<li>
{" "}
<a
className="govuk-link--no-underline"
target="_blank"
href={t(
"home:login-comingsoon-forms-link-2"
)}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-2")}
</a>
</li>
<li>
{" "}
<a
className="govuk-link--no-underline"
target="_blank"
href={t(
"home:login-comingsoon-forms-link-3"
)}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-3")}
</a>
</li>
<li>
{" "}
<a
className="govuk-link--no-underline"
target="_blank"
href={t(
"home:login-comingsoon-forms-link-4"
)}
rel="noreferrer"
>
{t("home:login-comingsoon-forms-label-4")}
</a>
</li>
</ul>
<br />
<p className="govuk-body-s">
{t("home:login-comingsoon-email-link-label")}{" "}
<a
href={"mailto:" + t("home:login-contact-email")}
className="govuk-link--no-underline"
>
{t("home:login-contact-email")}
</a>
</p>
</div> */}
</div> </div>
</div>
{/* <LoadingOverlay {/* <LoadingOverlay
active={showSpinnerState} active={showSpinnerState}
spinner spinner
text="Logging in" text="Logging in"
/> */} /> */}
</div> </div>
</>
); );
}; };
+2 -1
View File
@@ -12,7 +12,7 @@ import Dns from "./homepage/dns";
const MainHome = (props) => { const MainHome = (props) => {
let { t } = useTranslation(); let { t } = useTranslation();
const { showLogin, GG_REDIRECT_URI, GG_CLIENT_ID } = props; const { showLogin, GG_REDIRECT_URI, GG_CLIENT_ID, session } = props;
const router = useRouter(); const router = useRouter();
const { locale } = router; const { locale } = router;
@@ -33,6 +33,7 @@ const MainHome = (props) => {
<Login <Login
GG_CLIENT_ID={GG_CLIENT_ID} GG_CLIENT_ID={GG_CLIENT_ID}
GG_REDIRECT_URI={GG_REDIRECT_URI} GG_REDIRECT_URI={GG_REDIRECT_URI}
session={session}
/> />
)} )}
{/* <Inspectorate /> */} {/* <Inspectorate /> */}
+4 -1
View File
@@ -19,6 +19,8 @@ import { useState } from "react";
import TimeOut from "../components/timeout"; import TimeOut from "../components/timeout";
import UploadFile from "./myportal/uploadFile"; import UploadFile from "./myportal/uploadFile";
import { useSession, signIn, signOut } from "next-auth/react";
const MyPortal = (props) => { const MyPortal = (props) => {
const { showFileUpload } = props; const { showFileUpload } = props;
let { t } = useTranslation(); let { t } = useTranslation();
@@ -57,7 +59,8 @@ const MyPortal = (props) => {
console.log("////////////", "\n", "logout", "\n", "//////////"); console.log("////////////", "\n", "logout", "\n", "//////////");
destroyCookie({}, "pinsUser"), destroyCookie({}, "pinsUser"),
window.localStorage.clear(), window.localStorage.clear(),
router.replace(props.ggLogout); //router.replace(props.ggLogout);
signOut({ callbackUrl: "/logout" });
}; };
return ( return (
+2 -2
View File
@@ -27,7 +27,7 @@ export default function YourAccount() {
</div> </div>
</div> </div>
<div className="card-body"> {/* <div className="card-body">
<h3 className="heading-small card-heading"> <h3 className="heading-small card-heading">
{t("myportal:changepassword-card-title")} {t("myportal:changepassword-card-title")}
</h3> </h3>
@@ -43,7 +43,7 @@ export default function YourAccount() {
</a> </a>
</Link> </Link>
</div> </div>
</div> </div> */}
</div> </div>
</> </>
); );
+2 -2
View File
@@ -4,6 +4,7 @@ import { destroyCookie } from "nookies";
import { useState } from "react"; import { useState } from "react";
import IdleTimer from "react-idle-timer"; import IdleTimer from "react-idle-timer";
import TimeoutModal from "./timeoutmodal"; import TimeoutModal from "./timeoutmodal";
import { useSession, signIn, signOut } from "next-auth/react";
const TimeOut = (props) => { const TimeOut = (props) => {
let { t } = useTranslation(); let { t } = useTranslation();
@@ -41,8 +42,7 @@ const TimeOut = (props) => {
console.log("////////////", "\n", "logout", "\n", "//////////"); console.log("////////////", "\n", "logout", "\n", "//////////");
destroyCookie({}, "pinsUser"), destroyCookie({}, "pinsUser"),
window.localStorage.clear(), window.localStorage.clear(),
//router.replace(props.ggLogout); signOut({ callbackUrl: "/logout" });
router.replace("/logout");
}; };
return ( return (
+10 -1
View File
@@ -10,6 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@googlemaps/react-wrapper": "^1.1.24", "@googlemaps/react-wrapper": "^1.1.24",
"@magic-sdk/admin": "^1.4.0",
"@next-auth/prisma-adapter": "^1.0.3",
"@prisma/client": "^3.12.0",
"@webdeb/next-styles": "^1.1.1", "@webdeb/next-styles": "^1.1.1",
"applicationinsights": "^2.2.0", "applicationinsights": "^2.2.0",
"axios": "^0.24.0", "axios": "^0.24.0",
@@ -31,14 +34,18 @@
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"leaflet-defaulticon-compatibility": "^0.1.1", "leaflet-defaulticon-compatibility": "^0.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"magic-sdk": "^8.1.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"multer": "^1.4.4", "multer": "^1.4.4",
"mysql": "^2.18.1",
"nanoid": "^3.2.0", "nanoid": "^3.2.0",
"next": "^12.0.8", "next": "^12.0.8",
"next-auth": "^4.3.1",
"next-connect": "^0.12.2", "next-connect": "^0.12.2",
"next-redux-wrapper": "^7.0.5", "next-redux-wrapper": "^7.0.5",
"next-translate": "^1.2.0", "next-translate": "^1.2.0",
"nextjs-basic-auth-middleware": "^0.2.1", "nextjs-basic-auth-middleware": "^0.2.1",
"nodemailer": "^6.7.3",
"nookies": "^2.5.2", "nookies": "^2.5.2",
"ospoint": "^0.2.1", "ospoint": "^0.2.1",
"path": "^0.12.7", "path": "^0.12.7",
@@ -61,6 +68,7 @@
"sass": "1.32", "sass": "1.32",
"sass-loader": "^12.4.0", "sass-loader": "^12.4.0",
"swr": "^1.1.2", "swr": "^1.1.2",
"typeorm": "^0.3.5",
"webpack": "^5.66.0", "webpack": "^5.66.0",
"xml2js": "^0.4.23", "xml2js": "^0.4.23",
"xmldom": "^0.6.0", "xmldom": "^0.6.0",
@@ -70,6 +78,7 @@
"devDependencies": { "devDependencies": {
"eslint": "^8.6.0", "eslint": "^8.6.0",
"eslint-config-next": "^12.0.8", "eslint-config-next": "^12.0.8",
"prettier": "2.5.1" "prettier": "2.5.1",
"prisma": "^3.12.0"
} }
} }
+12 -6
View File
@@ -5,19 +5,25 @@ import { wrapper } from "../store/store";
import { useStore, Provider } from "react-redux"; import { useStore, Provider } from "react-redux";
import { persistStore } from "redux-persist"; import { persistStore } from "redux-persist";
import { PersistGate } from "redux-persist/integration/react"; import { PersistGate } from "redux-persist/integration/react";
import { SessionProvider as AuthProvider } from "next-auth/react";
const MyApp = ({ Component, pageProps }) => { const MyApp = ({ Component, pageProps: { session, ...pageProps } }) => {
const store = useStore((state) => state); const store = useStore((state) => state);
const persistor = persistStore(store, {}, function () { const persistor = persistStore(store, {}, function () {
persistor.persist(); persistor.persist();
}); });
return ( return (
<Provider store={store}> <AuthProvider session={session}>
<PersistGate persistor={store.__persistor} loading={<Loading />}> <Provider store={store}>
<Component {...pageProps} /> <PersistGate
</PersistGate> persistor={store.__persistor}
</Provider> loading={<Loading />}
>
<Component {...pageProps} />
</PersistGate>
</Provider>
</AuthProvider>
); );
}; };
+129
View File
@@ -0,0 +1,129 @@
import NextAuth from "next-auth";
import EmailProvider from "next-auth/providers/email";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import nodemailer from "nodemailer";
const prisma = new PrismaClient();
// async function sendVerificationRequest({
// identifier: email,
// url,
// provider: { server, from },
// }) {
// const { host } = new URL(url);
// const transport = nodemailer.createTransport(server);
// await transport.sendMail({
// to: email,
// from,
// subject: `Sign in to ${host}`,
// text: text({ url, host }),
// html: html({ url, host, email }),
// });
// }
// Email HTML body
function html({ url, host, email }) {
// Insert invisible space into domains and email address to prevent both the
// email address and the domain from being turned into a hyperlink by email
// clients like Outlook and Apple mail, as this is confusing because it seems
// like they are supposed to click on their email address to sign in.
const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`;
const escapedHost = `${host.replace(/\./g, "&#8203;.")}`;
// Some simple styling options
const backgroundColor = "#ffffff";
const textColor = "#444444";
const mainBackgroundColor = "#ffffff";
const buttonBackgroundColor = "#aa1111";
const buttonBorderColor = "#aa1111";
const buttonTextColor = "#ffffff";
const headerBackgroundColor = "#000000";
return `
<body style="background: ${backgroundColor};">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: #fff;background-color: ${headerBackgroundColor};border-bottom: 10px solid #ffd530;>
<strong>PEDW - Planning Casework</strong>
</td>
</tr>
</table>
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Sign in as <strong>${escapedEmail}</strong>
</td>
<img src ="http://localhost:3000/assets/images/planning-casework-en.svg"/>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`;
}
// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
function text({ url, host }) {
return `Sign in to PEDW - Planning Casework\n${url}\n\n`;
}
// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
export default NextAuth({
// https://next-auth.js.org/configuration/providers
providers: [
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: process.env.EMAIL_SERVER_PORT,
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
maxAge: 10 * 60, // Magic links are valid for 10 min only
async sendVerificationRequest({
identifier: email,
url,
provider: { server, from },
}) {
const { host } = new URL(url);
const transport = nodemailer.createTransport(server);
await transport.sendMail({
to: email,
from,
subject: `Sign in to PEDW - Planning Casework`,
text: text({ url, host }),
html: html({ url, host, email }),
});
},
}),
],
adapter: PrismaAdapter(prisma),
session: {
jwt: true,
pages: {},
theme: "dark",
debug: true,
},
pages: {
signIn: "/auth/signin",
//signOut: "/auth/signout",
error: "/auth/error", // Error code passed in query string as ?error=
verifyRequest: "/auth/verify-request", // (used for check email message)
// newUser: "/auth/new-user", // New users will be directed here on first sign in (leave the property out if not of interest)
},
});
+80
View File
@@ -0,0 +1,80 @@
import useTranslation from "next-translate/useTranslation";
import Head from "next/head";
import Link from "next/link";
import CookieBanner from "../../components/cookieBanner";
import Footer from "../../components/footer";
import Header from "../../components/header";
import Breadcrumbs from "../../components/breadcrumbs";
import { useRouter } from "next/router";
import { getCsrfToken } from "next-auth/react";
const Error = (props) => {
let { t, lang } = useTranslation();
const { footerLinks, pages, csrfToken } = props;
const { error } = useRouter().query;
const errors = {
Signin: "Try signing with a different account.",
OAuthSignin: "Try signing with a different account.",
OAuthCallback: "Try signing with a different account.",
OAuthCreateAccount: "Try signing with a different account.",
EmailCreateAccount: "Try signing with a different account.",
Callback: "Try signing with a different account.",
OAuthAccountNotLinked:
"To confirm your identity, sign in with the same account you used originally.",
EmailSignin: "Check your email address.",
CredentialsSignin:
"Sign in failed. Check the details you provided are correct.",
Verification:
"Unable to sign in. The sign in link is no longer valid.It may have been used already or it may have expired. ",
default: "Unable to sign in.",
};
const errorMessage = error && (errors[error] ?? errors.default);
return (
<div>
<Head>
<title>Sign in Error - {t("common:service-name")}</title>
</Head>
<div id="page_wrapper">
<CookieBanner />
<Header courseName={t("common:service-name")} />
<div className="govuk-width-container govuk-!-padding-bottom-9">
<Breadcrumbs />
<main className="govuk-main-wrapper govuk-!-padding-top-0 govuk-!-padding-bottom-9">
<div className="govuk-grid-row ">
<div className="govuk-grid-column-two-thirds ">
<h1>There has been an error </h1>
</div>
</div>
<div className="govuk-grid-row">
<div className="govuk-grid-column-two-thirds govuk-body">
{errorMessage}
</div>
</div>
<div className="govuk-grid-row">
<div className="govuk-grid-column-two-thirds govuk-body">
{" "}
<Link href="/">Return to Home</Link>
</div>
</div>
</main>
</div>
<Footer footerLinks={footerLinks} />
</div>
</div>
);
};
export async function getServerSideProps(context) {
const csrfToken = await getCsrfToken(context);
return {
props: { csrfToken },
};
}
export default Error;
+82
View File
@@ -0,0 +1,82 @@
import useTranslation from "next-translate/useTranslation";
import Head from "next/head";
import Link from "next/link";
import CookieBanner from "../../components/cookieBanner";
import Footer from "../../components/footer";
import Header from "../../components/header";
import Breadcrumbs from "../../components/breadcrumbs";
import { getCsrfToken } from "next-auth/react";
const SignIn = (props) => {
let { t, lang } = useTranslation();
const { footerLinks, pages, csrfToken } = props;
return (
<div>
<Head>
<title>Sign in - {t("common:service-name")}</title>
</Head>
<div id="page_wrapper">
<CookieBanner />
<Header courseName={t("common:service-name")} />
<div className="govuk-width-container govuk-!-padding-bottom-9">
<Breadcrumbs />
<main className="govuk-main-wrapper govuk-!-padding-top-0 govuk-!-padding-bottom-9">
<div className="govuk-grid-row ">
<div className="govuk-grid-column-two-thirds ">
<h1>Sign in</h1>
</div>
</div>
<div className="govuk-grid-row">
<div className="govuk-grid-column-two-thirds ">
<form
method="post"
action="/api/auth/signin/email"
>
<div className="govuk-form-group">
<input
className="govuk-input"
name="csrfToken"
type="hidden"
defaultValue={csrfToken}
/>
<label
className="govuk-label"
htmlFor="email"
>
Email address{" "}
</label>
<input
className="govuk-input govuk-!-width-three-quarters"
type="email"
id="email"
name="email"
/>
</div>
<div className="govuk-form-group">
<button
type="submit"
className="govuk-button"
>
Sign in with your email
</button>
</div>
</form>
</div>
</div>
</main>
</div>
<Footer footerLinks={footerLinks} />
</div>
</div>
);
};
export async function getServerSideProps(context) {
const csrfToken = await getCsrfToken(context);
return {
props: { csrfToken },
};
}
export default SignIn;
+59
View File
@@ -0,0 +1,59 @@
import useTranslation from "next-translate/useTranslation";
import Head from "next/head";
import Link from "next/link";
import CookieBanner from "../../components/cookieBanner";
import Footer from "../../components/footer";
import Header from "../../components/header";
import Breadcrumbs from "../../components/breadcrumbs";
import { getCsrfToken } from "next-auth/react";
const SignIn = (props) => {
let { t, lang } = useTranslation();
const { footerLinks, pages, csrfToken } = props;
return (
<div>
<Head>
<title>Sign in - {t("common:service-name")}</title>
</Head>
<div id="page_wrapper">
<CookieBanner />
<Header courseName={t("common:service-name")} />
<div className="govuk-width-container govuk-!-padding-bottom-9">
<Breadcrumbs />
<main className="govuk-main-wrapper govuk-!-padding-top-0 govuk-!-padding-bottom-9">
<div className="govuk-grid-row ">
<div className="govuk-grid-column-two-thirds ">
<h1>Check your email</h1>
</div>
</div>
<div className="govuk-grid-row">
<div className="govuk-grid-column-two-thirds govuk-body">
A link has been sent to your email so you can
continue
</div>
</div>
<div className="govuk-grid-row">
<div className="govuk-grid-column-two-thirds govuk-body">
{" "}
<Link href="/">Return to Home</Link>
</div>
</div>
</main>
</div>
<Footer footerLinks={footerLinks} />
</div>
</div>
);
};
export async function getServerSideProps(context) {
const csrfToken = await getCsrfToken(context);
return {
props: { csrfToken },
};
}
export default SignIn;
+16 -25
View File
@@ -19,6 +19,7 @@ import ServiceBanner from "../components/servicebanner";
import { setAccountDetails } from "../store/accountDetails/action"; import { setAccountDetails } from "../store/accountDetails/action";
import { getGovGatewayConfig } from "../store/govgateway/action"; import { getGovGatewayConfig } from "../store/govgateway/action";
import { wrapper } from "../store/store"; import { wrapper } from "../store/store";
import { getSession, useSession, signIn, signOut } from "next-auth/react";
const Home = (props) => { const Home = (props) => {
const { const {
@@ -43,7 +44,7 @@ const Home = (props) => {
//console.log("hashed email str", loginEmailStr); //console.log("hashed email str", loginEmailStr);
_.isEmpty(loggedInUserId) != false && !hasGGCode _.isEmpty(loggedInUserId) != false && !hasGGCode
? console.log("no ggcode") ? console.log("no session")
: _.has(loggedInUserId, "value") : _.has(loggedInUserId, "value")
? _.isEmpty(loggedInUserId.value) ? _.isEmpty(loggedInUserId.value)
? router.replace({ ? router.replace({
@@ -159,16 +160,18 @@ export const getServerSideProps = wrapper.getServerSideProps(
(store) => async (ctx) => { (store) => async (ctx) => {
const { query, req, res } = ctx; const { query, req, res } = ctx;
let thisSession = await getSession(ctx);
var relayToken = await getToken(); var relayToken = await getToken();
relayToken = relayToken.access_token; relayToken = relayToken.access_token;
console.log("gg code:", query.code); console.log("auth sess:", thisSession);
console.log("gg local", query.ui_locales);
const showLoginCheck = process.env.SHOWLOGIN || false; const showLoginCheck = process.env.SHOWLOGIN || false;
const showMapCheck = process.env.SHOWMAPS || false; const showMapCheck = process.env.SHOWMAPS || false;
const hasLoginCode = typeof query.code != "undefined"; const hasLoginCode = thisSession != null;
let providerConfig = await getProviderConfig(); let providerConfig = await getProviderConfig();
let ggLocale = let ggLocale =
typeof query.ui_locales != "undefined" && query.ui_locales; typeof query.ui_locales != "undefined" && query.ui_locales;
@@ -192,27 +195,15 @@ export const getServerSideProps = wrapper.getServerSideProps(
let loginEmailStr = ""; let loginEmailStr = "";
if (hasLoginCode == true) { if (hasLoginCode == true) {
ggToken = await getGGToken( (loginEmailStr = hashString(thisSession.user.email)),
providerConfig.token_endpoint, (portalUserObj = await getPortalLogin(
query.code, thisSession.user.email,
process.env.GG_REDIRECT_URI relayToken
); )),
console.log("loggin user stuff: ", portalUserObj),
_.has(ggToken, "access_token") store.dispatch(setAccountDetails(portalUserObj));
? ((ggUserInfo = await getUserInfo( } else {
providerConfig.userinfo_endpoint, console.log("no session");
ggToken.access_token
)),
(loginEmailStr = hashString(ggUserInfo.data.email)),
(portalUserObj = await getPortalLogin(
ggUserInfo.data.email,
relayToken
)),
console.log("loggin user stuff: ", portalUserObj),
store.dispatch(setAccountDetails(portalUserObj)))
: console.log(
ggToken.status + " " + ggToken.data.error_description
);
} }
if (ggToken.status == 400) { if (ggToken.status == 400) {
+7 -1
View File
@@ -31,7 +31,7 @@ import {
} from "../../store/watchedCases/action"; } from "../../store/watchedCases/action";
const Home = (props) => { const Home = (props) => {
const { footerLinks, pages, watchedCases } = props; const { footerLinks, pages, watchedCases, showMap } = props;
let { t, lang } = useTranslation(); let { t, lang } = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -112,6 +112,7 @@ const Home = (props) => {
<div className="govuk-width-container"> <div className="govuk-width-container">
<Breadcrumbs slug={appealtypes} /> <Breadcrumbs slug={appealtypes} />
<DNSSearchResults <DNSSearchResults
showMap={showMap}
searchString="DNS" searchString="DNS"
searchResultsObj={props.searchResultsObj} searchResultsObj={props.searchResultsObj}
myportal={true} myportal={true}
@@ -152,6 +153,8 @@ export const getServerSideProps = wrapper.getServerSideProps(
} }
: searchResultsObj; : searchResultsObj;
const showMapCheck = process.env.SHOWMAPS || false;
const [searchDetailsObj, watchedCasesDetails] = await Promise.all([ const [searchDetailsObj, watchedCasesDetails] = await Promise.all([
await getSearchDetails(searchResultsObj), await getSearchDetails(searchResultsObj),
await getDetails(watchedCases, "myWatchedCases"), await getDetails(watchedCases, "myWatchedCases"),
@@ -163,6 +166,9 @@ export const getServerSideProps = wrapper.getServerSideProps(
store.dispatch(setDNSCoords(dnsCoords)); store.dispatch(setDNSCoords(dnsCoords));
store.dispatch(setWatchedCases(watchedCases)); store.dispatch(setWatchedCases(watchedCases));
store.dispatch(setWatchedCasesDetails(watchedCasesDetails)); store.dispatch(setWatchedCasesDetails(watchedCasesDetails));
return {
props: { showMap: showMapCheck },
};
} }
); );
+1
View File
@@ -63,6 +63,7 @@ const Home = (props) => {
appealTypeID={ appealTypeID={
props.currentView.caseReference.appealType props.currentView.caseReference.appealType
} }
showMap={props.currentView.caseReference.showMap}
/> />
</div> </div>
<Footer footerLinks={footerLinks} /> <Footer footerLinks={footerLinks} />
+8
View File
@@ -39,6 +39,8 @@ import {
} from "../../store/watchedCases/action"; } from "../../store/watchedCases/action";
import { setCookie } from "nookies"; import { setCookie } from "nookies";
import { getSession, useSession, signIn, signOut } from "next-auth/react";
const Home = (props) => { const Home = (props) => {
const { const {
footerLinks, footerLinks,
@@ -56,6 +58,8 @@ const Home = (props) => {
const { locale } = router; const { locale } = router;
const { appealtypes } = router.query; const { appealtypes } = router.query;
const { data: session } = useSession();
return ( return (
<div> <div>
<Head> <Head>
@@ -153,6 +157,10 @@ export const getServerSideProps = wrapper.getServerSideProps(
let loggedInUser = cookies.pinsUser; let loggedInUser = cookies.pinsUser;
let thisSession = await getSession(ctx);
console.log("nextAuth Session:", thisSession);
let providerConfig = await getProviderConfig(); let providerConfig = await getProviderConfig();
store.dispatch(getGovGatewayConfig(providerConfig)); store.dispatch(getGovGatewayConfig(providerConfig));
@@ -0,0 +1,57 @@
-- CreateTable
CREATE TABLE `accounts` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`provider_account_id` VARCHAR(191) NOT NULL,
`refresh_token` TEXT NULL,
`access_token` TEXT NULL,
`expires_at` INTEGER NULL,
`token_type` VARCHAR(191) NULL,
`scope` VARCHAR(191) NULL,
`id_token` TEXT NULL,
`session_state` VARCHAR(191) NULL,
UNIQUE INDEX `accounts_provider_provider_account_id_key`(`provider`, `provider_account_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sessions` (
`id` VARCHAR(191) NOT NULL,
`session_token` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `sessions_session_token_key`(`session_token`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `users` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`email_verified` DATETIME(3) NULL,
`image` VARCHAR(191) NULL,
UNIQUE INDEX `users_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `verificationtokens` (
`identifier` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `verificationtokens_token_key`(`token`),
UNIQUE INDEX `verificationtokens_identifier_token_key`(`identifier`, `token`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sessions` ADD CONSTRAINT `sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
+61
View File
@@ -0,0 +1,61 @@
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
accounts Account[]
sessions Session[]
@@map("users")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verificationtokens")
}
+3818 -2371
View File
File diff suppressed because it is too large Load Diff