diff --git a/sites/backend/src/controllers/users.mjs b/sites/backend/src/controllers/users.mjs index ca8c0dc6ae3..724b34380cd 100644 --- a/sites/backend/src/controllers/users.mjs +++ b/sites/backend/src/controllers/users.mjs @@ -141,6 +141,30 @@ UsersController.prototype.exportAccount = async (req, res, tools) => { return User.sendResponse(res) } +/* + * Restricts processing of account data + * + * See: https://freesewing.dev/reference/backend/api + */ +UsersController.prototype.restrictAccount = async (req, res, tools) => { + const User = new UserModel(tools) + await User.restrictAccount(req) + + return User.sendResponse(res) +} + +/* + * Remove account + * + * See: https://freesewing.dev/reference/backend/api + */ +UsersController.prototype.removeAccount = async (req, res, tools) => { + const User = new UserModel(tools) + await User.removeAccount(req) + + return User.sendResponse(res) +} + /* * Checks whether a submitted username is available * diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 23ae817e1e4..c032dc41518 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -1,7 +1,7 @@ import jwt from 'jsonwebtoken' import { log } from '../utils/log.mjs' import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' -import { replaceImage, importImage } from '../utils/cloudflare-images.mjs' +import { replaceImage, importImage, removeImage } from '../utils/cloudflare-images.mjs' import { clean, asJson, i18nUrl, writeExportedData } from '../utils/index.mjs' import { decorateModel } from '../utils/model-decorator.mjs' @@ -110,6 +110,76 @@ UserModel.prototype.exportAccount = async function ({ user }) { }) } +/* + * Restricts processing of account data + * + * @param {user} object - The user as loaded by the authentication middleware + * @returns {UserModel} object - The UserModel + */ +UserModel.prototype.restrictAccount = async function ({ user }) { + /* + * Read the record from the database + */ + await this.read({ id: user.uid }, { apikeys: true, bookmarks: true, patterns: true, sets: true }) + + /* + * If it does not exist, return 404 + */ + if (!this.exists) return this.setResponse(404) + + /* + * Update status to block the account + */ + await this.update({ status: -1 }) + + return this.setResponse200({ + result: 'success', + data: {}, + }) +} + +/* + * Remove account + * + * @param {user} object - The user as loaded by the authentication middleware + * @returns {UserModel} object - The UserModel + */ +UserModel.prototype.removeAccount = async function ({ user }) { + /* + * Read the record from the database + */ + await this.read({ id: user.uid }, { apikeys: true, bookmarks: true, patterns: true, sets: true }) + + /* + * If it does not exist, return 404 + */ + if (!this.exists) return this.setResponse(404) + + /* + * Remove user image + */ + await removeImage(`user-${this.record.ihash}`) + + /* + * Remove account + */ + try { + await this.prisma.pattern.deleteMany({ where: { userId: user.uid } }) + await this.prisma.set.deleteMany({ where: { userId: user.uid } }) + await this.prisma.bookmark.deleteMany({ where: { userId: user.uid } }) + await this.prisma.apikey.deleteMany({ where: { userId: user.uid } }) + await this.prisma.confirmation.deleteMany({ where: { userId: user.uid } }) + await this.delete() + } catch (err) { + log.warn(err, 'Error while removing account') + } + + return this.setResponse200({ + result: 'success', + data: {}, + }) +} + /* * Loads a user from the database based on the where clause you pass it * In addition prepares it for returning the account data diff --git a/sites/backend/src/routes/users.mjs b/sites/backend/src/routes/users.mjs index 836679a6259..93aa1c2956a 100644 --- a/sites/backend/src/routes/users.mjs +++ b/sites/backend/src/routes/users.mjs @@ -73,6 +73,23 @@ export function usersRoutes(tools) { app.get('/account/export/key', passport.authenticate(...bsc), (req, res) => Users.exportAccount(req, res, tools) ) + + // Restrict processing of account data + app.get('/account/restrict/jwt', passport.authenticate(...jwt), (req, res) => + Users.restrictAccount(req, res, tools) + ) + app.get('/account/restrict/key', passport.authenticate(...bsc), (req, res) => + Users.restrictAccount(req, res, tools) + ) + + // Remove account + app.delete('/account/jwt', passport.authenticate(...jwt), (req, res) => + Users.removeAccount(req, res, tools) + ) + app.delete('/account/key', passport.authenticate(...bsc), (req, res) => + Users.removeAccount(req, res, tools) + ) + /* // Remove account diff --git a/sites/org/pages/account/remove.mjs b/sites/org/pages/account/remove.mjs new file mode 100644 index 00000000000..173b27257ae --- /dev/null +++ b/sites/org/pages/account/remove.mjs @@ -0,0 +1,59 @@ +// Dependencies +import dynamic from 'next/dynamic' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { nsMerge } from 'shared/utils.mjs' +// Hooks +import { useTranslation } from 'next-i18next' +// Components +import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' +import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs' +import { ns as reloadNs } from 'shared/components/account/reload.mjs' + +// Translation namespaces used on this page +const ns = nsMerge(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('shared/components/wrappers/auth/index.mjs').then((mod) => mod.AuthWrapper), + { ssr: false } +) + +const DynamicRemove = dynamic( + () => import('shared/components/account/remove.mjs').then((mod) => mod.RemoveAccount), + { ssr: false } +) + +/* + * Each page MUST be wrapped in the PageWrapper component. + * You also MUST spread props.page into this wrapper component + * when path and locale come from static props (as here) + * or set them manually. + */ +const AccountRemovePage = ({ page }) => { + const { t } = useTranslation(ns) + + return ( + + + + + + ) +} + +export default AccountRemovePage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, ns)), + page: { + locale, + path: ['account', 'remove'], + }, + }, + } +} diff --git a/sites/org/pages/account/restrict.mjs b/sites/org/pages/account/restrict.mjs new file mode 100644 index 00000000000..84f3cb3cd90 --- /dev/null +++ b/sites/org/pages/account/restrict.mjs @@ -0,0 +1,59 @@ +// Dependencies +import dynamic from 'next/dynamic' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { nsMerge } from 'shared/utils.mjs' +// Hooks +import { useTranslation } from 'next-i18next' +// Components +import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' +import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs' +import { ns as reloadNs } from 'shared/components/account/reload.mjs' + +// Translation namespaces used on this page +const ns = nsMerge(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('shared/components/wrappers/auth/index.mjs').then((mod) => mod.AuthWrapper), + { ssr: false } +) + +const DynamicRestrict = dynamic( + () => import('shared/components/account/restrict.mjs').then((mod) => mod.RestrictAccount), + { ssr: false } +) + +/* + * Each page MUST be wrapped in the PageWrapper component. + * You also MUST spread props.page into this wrapper component + * when path and locale come from static props (as here) + * or set them manually. + */ +const AccountRestrictPage = ({ page }) => { + const { t } = useTranslation(ns) + + return ( + + + + + + ) +} + +export default AccountRestrictPage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, ns)), + page: { + locale, + path: ['account', 'restrict'], + }, + }, + } +} diff --git a/sites/shared/components/account/en.yaml b/sites/shared/components/account/en.yaml index 2d922275260..00881108703 100644 --- a/sites/shared/components/account/en.yaml +++ b/sites/shared/components/account/en.yaml @@ -56,6 +56,10 @@ restrict: Restrict processing of your data disable: Disable your account remove: Remove your account +proceedWithCaution: Proceed with caution +restrictWarning: While no data will be removed, this will disable your account. Furthermore, you can not undo this on your own, but will have to contact support when you want to restore access to your account. +noWayBack: There is no way back from this. + mdSupport: You can use markdown here or: or continue: Continue diff --git a/sites/shared/components/account/remove.mjs b/sites/shared/components/account/remove.mjs new file mode 100644 index 00000000000..197892bbba5 --- /dev/null +++ b/sites/shared/components/account/remove.mjs @@ -0,0 +1,43 @@ +// Dependencies +import { useTranslation } from 'next-i18next' +// Hooks +import { useAccount } from 'shared/hooks/use-account.mjs' +import { useBackend } from 'shared/hooks/use-backend.mjs' +import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs' +// Components +import { BackToAccountButton } from './shared.mjs' +import { Popout } from 'shared/components/popout/index.mjs' +import { WebLink } from 'shared/components/web-link.mjs' + +export const ns = ['account', 'status'] + +export const RemoveAccount = () => { + // Hooks + const { setAccount, token, signOut } = useAccount() + const backend = useBackend(token) + const { t } = useTranslation(ns) + const { setLoadingStatus, LoadingStatus } = useLoadingStatus() + + // Helper method to export account + const removeAccount = async () => { + setLoadingStatus([true, 'processingUpdate']) + const result = await backend.removeAccount() + if (result.success) { + setLoadingStatus([true, 'nailedIt', true, true]) + signOut() + } else setLoadingStatus([true, 'backendError', true, false]) + } + + return ( +
+ + +

