diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 0ff3c79d..b7026e89 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -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; diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts index 187ac7c3..5eaafeb9 100644 --- a/app/client/ui/AccountPage.ts +++ b/app/client/ui/AccountPage.ts @@ -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'), ))); } diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index 06f09400..6944e752 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -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. */ diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 0e1fcf99..78715512 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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), diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index 0a78b7c8..1a32ad9d 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -341,6 +341,8 @@ export interface ConfirmModalOptions { hideCancel?: boolean; extraButtons?: DomContents; modalOptions?: IModalOptions; + saveDisabled?: Observable; + width?: ModalWidth; } /** @@ -352,7 +354,7 @@ export function confirmModal( title: DomElementArg, btnText: DomElementArg, onConfirm: () => Promise, - {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); } diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index b581cacb..6dfab272 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -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", diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index b45cc571..463c81cd 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -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; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 4617c055..057361b7 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -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; + /** + * 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; + /** + * 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; } /** @@ -813,6 +827,14 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { body: JSON.stringify({name})}); } + public async closeAccount(userId: number): Promise { + 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 { 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, diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 053f53a1..0647a919 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -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( diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index af36cfd4..a428a469 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -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', diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index 0e91f918..b891be0c 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -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', diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 2db226d1..26f91e2d 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -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); diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 5d0f1e03..ccbfb5fe 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -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 { diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 8ad643c9..2f7674f9 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -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]; + } } /** diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 374b27c3..7ba31c38 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -52,6 +52,7 @@ export interface GristServer { getTag(): string; sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise; getAccessTokens(): IAccessTokens; + resolveLoginSystem(): Promise; } 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'); }, }; } diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index 73b76e5c..fc7a8d2c 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -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) { diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 304c0422..cf66c59a 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -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, }; } diff --git a/stubs/app/client/ui/DeleteAccountDialog.ts b/stubs/app/client/ui/DeleteAccountDialog.ts new file mode 100644 index 00000000..cdfe0d1b --- /dev/null +++ b/stubs/app/client/ui/DeleteAccountDialog.ts @@ -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; + } +} diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index ac503075..0dd2facc 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -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. */