diff --git a/config/dependencies.yaml b/config/dependencies.yaml index 6fb3423bad9..8d88eec9876 100644 --- a/config/dependencies.yaml +++ b/config/dependencies.yaml @@ -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 diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 13010c92fb2..07b4ab3954f 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -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 diff --git a/sites/backend/src/utils/mfa.mjs b/sites/backend/src/utils/mfa.mjs index f14c7074eae..542425deb3f 100644 --- a/sites/backend/src/utils/mfa.mjs +++ b/sites/backend/src/utils/mfa.mjs @@ -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(' { + 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 ( + <> +
+ +
+ + +
+
+ + {t('keyExpiresDesc')} + {expires.toHTTP()} + + + ) +} + +const CopyInput = ({ text }) => { + const { t } = useTranslation(['toast']) + const toast = useToast() + + const [copied, setCopied] = useState(false) + + const showCopied = () => { + setCopied(true) + toast.success({t('copiedToClipboard')}) + window.setTimeout(() => setCopied(false), 3000) + } + + return ( +
+ + + + +
+ ) +} + +const Row = ({ title, children }) => ( +
+
{title}
+
{children}
+
+) +const ShowKey = ({ apikey, t, clear }) => ( +
+ {apikey.name} + {DateTime.fromISO(apikey.createdAt).toHTTP()} + {DateTime.fromISO(apikey.expiresAt).toHTTP()} + + + + + + + + {t('keySecretWarning')} + + +
+) + +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({t('nailedIt')}) + setApikey(result) + } else toast.for.backendError() + app.stopLoading() + } + + const clear = () => { + setApikey(false) + setGenerate(false) + } + + return ( +
+

{t('newApikey')}

+ {apikey ? ( + <> + + + ) : ( + <> +

{t('keyName')}

+

{t('keyNameDesc')}

+ setName(evt.target.value)} + className="input w-full input-bordered flex flex-row" + type="text" + placeholder={'Alicia key'} + /> +

{t('keyExpires')}

+ +

{t('keyLevel')}

+ {levels.map((l) => ( + + {t(`keyLevel${l}`)} + + ))} +
+ + +
+ + )} +
+ ) +} + +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 ( + <> +
+ {generate ? ( + + ) : ( + <> +

{t('apikeys')}

+ + + +
Refer to FreeSewing.dev for details (English only)
+

+ This is an advanced feature aimed at developers or anyone who wants to interact with + our backend directly. For details, please refer to{' '} + {' '} + on our site for + developers and contributors. +

+
+ + )} +
+ + ) +} diff --git a/sites/org/components/account/links.mjs b/sites/org/components/account/links.mjs index 0242f628867..1b4c3e6b747 100644 --- a/sites/org/components/account/links.mjs +++ b/sites/org/components/account/links.mjs @@ -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 }) => { diff --git a/sites/org/components/account/mfa.mjs b/sites/org/components/account/mfa.mjs new file mode 100644 index 00000000000..40621a80c7b --- /dev/null +++ b/sites/org/components/account/mfa.mjs @@ -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 }) => ( + 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({t('mfaDisabled')}) + 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({t('mfaEnabled')}) + 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 ?

{titleText}

: null} + {enable ? ( + <> +
+
+
+ {t('mfaAdd')} + {t('confirmWithMfa')} + 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')} + /> + + + ) : null} + {disable ? ( +
+ +
{t('confirmWithPassword')}
+ setPassword(evt.target.value)} + className="input w-full input-bordered flex flex-row" + type="text" + placeholder={t('passwordPlaceholder')} + /> +
+ +
{t('confirmWithMfa')}
+ +
+ +
+ ) : null} +
+ {app.account.mfaEnabled ? ( + disable ? null : ( + + ) + ) : enable ? null : ( +
+ + +
{t('mfaTipTitle')}
+

{t('mfaTipMsg')}

+
+
+ )} +
+ {!welcome && } + + ) +} diff --git a/sites/org/components/account/password.mjs b/sites/org/components/account/password.mjs index 475033ad175..6e8c54349c8 100644 --- a/sites/org/components/account/password.mjs +++ b/sites/org/components/account/password.mjs @@ -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')} />
diff --git a/sites/org/components/account/reload.mjs b/sites/org/components/account/reload.mjs new file mode 100644 index 00000000000..91c6706c705 --- /dev/null +++ b/sites/org/components/account/reload.mjs @@ -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({t('nailedIt')}) + else toast.for.backendError() + app.stopLoading() + } + + return ( + <> + {title ?

{t('reloadMsg1')}

: null} +

{t('reloadMsg2')}

+ + + + ) +} diff --git a/sites/org/components/bullet.mjs b/sites/org/components/bullet.mjs new file mode 100644 index 00000000000..cc30456274f --- /dev/null +++ b/sites/org/components/bullet.mjs @@ -0,0 +1,8 @@ +export const Bullet = ({ num, children }) => ( +
+ + {num} + +
{children}
+
+) diff --git a/sites/org/components/toast/toast.en.yaml b/sites/org/components/toast/toast.en.yaml index 70796077043..9546e0ac319 100644 --- a/sites/org/components/toast/toast.en.yaml +++ b/sites/org/components/toast/toast.en.yaml @@ -1,2 +1,3 @@ settingsSaved: Settings saved backendError: Backend returned an error +copiedToClipboard: Copied to clipboard diff --git a/sites/org/hooks/useBackend.mjs b/sites/org/hooks/useBackend.mjs index 1ff78c92c5c..36098ed047d 100644 --- a/sites/org/hooks/useBackend.mjs +++ b/sites/org/hooks/useBackend.mjs @@ -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 } diff --git a/sites/org/package.json b/sites/org/package.json index 3856623608f..980f300cea1 100644 --- a/sites/org/package.json +++ b/sites/org/package.json @@ -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", diff --git a/sites/org/pages/account/apikeys.mjs b/sites/org/pages/account/apikeys.mjs new file mode 100644 index 00000000000..8fa4a952856 --- /dev/null +++ b/sites/org/pages/account/apikeys.mjs @@ -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 ( + + + + + + ) +} + +export default AccountPage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, namespaces)), + }, + } +} diff --git a/sites/org/pages/account/mfa.mjs b/sites/org/pages/account/mfa.mjs new file mode 100644 index 00000000000..65ee5fe4039 --- /dev/null +++ b/sites/org/pages/account/mfa.mjs @@ -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 ( + + + + + + ) +} + +export default AccountPage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, namespaces)), + }, + } +} diff --git a/sites/org/pages/account/reload.mjs b/sites/org/pages/account/reload.mjs new file mode 100644 index 00000000000..63c87c4b97c --- /dev/null +++ b/sites/org/pages/account/reload.mjs @@ -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 ( + + + + + + ) +} + +export default AccountPage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, namespaces)), + }, + } +} diff --git a/sites/shared/styles/globals.css b/sites/shared/styles/globals.css index 017901fc117..47543359c72 100644 --- a/sites/shared/styles/globals.css +++ b/sites/shared/styles/globals.css @@ -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; } -*/ diff --git a/yarn.lock b/yarn.lock index 38415c39c95..531005ad9d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"