feat(org): Further work on account pages
This commit is contained in:
parent
c0571d54f0
commit
79434846b6
18 changed files with 822 additions and 80 deletions
|
@ -320,10 +320,12 @@ org:
|
|||
'@mdx-js/runtime': *mdxRuntime
|
||||
'@tailwindcss/typography': *tailwindTypography
|
||||
'algoliasearch': *algoliasearch
|
||||
'react-copy-to-clipboard': 5.1.0
|
||||
'daisyui': *daisyui
|
||||
'lodash.get': *_get
|
||||
'lodash.orderby': *_orderby
|
||||
'lodash.set': *_set
|
||||
'luxon': '3.2.1'
|
||||
'next': *next
|
||||
'react-dropzone': '14.2.3'
|
||||
'react-hotkeys-hook': *reactHotkeysHook
|
||||
|
|
|
@ -519,7 +519,10 @@ UserModel.prototype.guardedMfaUpdate = async function ({ body, user, ip }) {
|
|||
log.warn(err, 'Could not disable MFA after token check')
|
||||
return this.setResponse(500, 'mfaDeactivationFailed')
|
||||
}
|
||||
return this.setResponse(200, false, {})
|
||||
return this.setResponse(200, false, {
|
||||
result: 'success',
|
||||
account: this.asAccount(),
|
||||
})
|
||||
} else {
|
||||
console.log('token check failed')
|
||||
return this.setResponse(401, 'authenticationFailed')
|
||||
|
@ -537,7 +540,10 @@ UserModel.prototype.guardedMfaUpdate = async function ({ body, user, ip }) {
|
|||
log.warn(err, 'Could not enable MFA after token check')
|
||||
return this.setResponse(500, 'mfaActivationFailed')
|
||||
}
|
||||
return this.setResponse(200, false, {})
|
||||
return this.setResponse(200, false, {
|
||||
result: 'success',
|
||||
account: this.asAccount(),
|
||||
})
|
||||
} else return this.setResponse(403, 'mfaTokenInvalid')
|
||||
}
|
||||
// Enroll
|
||||
|
|
|
@ -21,7 +21,10 @@ export const mfa = ({ service }) => ({
|
|||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
svg = svg.replace(dark, 'currentColor').replace(light, 'none')
|
||||
svg = svg
|
||||
.replace(dark, 'currentColor')
|
||||
.replace(light, 'none')
|
||||
.replace('<svg ', '<svg class="qrcode" width="100%" height="100%" ')
|
||||
|
||||
return { secret, otpauth, qrcode: svg }
|
||||
},
|
||||
|
|
|
@ -6,6 +6,8 @@ links: Links
|
|||
info: Info
|
||||
settings: Settings
|
||||
actions: Actions
|
||||
created: Created
|
||||
expires: Expires
|
||||
|
||||
yourProfile: Your profile
|
||||
yourPatterns: Your patterns
|
||||
|
@ -24,19 +26,28 @@ imperial: Units
|
|||
apikeys: API Keys
|
||||
newsletter: Newsletter subscription
|
||||
password: Password
|
||||
passwordPlaceholder: Enter your new password here
|
||||
newPasswordPlaceholder: Enter your new password here
|
||||
passwordPlaceholder: Enter your password here
|
||||
mfa: Two-Factor authentication
|
||||
mfaTipTitle: Please consider enabling Two-Factor Authentication
|
||||
mfaTipMsg: We do not enforce a password policy, but we do recommend you enable Two-Factor Authentication to keep your FreeSewing account safe.
|
||||
mfaEnabled: Two-Factor Authentication is enabled
|
||||
mfaDisabled: Two-Factor Authentication is disabled
|
||||
mfaSetup: Set up Two-Factor Authentication
|
||||
mfaAdd: Add FreeSewing to your Authenticator App by scanning the QR code above.
|
||||
confirmWithPassword: Please enter your password to confirm this action
|
||||
confirmWithMfa: Please enter a code from your Authenticator App to confirm this action
|
||||
enableMfa: Enable Two-Factor Authentication
|
||||
disableMfa: Disable Two-Factor Authentication
|
||||
language: Language
|
||||
developer: Developer
|
||||
|
||||
reloadAccount: Reload account
|
||||
exportData: Export your data
|
||||
reviewContent: Review your consent
|
||||
restrictProcessing: Restrict processing of your data
|
||||
disableAccount: Disable your account
|
||||
removeAccount: Remove your account
|
||||
reload: Reload account
|
||||
export: Export your data
|
||||
review: Review your consent
|
||||
restrict: Restrict processing of your data
|
||||
disable: Disable your account
|
||||
remove: Remove your account
|
||||
|
||||
mdSupport: You can use markdown here
|
||||
or: or
|
||||
|
@ -44,6 +55,11 @@ continue: Continue
|
|||
save: Save
|
||||
noThanks: No thanks
|
||||
|
||||
# reload
|
||||
nailedIt: Nailed it
|
||||
reloadMsg1: The data stored in your browser can sometimes get out of sync with the data stored in our backend.
|
||||
reloadMsg2: This lets you reload your account data from the backend. It has the same effect as loggin out, and then logging in again
|
||||
|
||||
# bio
|
||||
bioTitle: Tell people a little bit about yourself
|
||||
bioPreview: Bio Preview
|
||||
|
@ -114,3 +130,22 @@ languageTitle: Which language do you prefer?
|
|||
|
||||
# password
|
||||
passwordTitle: Something only you know
|
||||
|
||||
# api key
|
||||
newApikey: Generate a new API key
|
||||
keyName: Key name
|
||||
keyNameDesc: A unique name for this API key. Only visible to you.
|
||||
keyExpires: Key expiration
|
||||
keyExpiresDesc: "The key will expire on:"
|
||||
keyLevel: Key permission level
|
||||
keyLevel0: Authenticate only
|
||||
keyLevel1: Read access to your own patterns and measurement sets
|
||||
keyLevel2: Read access to all your account data
|
||||
keyLevel3: Write access to your own patterns and measurement sets
|
||||
keyLevel4: Write access to all your account data
|
||||
keyLevel5: Read access to patterns and measurement sets of other users
|
||||
keyLevel6: Write access to patterns and measurement sets of other users
|
||||
keyLevel7: Write access to all account data of other users
|
||||
keyLevel8: Impersonate other users, full write access to all data
|
||||
cancel: Cancel
|
||||
keySecretWarning: This is the only time you can see the key secret, make sure to copy it.
|
||||
|
|
262
sites/org/components/account/apikeys.mjs
Normal file
262
sites/org/components/account/apikeys.mjs
Normal file
|
@ -0,0 +1,262 @@
|
|||
// Hooks
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import { useBackend } from 'site/hooks/useBackend.mjs'
|
||||
import { useToast } from 'site/hooks/useToast.mjs'
|
||||
// Dependencies
|
||||
import { DateTime } from 'luxon'
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard'
|
||||
// Components
|
||||
import { BackToAccountButton, Choice } from './shared.mjs'
|
||||
import { Popout } from 'shared/components/popout.mjs'
|
||||
import { WebLink } from 'shared/components/web-link.mjs'
|
||||
import { Bullet } from 'site/components/bullet.mjs'
|
||||
import { CopyIcon } from 'shared/components/icons.mjs'
|
||||
|
||||
export const ns = ['account', 'toast']
|
||||
|
||||
const ExpiryPicker = ({ t, expires, setExpires }) => {
|
||||
const [days, setDays] = useState(true) // False = months
|
||||
const [val, setVal] = useState(3)
|
||||
|
||||
// Run update when component mounts
|
||||
useEffect(() => update(val), [])
|
||||
|
||||
const update = (evt) => {
|
||||
const value = typeof evt === 'number' ? evt : evt.target.value
|
||||
setVal(value)
|
||||
const plus = {}
|
||||
if (days) plus.days = value
|
||||
else plus.months = value
|
||||
setExpires(DateTime.now().plus(plus))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={days ? 30 : 24}
|
||||
value={val}
|
||||
className="range range-secondary"
|
||||
onChange={update}
|
||||
/>
|
||||
<div className="btn-group btn-group-vertical">
|
||||
<button
|
||||
className={`btn btn-secondary btn-sm ${days ? '' : 'btn-outline'}`}
|
||||
onClick={() => setDays(true)}
|
||||
>
|
||||
{days ? (
|
||||
<span>
|
||||
{val} {t('days')}
|
||||
</span>
|
||||
) : (
|
||||
t('days')
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-secondary btn-sm ${days ? 'btn-outline' : ''}`}
|
||||
onClick={() => {
|
||||
setDays(false)
|
||||
setVal(1)
|
||||
}}
|
||||
>
|
||||
{!days ? (
|
||||
<span>
|
||||
{val} {t('months')}
|
||||
</span>
|
||||
) : (
|
||||
t('months')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Popout note compact>
|
||||
{t('keyExpiresDesc')}
|
||||
<b> {expires.toHTTP()}</b>
|
||||
</Popout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CopyInput = ({ text }) => {
|
||||
const { t } = useTranslation(['toast'])
|
||||
const toast = useToast()
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const showCopied = () => {
|
||||
setCopied(true)
|
||||
toast.success(<span>{t('copiedToClipboard')}</span>)
|
||||
window.setTimeout(() => setCopied(false), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flez-row gap-2 items-center w-full">
|
||||
<input value={text} className="input w-full input-bordered flex flex-row" type="text" />
|
||||
<CopyToClipboard text={text} onCopy={showCopied}>
|
||||
<button className={`btn ${copied ? 'btn-success' : 'btn-secondary'}`}>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = ({ title, children }) => (
|
||||
<div className="flex flex-row flex-wrap items-center lg:gap-4 my-2">
|
||||
<div className="w-24 text-left md:text-right block md:inline font-bold">{title}</div>
|
||||
<div className="grow">{children}</div>
|
||||
</div>
|
||||
)
|
||||
const ShowKey = ({ apikey, t, clear }) => (
|
||||
<div>
|
||||
<Row title={t('keyName')}>{apikey.name}</Row>
|
||||
<Row title={t('created')}>{DateTime.fromISO(apikey.createdAt).toHTTP()}</Row>
|
||||
<Row title={t('expires')}>{DateTime.fromISO(apikey.expiresAt).toHTTP()}</Row>
|
||||
<Row title="Key ID">
|
||||
<CopyInput text={apikey.key} />
|
||||
</Row>
|
||||
<Row title="Key Secret">
|
||||
<CopyInput text={apikey.secret} />
|
||||
</Row>
|
||||
<Popout warning compact>
|
||||
{t('keySecretWarning')}
|
||||
</Popout>
|
||||
<button className="btn btn-primary capitalize mt-4i w-full" onClick={clear}>
|
||||
{t('apikeys')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NewKey = ({ app, t, setGenerate, backend, toast }) => {
|
||||
const [name, setName] = useState('')
|
||||
const [level, setLevel] = useState(1)
|
||||
const [expires, setExpires] = useState(DateTime.now())
|
||||
const [apikey, setApikey] = useState(false)
|
||||
|
||||
const levels = app.account.role === 'admin' ? [0, 1, 2, 3, 4, 5, 6, 7, 8] : [0, 1, 2, 3, 4]
|
||||
|
||||
const createKey = async () => {
|
||||
app.startLoading()
|
||||
const result = await backend.createApikey({
|
||||
name,
|
||||
level,
|
||||
expiresIn: Math.floor((expires.valueOf() - DateTime.now().valueOf()) / 1000),
|
||||
})
|
||||
console.log({ result })
|
||||
if (result.key) {
|
||||
toast.success(<span>{t('nailedIt')}</span>)
|
||||
setApikey(result)
|
||||
} else toast.for.backendError()
|
||||
app.stopLoading()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setApikey(false)
|
||||
setGenerate(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{t('newApikey')}</h2>
|
||||
{apikey ? (
|
||||
<>
|
||||
<ShowKey apikey={apikey} t={t} clear={clear} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>{t('keyName')}</h3>
|
||||
<p>{t('keyNameDesc')}</p>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(evt) => setName(evt.target.value)}
|
||||
className="input w-full input-bordered flex flex-row"
|
||||
type="text"
|
||||
placeholder={'Alicia key'}
|
||||
/>
|
||||
<h3>{t('keyExpires')}</h3>
|
||||
<ExpiryPicker {...{ t, expires, setExpires }} />
|
||||
<h3>{t('keyLevel')}</h3>
|
||||
{levels.map((l) => (
|
||||
<Choice val={l} t={t} update={setLevel} current={level} key={l}>
|
||||
<span className="block text-lg leading-5">{t(`keyLevel${l}`)}</span>
|
||||
</Choice>
|
||||
))}
|
||||
<div className="flex flex-row gap-2 items-center w-full my-8">
|
||||
<button
|
||||
className="btn btn-primary grow capitalize"
|
||||
disabled={name.length < 1}
|
||||
onClick={createKey}
|
||||
>
|
||||
{t('newApikey')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-outline capitalize"
|
||||
onClick={() => setGenerate(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Apikeys = ({ app, title = false, welcome = false }) => {
|
||||
const backend = useBackend(app)
|
||||
const { t } = useTranslation(ns)
|
||||
const toast = useToast()
|
||||
|
||||
const [keys, setKeys] = useState([])
|
||||
const [generate, setGenerate] = useState(false)
|
||||
|
||||
//useEffect(() => {
|
||||
// const getApikeys = () => {
|
||||
// const allKeys = await backend.getApikeys()
|
||||
// if (allKeys) setKeys(allKeys)
|
||||
// }
|
||||
// getApiKeys()
|
||||
//}, [ ])
|
||||
|
||||
const save = async () => {
|
||||
app.startLoading()
|
||||
const result = await backend.updateAccount({ bio })
|
||||
if (result === true) toast.for.settingsSaved()
|
||||
else toast.for.backendError()
|
||||
app.stopLoading()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{generate ? (
|
||||
<NewKey {...{ app, t, setGenerate, backend, toast }} />
|
||||
) : (
|
||||
<>
|
||||
<h2>{t('apikeys')}</h2>
|
||||
<button className="btn btn-primary w-full capitalize" onClick={() => setGenerate(true)}>
|
||||
{t('newApikey')}
|
||||
</button>
|
||||
<BackToAccountButton loading={app.loading} />
|
||||
<Popout tip>
|
||||
<h5>Refer to FreeSewing.dev for details (English only)</h5>
|
||||
<p>
|
||||
This is an advanced feature aimed at developers or anyone who wants to interact with
|
||||
our backend directly. For details, please refer to{' '}
|
||||
<WebLink
|
||||
href="https://freesewing.dev/reference/backend/api/apikeys"
|
||||
txt="the API keys reference documentation"
|
||||
/>{' '}
|
||||
on <WebLink href="https://freesewing.dev/" txt="FreeSewing.dev" /> our site for
|
||||
developers and contributors.
|
||||
</p>
|
||||
</Popout>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -30,12 +30,11 @@ const LinkList = ({ items, t, control, first = false }) => {
|
|||
}
|
||||
|
||||
const actions = {
|
||||
reloadAccount: 4,
|
||||
exportData: 3,
|
||||
reviewContent: 4,
|
||||
restrictProcessing: 4,
|
||||
disableAccount: 4,
|
||||
removeAccount: 2,
|
||||
reload: 4,
|
||||
export: 3,
|
||||
restrict: 4,
|
||||
disable: 4,
|
||||
remove: 2,
|
||||
}
|
||||
|
||||
export const AccountLinks = ({ account }) => {
|
||||
|
|
151
sites/org/components/account/mfa.mjs
Normal file
151
sites/org/components/account/mfa.mjs
Normal file
|
@ -0,0 +1,151 @@
|
|||
// Hooks
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import { useBackend } from 'site/hooks/useBackend.mjs'
|
||||
import { useToast } from 'site/hooks/useToast.mjs'
|
||||
// Components
|
||||
import Link from 'next/link'
|
||||
import { BackToAccountButton, updateAccount } from './shared.mjs'
|
||||
import { SaveSettingsButton } from 'site/components/buttons/save-settings-button.mjs'
|
||||
import { Popout } from 'shared/components/popout.mjs'
|
||||
import { RightIcon } from 'shared/components/icons.mjs'
|
||||
import { Bullet } from 'site/components/bullet.mjs'
|
||||
|
||||
export const ns = ['account']
|
||||
|
||||
const CodeInput = ({ code, setCode, t }) => (
|
||||
<input
|
||||
value={code}
|
||||
onChange={(evt) => setCode(evt.target.value)}
|
||||
className="input w-full text-4xl input-bordered input-lg flex flex-row text-center mb-8 tracking-widest"
|
||||
type="number"
|
||||
placeholder={t('000000')}
|
||||
/>
|
||||
)
|
||||
|
||||
export const MfaSettings = ({ app, title = false, welcome = false }) => {
|
||||
const backend = useBackend(app)
|
||||
const { t } = useTranslation(ns)
|
||||
const toast = useToast()
|
||||
|
||||
const [mfa, setMfa] = useState(app.account.mfaEnabled)
|
||||
const [enable, setEnable] = useState(false)
|
||||
const [disable, setDisable] = useState(false)
|
||||
const [code, setCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const enableMfa = async () => {
|
||||
app.startLoading()
|
||||
const result = await backend.enableMfa()
|
||||
if (result) setEnable(result)
|
||||
app.stopLoading()
|
||||
}
|
||||
|
||||
const disableMfa = async () => {
|
||||
app.startLoading()
|
||||
const result = await backend.disableMfa({
|
||||
mfa: false,
|
||||
password,
|
||||
token: code,
|
||||
})
|
||||
if (result) {
|
||||
if (result === true) toast.warning(<span>{t('mfaDisabled')}</span>)
|
||||
else toast.for.backendError()
|
||||
setDisable(false)
|
||||
setEnable(false)
|
||||
setCode('')
|
||||
setPassword('')
|
||||
}
|
||||
app.stopLoading()
|
||||
}
|
||||
|
||||
const confirmMfa = async () => {
|
||||
app.startLoading()
|
||||
const result = await backend.confirmMfa({
|
||||
mfa: true,
|
||||
secret: enable.secret,
|
||||
token: code,
|
||||
})
|
||||
if (result === true) toast.success(<span>{t('mfaEnabled')}</span>)
|
||||
else toast.for.backendError()
|
||||
setEnable(false)
|
||||
setCode('')
|
||||
app.stopLoading()
|
||||
}
|
||||
|
||||
let titleText = app.account.mfaEnabled ? t('mfaEnabled') : t('mfaDisabled')
|
||||
if (enable) titleText = t('mfaSetup')
|
||||
|
||||
return (
|
||||
<>
|
||||
{title ? <h2 className="text-4xl">{titleText}</h2> : null}
|
||||
{enable ? (
|
||||
<>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: enable.qrcode }}
|
||||
classname="w-full h-auto m-auto grow"
|
||||
/>
|
||||
</div>
|
||||
<Bullet num="1">{t('mfaAdd')}</Bullet>
|
||||
<Bullet num="2">{t('confirmWithMfa')}</Bullet>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(evt) => setCode(evt.target.value)}
|
||||
className="input w-64 m-auto text-4xl input-bordered input-lg flex flex-row text-center mb-8 tracking-widest"
|
||||
type="number"
|
||||
placeholder={t('000000')}
|
||||
/>
|
||||
<button className="btn btn-success btn-lg block w-full" onClick={confirmMfa}>
|
||||
{t('enableMfa')}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{disable ? (
|
||||
<div className="my-8">
|
||||
<Bullet num="1">
|
||||
<h5>{t('confirmWithPassword')}</h5>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(evt) => setPassword(evt.target.value)}
|
||||
className="input w-full input-bordered flex flex-row"
|
||||
type="text"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
/>
|
||||
</Bullet>
|
||||
<Bullet num="2">
|
||||
<h5>{t('confirmWithMfa')}</h5>
|
||||
<CodeInput code={code} setCode={setCode} t={t} />
|
||||
</Bullet>
|
||||
<button
|
||||
className="btn btn-error btn-lg block w-full"
|
||||
onClick={disableMfa}
|
||||
disabled={code.length < 4 || password.length < 3}
|
||||
>
|
||||
{t('disableMfa')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-row items-center mt-4">
|
||||
{app.account.mfaEnabled ? (
|
||||
disable ? null : (
|
||||
<button className="btn btn-primary block w-full" onClick={() => setDisable(true)}>
|
||||
{t('disableMfa')}
|
||||
</button>
|
||||
)
|
||||
) : enable ? null : (
|
||||
<div>
|
||||
<button className="btn btn-primary block w-full" onClick={enableMfa}>
|
||||
{t('mfaSetup')}
|
||||
</button>
|
||||
<Popout tip>
|
||||
<h5>{t('mfaTipTitle')}</h5>
|
||||
<p>{t('mfaTipMsg')}</p>
|
||||
</Popout>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!welcome && <BackToAccountButton loading={app.loading} />}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -10,7 +10,7 @@ import { SaveSettingsButton } from 'site/components/buttons/save-settings-button
|
|||
import { Popout } from 'shared/components/popout.mjs'
|
||||
import { RightIcon } from 'shared/components/icons.mjs'
|
||||
|
||||
export const ns = ['account']
|
||||
export const ns = ['account', 'toast']
|
||||
|
||||
export const PasswordSettings = ({ app, title = false, welcome = false }) => {
|
||||
const backend = useBackend(app)
|
||||
|
@ -35,7 +35,7 @@ export const PasswordSettings = ({ app, title = false, welcome = false }) => {
|
|||
onChange={(evt) => setPassword(evt.target.value)}
|
||||
className="input w-full input-bordered flex flex-row"
|
||||
type="text"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
placeholder={t('newPasswordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<SaveSettingsButton app={app} btnProps={{ onClick: save, disabled: password.length < 4 }} />
|
||||
|
|
40
sites/org/components/account/reload.mjs
Normal file
40
sites/org/components/account/reload.mjs
Normal file
|
@ -0,0 +1,40 @@
|
|||
// Hooks
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import { useBackend } from 'site/hooks/useBackend.mjs'
|
||||
import { useToast } from 'site/hooks/useToast.mjs'
|
||||
// Components
|
||||
import Link from 'next/link'
|
||||
import Markdown from 'react-markdown'
|
||||
import { Icons, welcomeSteps, BackToAccountButton } from './shared.mjs'
|
||||
import { Popout } from 'shared/components/popout.mjs'
|
||||
import { PageLink } from 'shared/components/page-link.mjs'
|
||||
import { SaveSettingsButton } from 'site/components/buttons/save-settings-button.mjs'
|
||||
import { ContinueButton } from 'site/components/buttons/continue-button.mjs'
|
||||
|
||||
export const ns = ['account', 'toast']
|
||||
|
||||
export const ReloadAccount = ({ app, title = false }) => {
|
||||
const backend = useBackend(app)
|
||||
const { t } = useTranslation(ns)
|
||||
const toast = useToast()
|
||||
|
||||
const reload = async () => {
|
||||
app.startLoading()
|
||||
const result = await backend.reloadAccount()
|
||||
if (result === true) toast.success(<span>{t('nailedIt')}</span>)
|
||||
else toast.for.backendError()
|
||||
app.stopLoading()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{title ? <h2>{t('reloadMsg1')}</h2> : null}
|
||||
<p>{t('reloadMsg2')}</p>
|
||||
<button className="btn btn-primary capitalize w-full my-2" onClick={reload}>
|
||||
{t('reload')}
|
||||
</button>
|
||||
<BackToAccountButton loading={app.loading} />
|
||||
</>
|
||||
)
|
||||
}
|
8
sites/org/components/bullet.mjs
Normal file
8
sites/org/components/bullet.mjs
Normal file
|
@ -0,0 +1,8 @@
|
|||
export const Bullet = ({ num, children }) => (
|
||||
<div className="flex flex-row items-center py-4 w-full gap-4">
|
||||
<span className="bg-secondary text-secondary-content rounded-full w-8 h-8 p-1 inline-block text-center font-bold mr-4 shrink-0">
|
||||
{num}
|
||||
</span>
|
||||
<div className="text-lg grow">{children}</div>
|
||||
</div>
|
||||
)
|
|
@ -1,2 +1,3 @@
|
|||
settingsSaved: Settings saved
|
||||
backendError: Backend returned an error
|
||||
copiedToClipboard: Copied to clipboard
|
||||
|
|
|
@ -4,7 +4,7 @@ import process from 'process'
|
|||
/*
|
||||
* Helper methods to interact with the FreeSewing backend
|
||||
*/
|
||||
const api = axios.create({
|
||||
const apiHandler = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_BACKEND || 'https://backend.freesewing.org',
|
||||
timeout: 3000,
|
||||
})
|
||||
|
@ -14,21 +14,56 @@ export function useBackend(app) {
|
|||
headers: { Authorization: 'Bearer ' + app.token },
|
||||
}
|
||||
|
||||
const api = {
|
||||
get: async (uri, config = {}) => {
|
||||
let result
|
||||
try {
|
||||
result = await apiHandler.get(uri, config)
|
||||
return result
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
return false
|
||||
},
|
||||
post: async (uri, data = null, config = {}) => {
|
||||
let result
|
||||
try {
|
||||
result = await apiHandler.post(uri, data, config)
|
||||
return result
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
return false
|
||||
},
|
||||
patch: async (uri, data = null, config = {}) => {
|
||||
let result
|
||||
try {
|
||||
result = await apiHandler.patch(uri, data, config)
|
||||
return result
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
return false
|
||||
},
|
||||
delete: async (uri, config = {}) => {
|
||||
let result
|
||||
try {
|
||||
result = await apiHandler.delete(uri, data, config)
|
||||
return result
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
const backend = {}
|
||||
|
||||
/*
|
||||
* User signup
|
||||
*/
|
||||
backend.signUp = async ({ email, language }) => {
|
||||
let result
|
||||
try {
|
||||
app.startLoading()
|
||||
result = await api.post('/signup', { email, language })
|
||||
} catch (err) {
|
||||
return err
|
||||
} finally {
|
||||
app.stopLoading()
|
||||
}
|
||||
const result = await api.post('/signup', { email, language })
|
||||
if (result && result.status === 201 && result.data) return result.data
|
||||
return null
|
||||
}
|
||||
|
@ -37,15 +72,7 @@ export function useBackend(app) {
|
|||
* Load confirmation
|
||||
*/
|
||||
backend.loadConfirmation = async ({ id, check }) => {
|
||||
let result
|
||||
try {
|
||||
app.startLoading()
|
||||
result = await api.get(`/confirmations/${id}/${check}`)
|
||||
} catch (err) {
|
||||
return err
|
||||
} finally {
|
||||
app.stopLoading()
|
||||
}
|
||||
const result = await api.get(`/confirmations/${id}/${check}`)
|
||||
if (result && result.status === 201 && result.data) return result.data
|
||||
return null
|
||||
}
|
||||
|
@ -54,15 +81,7 @@ export function useBackend(app) {
|
|||
* Confirm signup
|
||||
*/
|
||||
backend.confirmSignup = async ({ id, consent }) => {
|
||||
let result
|
||||
try {
|
||||
app.startLoading()
|
||||
result = await api.post(`/confirm/signup/${id}`, { consent })
|
||||
} catch (err) {
|
||||
return err
|
||||
} finally {
|
||||
app.stopLoading()
|
||||
}
|
||||
const result = await api.post(`/confirm/signup/${id}`, { consent })
|
||||
if (result && result.status === 200 && result.data) return result.data
|
||||
return null
|
||||
}
|
||||
|
@ -71,19 +90,12 @@ export function useBackend(app) {
|
|||
* Generic update account method
|
||||
*/
|
||||
backend.updateAccount = async (data) => {
|
||||
let result
|
||||
try {
|
||||
app.startLoading()
|
||||
result = await api.patch(`/account/jwt`, data, auth)
|
||||
} catch (err) {
|
||||
return err
|
||||
} finally {
|
||||
app.stopLoading()
|
||||
}
|
||||
const result = await api.patch(`/account/jwt`, data, auth)
|
||||
if (result && result.status === 200 && result.data?.account) {
|
||||
app.setAccount(result.data.account)
|
||||
return true
|
||||
}
|
||||
console.log('backend result', result)
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -92,32 +104,18 @@ export function useBackend(app) {
|
|||
* Checks whether a username is available
|
||||
*/
|
||||
backend.isUsernameAvailable = async (username) => {
|
||||
try {
|
||||
app.startLoading()
|
||||
await api.post(`/available/username/jwt`, { username }, auth)
|
||||
} catch (err) {
|
||||
// 404 means user is not found, so the username is available
|
||||
if (err.response?.status === 404) return true
|
||||
return false
|
||||
} finally {
|
||||
app.stopLoading()
|
||||
}
|
||||
const result = await api.post(`/available/username/jwt`, { username }, auth)
|
||||
// 404 means username is available, which is success in this case
|
||||
if (result.response?.status === 404) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove account method
|
||||
*/
|
||||
backend.removeAccount = async (data) => {
|
||||
let result
|
||||
try {
|
||||
app.startLoading()
|
||||
result = await api.delete(`/account/jwt`, auth)
|
||||
} catch (err) {
|
||||
return err
|
||||
} finally {
|
||||
app.stopLoading()
|
||||
}
|
||||
backend.removeAccount = async () => {
|
||||
const result = await api.delete(`/account/jwt`, auth)
|
||||
if (result && result.status === 200 && result.data?.account) {
|
||||
app.setAccount(result.data.account)
|
||||
return true
|
||||
|
@ -126,5 +124,68 @@ export function useBackend(app) {
|
|||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Enable MFA
|
||||
*/
|
||||
backend.enableMfa = async () => {
|
||||
const result = await api.post(`/account/mfa/jwt`, { mfa: true }, auth)
|
||||
if (result && result.status === 200 && result.data?.mfa) {
|
||||
return result.data.mfa
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Confirm MFA
|
||||
*/
|
||||
backend.confirmMfa = async (data) => {
|
||||
const result = await api.post(`/account/mfa/jwt`, { ...data, mfa: true }, auth)
|
||||
if (result && result.status === 200 && result.data?.account) {
|
||||
app.setAccount(result.data.account)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Disable MFA
|
||||
*/
|
||||
backend.disableMfa = async (data) => {
|
||||
const result = await await api.post(`/account/mfa/jwt`, { ...data, mfa: false }, auth)
|
||||
if (result && result.status === 200 && result.data?.account) {
|
||||
app.setAccount(result.data.account)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Reload account
|
||||
*/
|
||||
backend.reloadAccount = async () => {
|
||||
const result = await await api.get(`/whoami/jwt`, auth)
|
||||
if (result && result.status === 200 && result.data?.account) {
|
||||
app.setAccount(result.data.account)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Create API key
|
||||
*/
|
||||
backend.createApikey = async (data) => {
|
||||
const result = await await api.post(`/apikeys/jwt`, data, auth)
|
||||
if (result && result.status === 201 && result.data?.apikey) {
|
||||
return result.data.apikey
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return backend
|
||||
}
|
||||
|
|
|
@ -34,10 +34,12 @@
|
|||
"@mdx-js/runtime": "2.0.0-next.9",
|
||||
"@tailwindcss/typography": "0.5.9",
|
||||
"algoliasearch": "4.14.3",
|
||||
"react-copy-to-clipboard": "5.1.0",
|
||||
"daisyui": "2.47.0",
|
||||
"lodash.get": "4.4.2",
|
||||
"lodash.orderby": "4.6.0",
|
||||
"lodash.set": "4.3.2",
|
||||
"luxon": "latest",
|
||||
"next": "13.1.5",
|
||||
"react-dropzone": "14.2.3",
|
||||
"react-hotkeys-hook": "4.3.2",
|
||||
|
|
54
sites/org/pages/account/apikeys.mjs
Normal file
54
sites/org/pages/account/apikeys.mjs
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Hooks
|
||||
import { useApp } from 'site/hooks/useApp.mjs'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
// Dependencies
|
||||
import dynamic from 'next/dynamic'
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||
// Components
|
||||
import { PageWrapper, ns as pageNs } from 'site/components/wrappers/page.mjs'
|
||||
import { ns as authNs } from 'site/components/wrappers/auth/index.mjs'
|
||||
import { ns as apikeysNs } from 'site/components/account/apikeys.mjs'
|
||||
|
||||
// Translation namespaces used on this page
|
||||
const namespaces = [...new Set([...apikeysNs, ...authNs, ...pageNs])]
|
||||
|
||||
/*
|
||||
* Some things should never generated as SSR
|
||||
* So for these, we run a dynamic import and disable SSR rendering
|
||||
*/
|
||||
const DynamicAuthWrapper = dynamic(
|
||||
() => import('site/components/wrappers/auth/index.mjs').then((mod) => mod.AuthWrapper),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const DynamicApikeys = dynamic(
|
||||
() => import('site/components/account/apikeys.mjs').then((mod) => mod.Apikeys),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const AccountPage = (props) => {
|
||||
const app = useApp(props)
|
||||
const { t } = useTranslation(namespaces)
|
||||
const crumbs = [
|
||||
[t('yourAccount'), '/account'],
|
||||
[t('apikeys'), '/account/apikeys'],
|
||||
]
|
||||
|
||||
return (
|
||||
<PageWrapper app={app} title={t('apikeys')} crumbs={crumbs}>
|
||||
<DynamicAuthWrapper app={app}>
|
||||
<DynamicApikeys app={app} title />
|
||||
</DynamicAuthWrapper>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountPage
|
||||
|
||||
export async function getStaticProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, namespaces)),
|
||||
},
|
||||
}
|
||||
}
|
54
sites/org/pages/account/mfa.mjs
Normal file
54
sites/org/pages/account/mfa.mjs
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Hooks
|
||||
import { useApp } from 'site/hooks/useApp.mjs'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
// Dependencies
|
||||
import dynamic from 'next/dynamic'
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||
// Components
|
||||
import { PageWrapper, ns as pageNs } from 'site/components/wrappers/page.mjs'
|
||||
import { ns as authNs } from 'site/components/wrappers/auth/index.mjs'
|
||||
import { ns as mfaNs } from 'site/components/account/mfa.mjs'
|
||||
|
||||
// Translation namespaces used on this page
|
||||
const namespaces = [...new Set([...mfaNs, ...authNs, ...pageNs])]
|
||||
|
||||
/*
|
||||
* Some things should never generated as SSR
|
||||
* So for these, we run a dynamic import and disable SSR rendering
|
||||
*/
|
||||
const DynamicAuthWrapper = dynamic(
|
||||
() => import('site/components/wrappers/auth/index.mjs').then((mod) => mod.AuthWrapper),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const DynamicMfa = dynamic(
|
||||
() => import('site/components/account/mfa.mjs').then((mod) => mod.MfaSettings),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const AccountPage = (props) => {
|
||||
const app = useApp(props)
|
||||
const { t } = useTranslation(namespaces)
|
||||
const crumbs = [
|
||||
[t('yourAccount'), '/account'],
|
||||
[t('mfa'), '/account/mfa'],
|
||||
]
|
||||
|
||||
return (
|
||||
<PageWrapper app={app} title={t('mfa')} crumbs={crumbs}>
|
||||
<DynamicAuthWrapper app={app}>
|
||||
<DynamicMfa app={app} title />
|
||||
</DynamicAuthWrapper>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountPage
|
||||
|
||||
export async function getStaticProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, namespaces)),
|
||||
},
|
||||
}
|
||||
}
|
54
sites/org/pages/account/reload.mjs
Normal file
54
sites/org/pages/account/reload.mjs
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Hooks
|
||||
import { useApp } from 'site/hooks/useApp.mjs'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
// Dependencies
|
||||
import dynamic from 'next/dynamic'
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||
// Components
|
||||
import { PageWrapper, ns as pageNs } from 'site/components/wrappers/page.mjs'
|
||||
import { ns as authNs } from 'site/components/wrappers/auth/index.mjs'
|
||||
import { ns as reloadNs } from 'site/components/account/reload.mjs'
|
||||
|
||||
// Translation namespaces used on this page
|
||||
const namespaces = [...new Set([...reloadNs, ...authNs, ...pageNs])]
|
||||
|
||||
/*
|
||||
* Some things should never generated as SSR
|
||||
* So for these, we run a dynamic import and disable SSR rendering
|
||||
*/
|
||||
const DynamicAuthWrapper = dynamic(
|
||||
() => import('site/components/wrappers/auth/index.mjs').then((mod) => mod.AuthWrapper),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const DynamicReload = dynamic(
|
||||
() => import('site/components/account/reload.mjs').then((mod) => mod.ReloadAccount),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const AccountPage = (props) => {
|
||||
const app = useApp(props)
|
||||
const { t } = useTranslation(namespaces)
|
||||
const crumbs = [
|
||||
[t('yourAccount'), '/account'],
|
||||
[t('reload'), '/account/reload'],
|
||||
]
|
||||
|
||||
return (
|
||||
<PageWrapper app={app} title={t('reload')} crumbs={crumbs}>
|
||||
<DynamicAuthWrapper app={app}>
|
||||
<DynamicReload app={app} title />
|
||||
</DynamicAuthWrapper>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountPage
|
||||
|
||||
export async function getStaticProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, namespaces)),
|
||||
},
|
||||
}
|
||||
}
|
|
@ -550,10 +550,15 @@ details.jargon-details[open] svg.jargon-close {
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Fix styling for pan&zoom */
|
||||
/*
|
||||
div#pan-zoom-wrapper > div.react-transform-wrapper > div.react-transform-component {
|
||||
width: calc(100vw - 64rem);
|
||||
height: calc(100vh - 8rem);
|
||||
* Fix slider styles that for some reason are ugly
|
||||
*/
|
||||
.range::-moz-range-thumb {
|
||||
border-radius: 100%;
|
||||
}
|
||||
input[type='range']::-moz-range-track {
|
||||
background-color: hsla(var(--bc) / 0.1);
|
||||
}
|
||||
.range {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -12898,6 +12898,11 @@ lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1:
|
|||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.13.1.tgz#267a81fbd0881327c46a81c5922606a2cfe336c4"
|
||||
integrity sha512-CHqbAq7NFlW3RSnoWXLJBxCWaZVBrfa9UEHId2M3AW8iEBurbqduNexEUCGc3SHc6iCYXNJCDi903LajSVAEPQ==
|
||||
|
||||
luxon@latest:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.2.1.tgz#14f1af209188ad61212578ea7e3d518d18cee45f"
|
||||
integrity sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==
|
||||
|
||||
magic-string@^0.25.3:
|
||||
version "0.25.9"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue