1
0
Fork 0

wip(org): Work on signup flow

This commit is contained in:
joostdecock 2023-01-14 22:40:07 +01:00
parent 378df4b156
commit fe568b6a16
16 changed files with 407 additions and 33 deletions

View file

@ -0,0 +1,17 @@
import { log } from '../utils/log.mjs'
import { ConfirmationModel } from '../models/confirmation.mjs'
export function ConfirmationsController() {}
/*
* Read confirmation
*
* This is the endpoint that handles reading confirmations
* See: https://freesewing.dev/reference/backend/api/confirmation
*/
ConfirmationsController.prototype.read = async (req, res, tools) => {
const Confirmation = new ConfirmationModel(tools)
await Confirmation.guardedRead(req)
return Confirmation.sendResponse(res)
}

View file

@ -6,7 +6,15 @@ export function ConfirmationModel(tools) {
this.prisma = tools.prisma
this.decrypt = tools.decrypt
this.encrypt = tools.encrypt
this.clear = {}
this.encryptedFields = ['data']
this.clear = {} // For holding decrypted data
return this
}
ConfirmationModel.prototype.unguardedRead = async function (where) {
this.record = await this.prisma.confirmation.findUnique({ where })
await this.reveal()
return this
}
@ -29,14 +37,16 @@ ConfirmationModel.prototype.setExists = function () {
return this
}
ConfirmationModel.prototype.setResponse = function (status = 200, error = false) {
ConfirmationModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
this.response = {
status,
body: {
result: 'success',
...data,
},
}
if (status > 201) {
if (status === 201) this.response.body.result = 'created'
else if (status > 204) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
@ -45,24 +55,26 @@ ConfirmationModel.prototype.setResponse = function (status = 200, error = false)
return this.setExists()
}
ConfirmationModel.prototype.setResponse = function (
status = 200,
result = 'success',
error = false
) {
this.response = {
status: this.status,
body: {
error: this.error,
result: this.result,
},
}
if (error) {
this.response.body.error = error
this.error = true
} else this.error = false
ConfirmationModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
return this.setExists()
ConfirmationModel.prototype.guardedRead = async function ({ params }) {
if (typeof params.id === 'undefined') return this.setResponse(404)
if (typeof params.check === 'undefined') return this.setResponse(404)
await this.unguardedRead({ id: params.id })
if (!this.record) return this.setResponse(404)
if (this.clear.data.check === params.check)
return this.setResponse(200, 'success', {
confirmation: {
id: this.record.id,
check: this.clear.data.check,
},
})
return this.setResponse(404)
}
ConfirmationModel.prototype.create = async function (data = {}) {
@ -78,3 +90,16 @@ ConfirmationModel.prototype.create = async function (data = {}) {
return this.setResponse(201)
}
/*
* Helper method to decrypt at-rest data
*/
ConfirmationModel.prototype.reveal = async function () {
this.clear = {}
if (this.record) {
for (const field of this.encryptedFields) {
this.clear[field] = this.decrypt(this.record[field])
}
}
return this
}

View file

@ -147,6 +147,7 @@ UserModel.prototype.guardedCreate = async function ({ body }) {
return this.setResponse(400, 'unsupportedLanguage')
const ehash = hash(clean(body.email))
const check = randomString()
await this.read({ ehash })
if (this.exists) {
/*
@ -176,6 +177,7 @@ UserModel.prototype.guardedCreate = async function ({ body }) {
email: this.clear.email,
id: this.record.id,
ehash: ehash,
check,
},
userId: this.record.id,
})
@ -189,7 +191,7 @@ UserModel.prototype.guardedCreate = async function ({ body }) {
actionUrl:
type === 'signup-aed'
? false // No actionUrl for disabled accounts
: i18nUrl(body.language, `/confirm/${type}/${this.Confirmation.record.id}`),
: i18nUrl(body.language, `/confirm/${type}/${this.Confirmation.record.id}/${check}`),
whyUrl: i18nUrl(body.language, `/docs/faq/email/why-${type}`),
supportUrl: i18nUrl(body.language, `/patrons/join`),
},
@ -247,9 +249,11 @@ UserModel.prototype.guardedCreate = async function ({ body }) {
email: this.clear.email,
id: this.record.id,
ehash: ehash,
check,
},
userId: this.record.id,
})
console.log(check)
// Send signup email
if (!this.isUnitTest(body) || this.config.tests.sendEmail)
@ -258,7 +262,10 @@ UserModel.prototype.guardedCreate = async function ({ body }) {
language: this.language,
to: this.clear.email,
replacements: {
actionUrl: i18nUrl(this.language, `/confirm/signup/${this.Confirmation.record.id}`),
actionUrl: i18nUrl(
this.language,
`/confirm/signup/${this.Confirmation.record.id}/${check}`
),
whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`),
supportUrl: i18nUrl(this.language, `/patrons/join`),
},

View file

@ -0,0 +1,12 @@
import { ConfirmationsController } from '../controllers/confirmations.mjs'
const Confirmations = new ConfirmationsController()
export function confirmationsRoutes(tools) {
/*
* Confirmations cannot be created through the API
* They are created internally, and the only endpoint it this one that
* lets you read a confirmation if you know it's ID and check value
*/
tools.app.get('/confirmations/:id/:check', (req, res) => Confirmations.read(req, res, tools))
}

View file

@ -2,10 +2,12 @@ import { apikeysRoutes } from './apikeys.mjs'
import { usersRoutes } from './users.mjs'
import { peopleRoutes } from './people.mjs'
import { patternsRoutes } from './patterns.mjs'
import { confirmationsRoutes } from './confirmations.mjs'
export const routes = {
apikeysRoutes,
usersRoutes,
peopleRoutes,
patternsRoutes,
confirmationsRoutes,
}

View file

@ -0,0 +1,55 @@
import { useTranslation } from 'next-i18next'
export const namespaces = ['gdpr']
const hClasses = 'text-left w-full'
export const GdprProfileDetails = () => {
const { t } = useTranslation(namespaces)
return (
<div className="border-l-4 ml-1 pl-4 my-2 opacity-80">
<h6>{t('profileWhatQuestion')}</h6>
<p
dangerouslySetInnerHTML={{
__html:
t('profileWhatAnswer') +
'<br /><em><small>' +
t('profileWhatAnswerOptional') +
'</small></em>',
}}
/>
<h6>{t('whyQuestion')}</h6>
<p dangerouslySetInnerHTML={{ __html: t('profileWhyAnswer') }} />
<h6>{t('timingQuestion')}</h6>
<p dangerouslySetInnerHTML={{ __html: t('profileTimingAnswer') }} />
<h6>{t('shareQuestion')}</h6>
<p dangerouslySetInnerHTML={{ __html: t('profileShareAnswer') }} />
</div>
)
}
export const GdprMeasurementsDetails = () => {
const { t } = useTranslation(namespaces)
return (
<div className="border-l-4 ml-1 pl-4 my-2 opacity-80">
<h6>{t('peopleWhatQuestion')}</h6>
<p
dangerouslySetInnerHTML={{
__html:
t('peopleWhatAnswer') +
'<br /><em><small>' +
t('peopleWhatAnswerOptional') +
'</small></em>',
}}
/>
<h6>{t('whyQuestion')}</h6>
<p dangerouslySetInnerHTML={{ __html: t('peopleWhyAnswer') }} />
<h6>{t('timingQuestion')}</h6>
<p dangerouslySetInnerHTML={{ __html: t('profileTimingAnswer') }} />
<h6>{t('shareQuestion')}</h6>
<p dangerouslySetInnerHTML={{ __html: t('profileShareAnswer') }} />
<p dangerouslySetInnerHTML={{ __html: t('openData') }} />
</div>
)
}

View file

@ -1,23 +1,29 @@
compliant: Freesewing.org respects your privacy and your rights. We apply the General Data Protection Regulation (GDPR) of the European Union (EU).
clickHere: Click here to give your consent
createLimitedAccount: Create account with limited consent
createAccount: Create account
compliant: "FreeSewing respects your privacy and your rights. We adhere to the toughest privacy and security law in the world: the General Data Protection Regulation (GDPR) of the European Union (EU)."
consent: Consent
consentForModelData: Consent for model data
consentForPeopleData: Consent for people data
consentForProfileData: Consent for profile data
consentGiven: Consent given
consentNotGiven: Consent not given
consentWhyAnswer: Under the GDPR, processing of your personal data requires your consent — in other words, your permission.
consentWhyAnswer: Under the GDPR, processing of your personal data requires granular consent — in other words, we need your permission for the various ways we handle your data.
createMyAccount: Create my account
furtherReading: Further reading
modelQuestion: Do you give your consent to process your model data?
modelWarning: Revoking this consent will lock you out of all your model data, as well as disable functionality that depends on it.
modelWhatAnswer: For each model their <b>measurements</b> and <b>breasts settings</b>.
modelWhatAnswerOptional: 'Optional: A model <b>picture</b> and the <b>name</b> that you give your model.'
modelWhatQuestion: What is model data?
modelWhyAnswer: 'To draft <b>made-to-measure sewing patterns</b>, we need <b>body measurements</b>.'
hideDetails: Hide details
noConsentNoAccount: Without this consent, we cannot create your account
noConsentNoPatterns: Without this consent, you cannot create any patterns
noConsentNoPatterns: Without this consent, you cannot create any made-to-measure patterns
noIDoNot: 'No, I do not'
openDataInfo: This data is used to study and understand the human form in all its shapes, so we can get better sewing patterns, and better fitting garments. Even though this data is anonymized, you have the right to object to this.
openDataQuestion: Share anonymized measurements as open data
peopleQuestion: Do you give your consent to process your people data?
peopleWarning: Revoking this consent will lock you out of all your people data, as well as disable functionality that depends on it.
peopleWhatAnswer: For each person their <b>measurements</b>.
peopleWhatAnswerOptional: 'Optional: A <b>picture</b> and the <b>name</b> that you give a person.'
peopleWhatQuestion: What is people data?
peopleWhyAnswer: 'To draft <b>made-to-measure sewing patterns</b>, we need <b>body measurements</b>.'
privacyMatters: Privacy matters
privacyNotice: FreeSewing Privacy Notice
profileQuestion: Do you give your consent to process your profile data?
profileShareAnswer: '<b>No</b>, never.'
profileTimingAnswer: '<b>12 months</b> after your last login, or until you <b>remove</b> your account or <b>revoke</b> this consent.'
@ -30,6 +36,7 @@ readMore: For more information, please read our privacy notice.
readRights: For more information, please read up on your rights.
revokeConsent: Revoke consent
shareQuestion: Do we share it with others?
showDetails: Show details
timingQuestion: How long do we keep it?
whatYouNeedToKnow: What you need to know
whyQuestion: Why do we need it?

View file

@ -0,0 +1,9 @@
const WelcomeWrapper = ({ theme, children }) => (
<section className="m-0 p-0 w-full">
<div className="flex flex-col items-center justify-start h-screen mt-4 lg:mt-32 max-w-lg m-auto">
<div className="w-full text-left">{children}</div>
</div>
</section>
)
export default WelcomeWrapper

View file

@ -0,0 +1,203 @@
import { useEffect } from 'react'
import Page from 'site/components/wrappers/page.js'
import useApp from 'site/hooks/useApp.js'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { useTranslation } from 'next-i18next'
import Layout from 'site/components/layouts/bare'
import Link from 'next/link'
import { useState } from 'react'
import { validateEmail, validateTld } from 'shared/utils.mjs'
import WelcomeWrapper from 'site/components/wrappers/welcome.js'
import { loadConfirmation, confirmSignup } from 'shared/backend.mjs'
import Spinner from 'shared/components/icons/spinner.js'
import { useRouter } from 'next/router'
import Popout from 'shared/components/popout.js'
import {
GdprProfileDetails,
GdprMeasurementsDetails,
namespaces as gdprNamespaces,
} from 'site/components/gdpr/details.js'
// Translation namespaces used on this page
const namespaces = [...gdprNamespaces]
const Checkbox = ({ value, name, setter, label, children = null }) => (
<div
className={`form-control p-4 hover:cursor-pointer rounded border-l-8 my-2
${value ? 'border-success bg-success' : 'border-error bg-error'}
bg-opacity-10 shadow`}
onClick={() => setter(value ? false : true)}
>
<div className="form-control flex flex-row items-center gap-2">
<input
type="checkbox"
className="checkbox"
checked={value ? 'checked' : ''}
onChange={() => setter(value ? false : true)}
/>
<span className="label-text">{label}</span>
</div>
{children}
</div>
)
const ConfirmSignUpPage = (props) => {
const app = useApp(props)
const { t } = useTranslation(namespaces)
const router = useRouter()
const loadingClasses = app.loading ? 'opacity-50' : ''
const [id, setId] = useState(false)
const [pDetails, setPDetails] = useState(false)
const [mDetails, setMDetails] = useState(false)
const [profile, setProfile] = useState(false)
const [measurements, setMeasurements] = useState(false)
const [openData, setOpenData] = useState(true)
const [ready, setReady] = useState(false)
const createAccount = async () => {
let consent = 0
if (profile) consent = 1
if (profile && measurements) consent = 2
if (profile && measurements && openData) consent = 3
if (consent > 0 && id) {
const data = await confirmSignup({ consent, id, ...app.loadHelpers })
console.log(data)
}
}
useEffect(() => {
// Async inside useEffect requires this approach
const getConfirmation = async () => {
// Get confirmation ID and check from url
const [confirmationId, confirmationCheck] = router.asPath.slice(1).split('/').slice(2)
// Reach out to backend
const data = await loadConfirmation({
id: confirmationId,
check: confirmationCheck,
...app.loadHelpers,
})
setReady(true)
setId(confirmationId)
}
// Call async method
getConfirmation()
}, [])
const partA = (
<>
<h5 className="mt-8">{t('profileQuestion')}</h5>
{pDetails ? <GdprProfileDetails /> : null}
{profile ? (
<Checkbox value={profile} setter={setProfile} label={t('yesIDo')} />
) : (
<>
<button
className="btn btn-primary btn-lg w-full mt-4 flex flex-row items-center justify-between"
onClick={() => setProfile(!profile)}
>
{t('clickHere')}
<span>1/2</span>
</button>
<p className="text-center">
<button
className="btn btn-neutral btn-ghost btn-sm"
onClick={() => setPDetails(!pDetails)}
>
{t(pDetails ? 'hideDetails' : 'showDetails')}
</button>
</p>
<Popout warning>{t('noConsentNoAccount')}</Popout>
</>
)}
</>
)
const partB = (
<>
<h5 className="mt-8">{t('peopleQuestion')}</h5>
{mDetails ? <GdprMeasurementsDetails /> : null}
{measurements ? (
<Checkbox value={measurements} setter={setMeasurements} label={t('yesIDo')} />
) : (
<button
className="btn btn-primary btn-lg w-full mt-4 flex flex-row items-center justify-between"
onClick={() => setMeasurements(!measurements)}
>
{t('clickHere')}
<span>2/2</span>
</button>
)}
{mDetails && measurements ? (
<Checkbox value={openData} setter={setOpenData} label={t('openDataQuestion')} />
) : null}
{measurements && !openData ? <Popout note>{t('openDataInfo')}</Popout> : null}
{!measurements && (
<>
<p className="text-center">
<button
className="btn btn-neutral btn-ghost btn-sm"
onClick={() => setMDetails(!mDetails)}
>
{t(mDetails ? 'hideDetails' : 'showDetails')}
</button>
</p>
<Popout warning>{t('noConsentNoPatterns')}</Popout>
</>
)}
</>
)
return (
<Page app={app} title={t('joinFreeSewing')} layout={Layout} footer={false}>
<WelcomeWrapper theme={app.theme}>
{ready ? (
<>
<h1>{t('privacyMatters')}</h1>
<p>{t('compliant')}</p>
<p>{t('consentWhyAnswer')}</p>
{partA}
{profile && partB}
</>
) : (
<Spinner className="w-8 h-8 m-auto" />
)}
{profile && !measurements && (
<button
className="btn btn-primary btn-outline btn-lg w-full mt-4"
onClick={createAccount}
>
{t('createLimitedAccount')}
</button>
)}
{profile && measurements && (
<button className="btn btn-primary btn-lg w-full mt-8" onClick={createAccount}>
{t('createAccount')}
</button>
)}
<p className="text-center opacity-50 mt-12">
<Link href="/docs/various/privacy" className="hover:text-secondary underline">
FreeSewing Privacy Notice
</Link>
</p>
</WelcomeWrapper>
</Page>
)
}
export default ConfirmSignUpPage
export async function getStaticProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale)),
},
}
}
export async function getStaticPaths() {
return {
paths: [],
fallback: true,
}
}

View file

@ -123,7 +123,7 @@ export default SignUpPage
export async function getStaticProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, namespaces)),
...(await serverSideTranslations(locale)),
},
}
}

View file

@ -25,3 +25,37 @@ export const signUp = async ({ email, language, startLoading, stopLoading }) =>
if (result && result.status === 201 && result.data) return result.data
return null
}
/*
* Load confirmation
*/
export const loadConfirmation = async ({ id, check, startLoading, stopLoading }) => {
let result
try {
startLoading()
result = await backend.get(`/confirmations/${id}/${check}`)
} catch (err) {
return err
} finally {
stopLoading()
}
if (result && result.status === 201 && result.data) return result.data
return null
}
/*
* Confirm signup
*/
export const confirmSignup = async ({ id, consent, startLoading, stopLoading }) => {
let result
try {
startLoading()
result = await backend.post(`/confirm/signup/${id}`, { consent })
} catch (err) {
return err
} finally {
stopLoading()
}
if (result && result.status === 200 && result.data) return result.data
return null
}

View file

@ -27,6 +27,9 @@
p {
@apply text-base-content my-1 py-2 text-base leading-6;
}
.btn {
@apply capitalize;
}
/* Dropdowns */
.navdrop {