wip(org): Work on signup flow
This commit is contained in:
parent
378df4b156
commit
fe568b6a16
16 changed files with 407 additions and 33 deletions
17
sites/backend/src/controllers/confirmations.mjs
Normal file
17
sites/backend/src/controllers/confirmations.mjs
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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`),
|
||||
},
|
||||
|
|
12
sites/backend/src/routes/confirmations.mjs
Normal file
12
sites/backend/src/routes/confirmations.mjs
Normal 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))
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
55
sites/org/components/gdpr/details.js
Normal file
55
sites/org/components/gdpr/details.js
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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?
|
9
sites/org/components/wrappers/welcome.js
Normal file
9
sites/org/components/wrappers/welcome.js
Normal 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
|
203
sites/org/pages/confirm/signup/[...confirmation].js
Normal file
203
sites/org/pages/confirm/signup/[...confirmation].js
Normal 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,
|
||||
}
|
||||
}
|
|
@ -123,7 +123,7 @@ export default SignUpPage
|
|||
export async function getStaticProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, namespaces)),
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -27,6 +27,9 @@
|
|||
p {
|
||||
@apply text-base-content my-1 py-2 text-base leading-6;
|
||||
}
|
||||
.btn {
|
||||
@apply capitalize;
|
||||
}
|
||||
|
||||
/* Dropdowns */
|
||||
.navdrop {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue