mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Delete my account button
Summary: Adding new "Delete my account" button to the profile page that allows users to remove completely their accounts as long as they don't own any team site. Test Plan: Added Reviewers: georgegevoian, paulfitz Reviewed By: georgegevoian, paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4037
This commit is contained in:
		
							parent
							
								
									e033889b6a
								
							
						
					
					
						commit
						cce185956c
					
				| @ -87,6 +87,11 @@ export function getLogoutUrl(): string { | ||||
|   return _getLoginLogoutUrl('logout'); | ||||
| } | ||||
| 
 | ||||
| // Get the URL that users are redirect to after deleting their account.
 | ||||
| export function getAccountDeletedUrl(): string { | ||||
|   return _getLoginLogoutUrl('account-deleted', {nextUrl: ''}); | ||||
| } | ||||
| 
 | ||||
| // Get URL for the signin page.
 | ||||
| export function getLoginOrSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string { | ||||
|   return _getLoginLogoutUrl('signin', options); | ||||
| @ -96,19 +101,21 @@ export function getWelcomeHomeUrl() { | ||||
|   return _buildUrl('welcome/home').href; | ||||
| } | ||||
| 
 | ||||
| const FINAL_PATHS = ['/signed-out', '/account-deleted']; | ||||
| 
 | ||||
| // Returns the relative URL (i.e. path) of the current page, except when it's the
 | ||||
| // "/signed-out" page, in which case it returns the home page ("/").
 | ||||
| // "/signed-out" page or "/account-deleted", in which case it returns the home page ("/").
 | ||||
| // This is a good URL to use for a post-login redirect.
 | ||||
| function _getCurrentUrl(): string { | ||||
|   const {hash, pathname, search} = new URL(window.location.href); | ||||
|   if (pathname.endsWith('/signed-out')) { return '/'; } | ||||
|   if (FINAL_PATHS.some(final => pathname.endsWith(final))) { return '/'; } | ||||
| 
 | ||||
|   return parseFirstUrlPart('o', pathname).path + search + hash; | ||||
| } | ||||
| 
 | ||||
| // Returns the URL for the given login page.
 | ||||
| function _getLoginLogoutUrl( | ||||
|   page: 'login'|'logout'|'signin'|'signup', | ||||
|   page: 'login'|'logout'|'signin'|'signup'|'account-deleted', | ||||
|   options: GetLoginOrSignupUrlOptions = {} | ||||
| ): string { | ||||
|   const {srcDocId, nextUrl = _getCurrentUrl()} = options; | ||||
|  | ||||
| @ -1,9 +1,12 @@ | ||||
| import {detectCurrentLang, makeT} from 'app/client/lib/localization'; | ||||
| import {AppModel, reportError} from 'app/client/models/AppModel'; | ||||
| import {urlState} from 'app/client/models/gristUrlState'; | ||||
| import * as css from 'app/client/ui/AccountPageCss'; | ||||
| import {ApiKey} from 'app/client/ui/ApiKey'; | ||||
| import {AppHeader} from 'app/client/ui/AppHeader'; | ||||
| import {buildChangePasswordDialog} from 'app/client/ui/ChangePasswordDialog'; | ||||
| import {DeleteAccountDialog} from 'app/client/ui/DeleteAccountDialog'; | ||||
| import {translateLocale} from 'app/client/ui/LanguageMenu'; | ||||
| import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; | ||||
| import {MFAConfig} from 'app/client/ui/MFAConfig'; | ||||
| import {pagePanels} from 'app/client/ui/PagePanels'; | ||||
| @ -14,11 +17,9 @@ import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs'; | ||||
| import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; | ||||
| import {cssLink} from 'app/client/ui2018/links'; | ||||
| import {select} from 'app/client/ui2018/menus'; | ||||
| import {getPageTitleSuffix} from 'app/common/gristUrls'; | ||||
| import {getGristConfig} from 'app/common/urlUtils'; | ||||
| import {FullUser} from 'app/common/UserAPI'; | ||||
| import {detectCurrentLang, makeT} from 'app/client/lib/localization'; | ||||
| import {translateLocale} from 'app/client/ui/LanguageMenu'; | ||||
| import {getPageTitleSuffix} from 'app/common/gristUrls'; | ||||
| import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled, subscribe} from 'grainjs'; | ||||
| 
 | ||||
| const testId = makeTestId('test-account-page-'); | ||||
| @ -161,7 +162,10 @@ designed to ensure that you're the only person who can access your account, even | ||||
|             inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px.
 | ||||
|           }) | ||||
|         )), | ||||
|       ), | ||||
|         !getGristConfig().canCloseAccount ? null : [ | ||||
|             dom.create(DeleteAccountDialog, user), | ||||
|         ], | ||||
| ), | ||||
|       testId('body'), | ||||
|     ))); | ||||
|   } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {AppModel} from 'app/client/models/AppModel'; | ||||
| import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState'; | ||||
| import {getLoginUrl, getMainOrgUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState'; | ||||
| import {AppHeader} from 'app/client/ui/AppHeader'; | ||||
| import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; | ||||
| import {pagePanels} from 'app/client/ui/PagePanels'; | ||||
| @ -21,7 +21,8 @@ export function createErrPage(appModel: AppModel) { | ||||
|   return gristConfig.errPage === 'signed-out' ? createSignedOutPage(appModel) : | ||||
|     gristConfig.errPage === 'not-found' ? createNotFoundPage(appModel, message) : | ||||
|     gristConfig.errPage === 'access-denied' ? createForbiddenPage(appModel, message) : | ||||
|     createOtherErrorPage(appModel, message); | ||||
|     gristConfig.errPage === 'account-deleted' ? createAccountDeletedPage(appModel) : | ||||
|         createOtherErrorPage(appModel, message); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -67,6 +68,20 @@ export function createSignedOutPage(appModel: AppModel) { | ||||
|   ]); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Creates a page that shows the user is logged out. | ||||
|  */ | ||||
| export function createAccountDeletedPage(appModel: AppModel) { | ||||
|   document.title = t("Account deleted{{suffix}}", {suffix: getPageTitleSuffix(getGristConfig())}); | ||||
| 
 | ||||
|   return pagePanelsError(appModel, t("Account deleted{{suffix}}", {suffix: ''}), [ | ||||
|     cssErrorText(t("Your account has been deleted.")), | ||||
|     cssButtonWrap(bigPrimaryButtonLink( | ||||
|       t("Sign up"), {href: getSignupUrl()}, testId('error-signin') | ||||
|     )) | ||||
|   ]); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Creates a "Page not found" page. | ||||
|  */ | ||||
|  | ||||
| @ -168,6 +168,7 @@ export const theme = { | ||||
|   lightText: new CustomProp('theme-text-light', undefined, colors.slate), | ||||
|   darkText: new CustomProp('theme-text-dark', undefined, 'black'), | ||||
|   errorText: new CustomProp('theme-text-error', undefined, colors.error), | ||||
|   errorTextHover: new CustomProp('theme-text-error-hover', undefined, '#BF0A31'), | ||||
|   dangerText: new CustomProp('theme-text-danger', undefined, '#FFA500'), | ||||
|   disabledText: new CustomProp('theme-text-disabled', undefined, colors.slate), | ||||
| 
 | ||||
|  | ||||
| @ -341,6 +341,8 @@ export interface ConfirmModalOptions { | ||||
|   hideCancel?: boolean; | ||||
|   extraButtons?: DomContents; | ||||
|   modalOptions?: IModalOptions; | ||||
|   saveDisabled?: Observable<boolean>; | ||||
|   width?: ModalWidth; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -352,7 +354,7 @@ export function confirmModal( | ||||
|   title: DomElementArg, | ||||
|   btnText: DomElementArg, | ||||
|   onConfirm: () => Promise<void>, | ||||
|   {explanation, hideCancel, extraButtons, modalOptions}: ConfirmModalOptions = {}, | ||||
|   {explanation, hideCancel, extraButtons, modalOptions, saveDisabled, width}: ConfirmModalOptions = {}, | ||||
| ): void { | ||||
|   return saveModal((ctl, owner): ISaveModalOptions => ({ | ||||
|     title, | ||||
| @ -360,8 +362,9 @@ export function confirmModal( | ||||
|     saveLabel: btnText, | ||||
|     saveFunc: onConfirm, | ||||
|     hideCancel, | ||||
|     width: 'normal', | ||||
|     width: width ?? 'normal', | ||||
|     extraButtons, | ||||
|     saveDisabled, | ||||
|   }), modalOptions); | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -29,6 +29,7 @@ export const ThemeColors = t.iface([], { | ||||
|   "text-light": "string", | ||||
|   "text-dark": "string", | ||||
|   "text-error": "string", | ||||
|   "text-error-hover": "string", | ||||
|   "text-danger": "string", | ||||
|   "text-disabled": "string", | ||||
|   "page-bg": "string", | ||||
|  | ||||
| @ -27,6 +27,7 @@ export interface ThemeColors { | ||||
|   'text-light': string; | ||||
|   'text-dark': string; | ||||
|   'text-error': string; | ||||
|   'text-error-hover': string; | ||||
|   'text-danger': string; | ||||
|   'text-disabled': string; | ||||
| 
 | ||||
|  | ||||
| @ -405,6 +405,20 @@ export interface UserAPI { | ||||
|   getBaseUrl(): string;  // Get the prefix for all the endpoints this object wraps.
 | ||||
|   forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
 | ||||
|   getWidgets(): Promise<ICustomWidget[]>; | ||||
|   /** | ||||
|    * Deletes account and personal org with all documents. Note: deleteUser doesn't clear documents, and this method | ||||
|    * is specific to Grist installation, and might not be supported. Pass current user's id so that we can verify | ||||
|    * that the user is deleting their own account. This is just to prevent accidental deletion from multiple tabs. | ||||
|    * | ||||
|    * @returns true if the account was deleted, false if there was a mismatch with the current user's id, and the | ||||
|    * account was probably already deleted. | ||||
|    */ | ||||
|   closeAccount(userId: number): Promise<boolean>; | ||||
|   /** | ||||
|    * Deletes current non personal org with all documents. Note: deleteOrg doesn't clear documents, and this method | ||||
|    * is specific to Grist installation, and might not be supported. | ||||
|    */ | ||||
|   closeOrg(): Promise<void>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -813,6 +827,14 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { | ||||
|                         body: JSON.stringify({name})}); | ||||
|   } | ||||
| 
 | ||||
|   public async closeAccount(userId: number): Promise<boolean> { | ||||
|     return await this.requestJson(`${this._url}/api/doom/account?userid=` + userId, {method: 'DELETE'}); | ||||
|   } | ||||
| 
 | ||||
|   public async closeOrg() { | ||||
|     await this.request(`${this._url}/api/doom/org`, {method: 'DELETE'}); | ||||
|   } | ||||
| 
 | ||||
|   public getBaseUrl(): string { return this._url; } | ||||
| 
 | ||||
|   // Recomputes the URL on every call to pick up changes in the URL when switching orgs.
 | ||||
| @ -1047,7 +1069,7 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { | ||||
| 
 | ||||
|   public async uploadAttachment(value: string | Blob, filename?: string): Promise<number> { | ||||
|     const formData = this.newFormData(); | ||||
|     formData.append('upload', value, filename); | ||||
|     formData.append('upload', value as Blob, filename); | ||||
|     const response = await this.requestAxios(`${this._url}/attachments`, { | ||||
|       method: 'POST', | ||||
|       data: formData, | ||||
|  | ||||
| @ -694,6 +694,9 @@ export interface GristLoadConfig { | ||||
| 
 | ||||
|   // The org containing public templates and tutorials.
 | ||||
|   templateOrg?: string|null; | ||||
| 
 | ||||
|   // Whether to show the "Delete Account" button in the account page.
 | ||||
|   canCloseAccount?: boolean; | ||||
| } | ||||
| 
 | ||||
| export const Features = StringUnion( | ||||
|  | ||||
| @ -5,7 +5,8 @@ export const GristDark: ThemeColors = { | ||||
|   'text': '#EFEFEF', | ||||
|   'text-light': '#A4A4B1', | ||||
|   'text-dark': '#FFFFFF', | ||||
|   'text-error': '#FF6666', | ||||
|   'text-error': '#E63946', | ||||
|   'text-error-hover': '#FF5C5C', | ||||
|   'text-danger': '#FFA500', | ||||
|   'text-disabled': '#A4A4B1', | ||||
| 
 | ||||
|  | ||||
| @ -6,6 +6,7 @@ export const GristLight: ThemeColors = { | ||||
|   'text-light': '#929299', | ||||
|   'text-dark': 'black', | ||||
|   'text-error': '#D0021B', | ||||
|   'text-error-hover': '#A10000', | ||||
|   'text-danger': '#FFA500', | ||||
|   'text-disabled': '#929299', | ||||
| 
 | ||||
|  | ||||
| @ -177,7 +177,7 @@ export class ApiServer { | ||||
|       return sendReply(req, res, query); | ||||
|     })); | ||||
| 
 | ||||
|     // // DELETE /api/orgs/:oid
 | ||||
|     // DELETE /api/orgs/:oid
 | ||||
|     // Delete the specified org and all included workspaces and docs.
 | ||||
|     this._app.delete('/api/orgs/:oid', expressWrap(async (req, res) => { | ||||
|       const org = getOrgKey(req); | ||||
|  | ||||
| @ -837,7 +837,7 @@ export class HomeDBManager extends EventEmitter { | ||||
|     } | ||||
|     await this._connection.transaction(async manager => { | ||||
|       const user = await manager.findOne(User, {where: {id: userIdToDelete}, | ||||
|                                                 relations: ["logins", "personalOrg"]}); | ||||
|                                                 relations: ["logins", "personalOrg", "prefs"]}); | ||||
|       if (!user) { throw new ApiError('user not found', 404); } | ||||
|       if (name) { | ||||
|         if (user.name !== name) { | ||||
| @ -853,6 +853,7 @@ export class HomeDBManager extends EventEmitter { | ||||
|         .from('group_users') | ||||
|         .where('user_id = :userId', {userId: userIdToDelete}) | ||||
|         .execute(); | ||||
| 
 | ||||
|       await manager.delete(User, userIdToDelete); | ||||
|     }); | ||||
|     return { | ||||
|  | ||||
| @ -5,7 +5,7 @@ import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, | ||||
|         GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain, | ||||
|         sanitizePathTail} from 'app/common/gristUrls'; | ||||
| import {getOrgUrlInfo} from 'app/common/gristUrls'; | ||||
| import {safeJsonParse} from 'app/common/gutil'; | ||||
| import {isAffirmative, safeJsonParse} from 'app/common/gutil'; | ||||
| import {InstallProperties} from 'app/common/InstallAPI'; | ||||
| import {UserProfile} from 'app/common/LoginSessionAPI'; | ||||
| import {tbind} from 'app/common/tbind'; | ||||
| @ -17,6 +17,7 @@ import {Workspace} from 'app/gen-server/entity/Workspace'; | ||||
| import {Activations} from 'app/gen-server/lib/Activations'; | ||||
| import {DocApiForwarder} from 'app/gen-server/lib/DocApiForwarder'; | ||||
| import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap'; | ||||
| import {Doom} from 'app/gen-server/lib/Doom'; | ||||
| import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; | ||||
| import {Housekeeper} from 'app/gen-server/lib/Housekeeper'; | ||||
| import {Usage} from 'app/gen-server/lib/Usage'; | ||||
| @ -52,7 +53,7 @@ import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/place | ||||
| import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint'; | ||||
| import {PluginManager} from 'app/server/lib/PluginManager'; | ||||
| import * as ProcessMonitor from 'app/server/lib/ProcessMonitor'; | ||||
| import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, isDefaultUser, optStringParam, | ||||
| import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isDefaultUser, optStringParam, | ||||
|         RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET, | ||||
|         trustOrigin} from 'app/server/lib/requestUtils'; | ||||
| import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage'; | ||||
| @ -971,8 +972,7 @@ export class FlexServer implements GristServer { | ||||
| 
 | ||||
|     // TODO: We could include a third mock provider of login/logout URLs for better tests. Or we
 | ||||
|     // could create a mock SAML identity provider for testing this using the SAML flow.
 | ||||
|     const loginSystem = await (process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : | ||||
|       (this._getLoginSystem?.() || getLoginSystem())); | ||||
|     const loginSystem = await this.resolveLoginSystem(); | ||||
|     this._loginMiddleware = await loginSystem.getMiddleware(this); | ||||
|     this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware); | ||||
|     this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware); | ||||
| @ -1082,22 +1082,9 @@ export class FlexServer implements GristServer { | ||||
|       })); | ||||
|     } | ||||
| 
 | ||||
|     const logoutMiddleware = this._loginMiddleware.getLogoutMiddleware ? | ||||
|       this._loginMiddleware.getLogoutMiddleware() : | ||||
|       []; | ||||
|     this.app.get('/logout', ...logoutMiddleware, expressWrap(async (req, resp) => { | ||||
|       const scopedSession = this._sessions.getOrCreateSessionFromRequest(req); | ||||
|     this.app.get('/logout', ...this._logoutMiddleware(), expressWrap(async (req, resp) => { | ||||
|       const signedOutUrl = new URL(getOrgUrl(req) + 'signed-out'); | ||||
|       const redirectUrl = await this._getLogoutRedirectUrl(req, signedOutUrl); | ||||
| 
 | ||||
|       // Clear session so that user needs to log in again at the next request.
 | ||||
|       // SAML logout in theory uses userSession, so clear it AFTER we compute the URL.
 | ||||
|       // Express-session will save these changes.
 | ||||
|       const expressSession = (req as RequestWithLogin).session; | ||||
|       if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; } | ||||
|       await scopedSession.clearScopedSession(req); | ||||
|       // TODO: limit cache clearing to specific user.
 | ||||
|       this._sessions.clearCacheIfNeeded(); | ||||
|       resp.redirect(redirectUrl); | ||||
|     })); | ||||
| 
 | ||||
| @ -1220,6 +1207,81 @@ export class FlexServer implements GristServer { | ||||
|     this.app.get('/account', ...middleware, expressWrap(async (req, resp) => { | ||||
|       return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}}); | ||||
|     })); | ||||
| 
 | ||||
|     const createDoom = async (req: express.Request) => { | ||||
|       const dbManager = this.getHomeDBManager(); | ||||
|       const permitStore = this.getPermitStore(); | ||||
|       const notifier = this.getNotifier(); | ||||
|       const loginSystem = await this.resolveLoginSystem(); | ||||
|       const homeUrl = this.getHomeUrl(req).replace(/\/$/, ''); | ||||
|       return new Doom(dbManager, permitStore, notifier, loginSystem, homeUrl); | ||||
|     }; | ||||
| 
 | ||||
|     if (isAffirmative(process.env.GRIST_ACCOUNT_CLOSE)) { | ||||
|       this.app.delete('/api/doom/account', expressWrap(async (req, resp) => { | ||||
|         // Make sure we have a valid user authenticated user here.
 | ||||
|         const userId = getUserId(req); | ||||
| 
 | ||||
|         // Make sure we are deleting the correct user account (and not the anonymous user)
 | ||||
|         const requestedUser = integerParam(req.query.userid, 'userid'); | ||||
|         if (requestedUser !== userId || isAnonymousUser(req))  { | ||||
|           // This probably shouldn't happen, but if user has already deleted the account and tries to do it
 | ||||
|           // once again in a second tab, we might end up here. In that case we are returning false to indicate
 | ||||
|           // that account wasn't deleted.
 | ||||
|           return resp.status(200).json(false); | ||||
|         } | ||||
| 
 | ||||
|         // We are a valid user, we can proceed with the deletion. Note that we will
 | ||||
|         // delete user as an admin, as we need to remove other resources that user
 | ||||
|         // might not have access to.
 | ||||
| 
 | ||||
|         // First make sure user is not a member of any team site. We don't know yet
 | ||||
|         // what to do with orphaned documents.
 | ||||
|         const result = await this._dbManager.getOrgs(userId, null); | ||||
|         this._dbManager.checkQueryResult(result); | ||||
|         const orgs = this._dbManager.unwrapQueryResult(result); | ||||
|         if (orgs.some(org => !org.ownerId)) { | ||||
|           throw new ApiError("Cannot delete account with team sites", 400); | ||||
|         } | ||||
| 
 | ||||
|         // Reuse Doom cli tool for account deletion.
 | ||||
|         const doom = await createDoom(req); | ||||
|         await doom.deleteUser(userId); | ||||
|         return resp.status(200).json(true); | ||||
|       })); | ||||
| 
 | ||||
|       this.app.get('/account-deleted', ...this._logoutMiddleware(), expressWrap((req, resp) => { | ||||
|         return this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'account-deleted'}}); | ||||
|       })); | ||||
| 
 | ||||
|       this.app.delete('/api/doom/org', expressWrap(async (req, resp) => { | ||||
|         const mreq = req as RequestWithLogin; | ||||
|         const orgDomain = getOrgFromRequest(req); | ||||
|         if (!orgDomain) { throw new ApiError("Cannot determine organization", 400); } | ||||
| 
 | ||||
|         if (this._dbManager.isMergedOrg(orgDomain)) { | ||||
|           throw new ApiError("Cannot delete a personal site", 400); | ||||
|         } | ||||
| 
 | ||||
|         // Get org from the server.
 | ||||
|         const query = await this._dbManager.getOrg(getScope(mreq), orgDomain); | ||||
|         const org = this._dbManager.unwrapQueryResult(query); | ||||
| 
 | ||||
|         if (!org || org.ownerId) { | ||||
|           // This shouldn't happen, but just in case test it.
 | ||||
|           throw new ApiError("Cannot delete an org with an owner", 400); | ||||
|         } | ||||
| 
 | ||||
|         if (!org.billingAccount.isManager) { | ||||
|           throw new ApiError("Only billing manager can delete a team site", 403); | ||||
|         } | ||||
| 
 | ||||
|         // Reuse Doom cli tool for org deletion. Note, this removes everything as a super user.
 | ||||
|         const doom = await createDoom(req); | ||||
|         await doom.deleteOrg(org.id); | ||||
|         return resp.status(200).send(); | ||||
|       })); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public addBillingPages() { | ||||
| @ -1557,6 +1619,10 @@ export class FlexServer implements GristServer { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public resolveLoginSystem() { | ||||
|     return process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : (this._getLoginSystem?.() || getLoginSystem()); | ||||
|   } | ||||
| 
 | ||||
|   // Adds endpoints that support imports and exports.
 | ||||
|   private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) { | ||||
|     if (!this._docWorker) { throw new Error("need DocWorker"); } | ||||
| @ -2001,6 +2067,29 @@ export class FlexServer implements GristServer { | ||||
|     }); | ||||
|     return await response.json(); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Creates set of middleware for handling logout requests and clears session. Used in any endpoint | ||||
|    * or a page that needs to log out the user and clear the session. | ||||
|    */ | ||||
|   private _logoutMiddleware() { | ||||
|     const sessionClearMiddleware = expressWrap(async (req, resp, next) => { | ||||
|       const scopedSession = this._sessions.getOrCreateSessionFromRequest(req); | ||||
|       // Clear session so that user needs to log in again at the next request.
 | ||||
|       // SAML logout in theory uses userSession, so clear it AFTER we compute the URL.
 | ||||
|       // Express-session will save these changes.
 | ||||
|       const expressSession = (req as RequestWithLogin).session; | ||||
|       if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; } | ||||
|       await scopedSession.clearScopedSession(req); | ||||
|       // TODO: limit cache clearing to specific user.
 | ||||
|       this._sessions.clearCacheIfNeeded(); | ||||
|       next(); | ||||
|     }); | ||||
|     const pluggedMiddleware = this._loginMiddleware.getLogoutMiddleware ? | ||||
|       this._loginMiddleware.getLogoutMiddleware() : | ||||
|       []; | ||||
|     return [...pluggedMiddleware, sessionClearMiddleware]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -52,6 +52,7 @@ export interface GristServer { | ||||
|   getTag(): string; | ||||
|   sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>; | ||||
|   getAccessTokens(): IAccessTokens; | ||||
|   resolveLoginSystem(): Promise<GristLoginSystem>; | ||||
| } | ||||
| 
 | ||||
| export interface GristLoginSystem { | ||||
| @ -133,6 +134,7 @@ export function createDummyGristServer(): GristServer { | ||||
|     getTag() { return 'tag'; }, | ||||
|     sendAppPage() { return Promise.resolve(); }, | ||||
|     getAccessTokens() { throw new Error('no access tokens'); }, | ||||
|     resolveLoginSystem() { throw new Error('no login system'); }, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -96,7 +96,7 @@ export function makeSimpleCreator(opts: { | ||||
|     Notifier(dbManager, gristConfig) { | ||||
|       return notifier?.create(dbManager, gristConfig) ?? { | ||||
|         get testPending() { return false; }, | ||||
|         deleteUser()      { throw new Error('deleteUser unavailable'); }, | ||||
|         async deleteUser()      { /* do nothing */ }, | ||||
|       }; | ||||
|     }, | ||||
|     ExternalStorage(purpose, extraPrefix) { | ||||
|  | ||||
| @ -84,6 +84,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig | ||||
|     telemetry: server?.getTelemetry().getTelemetryConfig(), | ||||
|     deploymentType: server?.getDeploymentType(), | ||||
|     templateOrg: getTemplateOrg(), | ||||
|     canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE), | ||||
|     ...extra, | ||||
|   }; | ||||
| } | ||||
|  | ||||
							
								
								
									
										11
									
								
								stubs/app/client/ui/DeleteAccountDialog.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								stubs/app/client/ui/DeleteAccountDialog.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| import {FullUser} from 'app/common/UserAPI'; | ||||
| import {Disposable} from 'grainjs'; | ||||
| 
 | ||||
| export class DeleteAccountDialog extends Disposable { | ||||
|   constructor(appModel: FullUser) { | ||||
|     super(); | ||||
|   } | ||||
|   public buildDom() { | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
| @ -953,6 +953,14 @@ export async function sendActions(actions: UserAction[]) { | ||||
|   await waitForServer(); | ||||
| } | ||||
| 
 | ||||
| export async function getDocId() { | ||||
|   const docId = await driver.wait(() => driver.executeScript(` | ||||
|     return window.gristDocPageModel.currentDocId.get() | ||||
|   `)) as string;
 | ||||
|   if (!docId) { throw new Error('could not find doc'); } | ||||
|   return docId; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Confirms dialog for removing rows. In the future, can be used for other dialogs. | ||||
|  */ | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user