1
0
Fork 0

feat(org): Further work on account pages

This commit is contained in:
joostdecock 2023-02-19 20:49:15 +01:00
parent c0571d54f0
commit 79434846b6
18 changed files with 822 additions and 80 deletions

View file

@ -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

View file

@ -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

View file

@ -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 }
},

View file

@ -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.

View 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}&nbsp;{t('days')}
</span>
) : (
t('days')
)}
</button>
<button
className={`btn btn-secondary btn-sm ${days ? 'btn-outline' : ''}`}
onClick={() => {
setDays(false)
setVal(1)
}}
>
{!days ? (
<span>
{val}&nbsp;{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>
</>
)
}

View file

@ -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 }) => {

View 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} />}
</>
)
}

View file

@ -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 }} />

View 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} />
</>
)
}

View 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>
)

View file

@ -1,2 +1,3 @@
settingsSaved: Settings saved
backendError: Backend returned an error
copiedToClipboard: Copied to clipboard

View file

@ -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
}

View file

@ -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",

View 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)),
},
}
}

View 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)),
},
}
}

View 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)),
},
}
}

View file

@ -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;
}

View file

@ -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"