{t('noWayBack')}

+ +
+ +
+ ) +} diff --git a/sites/shared/components/account/restrict.mjs b/sites/shared/components/account/restrict.mjs new file mode 100644 index 00000000000..8452cc5779d --- /dev/null +++ b/sites/shared/components/account/restrict.mjs @@ -0,0 +1,42 @@ +// Dependencies +import { useTranslation } from 'next-i18next' +// Hooks +import { useAccount } from 'shared/hooks/use-account.mjs' +import { useBackend } from 'shared/hooks/use-backend.mjs' +import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs' +// Components +import { BackToAccountButton } from './shared.mjs' +import { Popout } from 'shared/components/popout/index.mjs' +import { WebLink } from 'shared/components/web-link.mjs' + +export const ns = ['account', 'status'] + +export const RestrictAccount = () => { + // Hooks + const { setAccount, token } = useAccount() + const backend = useBackend(token) + const { t } = useTranslation(ns) + const { setLoadingStatus, LoadingStatus } = useLoadingStatus() + + // Helper method to export account + const restrictAccount = async () => { + setLoadingStatus([true, 'processingUpdate']) + const result = await backend.restrictAccount() + if (result.success) setLoadingStatus([true, 'nailedIt', true, true]) + else setLoadingStatus([true, 'backendError', true, false]) + } + + return ( +
+ + +
{t('proceedWithCaution')}
+

{t('restrictWarning')}

+ +
+ +
+ ) +} diff --git a/sites/shared/hooks/use-backend.mjs b/sites/shared/hooks/use-backend.mjs index 2ef87617e69..22eb0ab8bc5 100644 --- a/sites/shared/hooks/use-backend.mjs +++ b/sites/shared/hooks/use-backend.mjs @@ -152,37 +152,49 @@ Backend.prototype.confirmMfa = async function (data) { * Disable MFA */ Backend.prototype.disableMfa = async function (data) { - return responseHandler( - await await api.post(`/account/mfa/jwt`, { ...data, mfa: false }, this.auth) - ) + return responseHandler(await api.post(`/account/mfa/jwt`, { ...data, mfa: false }, this.auth)) } /* * Reload account */ Backend.prototype.reloadAccount = async function () { - return responseHandler(await await api.get(`/whoami/jwt`, this.auth)) + return responseHandler(await api.get(`/whoami/jwt`, this.auth)) } /* * Export account data */ Backend.prototype.exportAccount = async function () { - return responseHandler(await await api.get(`/account/export/jwt`, this.auth)) + return responseHandler(await api.get(`/account/export/jwt`, this.auth)) +} + +/* + * Restrict processing of account data + */ +Backend.prototype.restrictAccount = async function () { + return responseHandler(await api.get(`/account/restrict/jwt`, this.auth)) +} + +/* + * Remove account + */ +Backend.prototype.restrictAccount = async function () { + return responseHandler(await api.delete(`/account/jwt`, this.auth)) } /* * Load all user data */ Backend.prototype.getUserData = async function (uid) { - return responseHandler(await await api.get(`/users/${uid}/jwt`, this.auth)) + return responseHandler(await api.get(`/users/${uid}/jwt`, this.auth)) } /* * Load user profile */ Backend.prototype.getProfile = async function (uid) { - return responseHandler(await await api.get(`/users/${uid}`)) + return responseHandler(await api.get(`/users/${uid}`)) } /*