mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +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');
|
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.
|
// Get URL for the signin page.
|
||||||
export function getLoginOrSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {
|
export function getLoginOrSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {
|
||||||
return _getLoginLogoutUrl('signin', options);
|
return _getLoginLogoutUrl('signin', options);
|
||||||
@ -96,19 +101,21 @@ export function getWelcomeHomeUrl() {
|
|||||||
return _buildUrl('welcome/home').href;
|
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
|
// 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.
|
// This is a good URL to use for a post-login redirect.
|
||||||
function _getCurrentUrl(): string {
|
function _getCurrentUrl(): string {
|
||||||
const {hash, pathname, search} = new URL(window.location.href);
|
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;
|
return parseFirstUrlPart('o', pathname).path + search + hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the URL for the given login page.
|
// Returns the URL for the given login page.
|
||||||
function _getLoginLogoutUrl(
|
function _getLoginLogoutUrl(
|
||||||
page: 'login'|'logout'|'signin'|'signup',
|
page: 'login'|'logout'|'signin'|'signup'|'account-deleted',
|
||||||
options: GetLoginOrSignupUrlOptions = {}
|
options: GetLoginOrSignupUrlOptions = {}
|
||||||
): string {
|
): string {
|
||||||
const {srcDocId, nextUrl = _getCurrentUrl()} = options;
|
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 {AppModel, reportError} from 'app/client/models/AppModel';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import * as css from 'app/client/ui/AccountPageCss';
|
import * as css from 'app/client/ui/AccountPageCss';
|
||||||
import {ApiKey} from 'app/client/ui/ApiKey';
|
import {ApiKey} from 'app/client/ui/ApiKey';
|
||||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||||
import {buildChangePasswordDialog} from 'app/client/ui/ChangePasswordDialog';
|
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 {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
import {MFAConfig} from 'app/client/ui/MFAConfig';
|
import {MFAConfig} from 'app/client/ui/MFAConfig';
|
||||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
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 {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
import {select} from 'app/client/ui2018/menus';
|
import {select} from 'app/client/ui2018/menus';
|
||||||
|
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
||||||
import {getGristConfig} from 'app/common/urlUtils';
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
import {FullUser} from 'app/common/UserAPI';
|
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';
|
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled, subscribe} from 'grainjs';
|
||||||
|
|
||||||
const testId = makeTestId('test-account-page-');
|
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.
|
inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px.
|
||||||
})
|
})
|
||||||
)),
|
)),
|
||||||
),
|
!getGristConfig().canCloseAccount ? null : [
|
||||||
|
dom.create(DeleteAccountDialog, user),
|
||||||
|
],
|
||||||
|
),
|
||||||
testId('body'),
|
testId('body'),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {AppModel} from 'app/client/models/AppModel';
|
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 {AppHeader} from 'app/client/ui/AppHeader';
|
||||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||||
@ -21,7 +21,8 @@ export function createErrPage(appModel: AppModel) {
|
|||||||
return gristConfig.errPage === 'signed-out' ? createSignedOutPage(appModel) :
|
return gristConfig.errPage === 'signed-out' ? createSignedOutPage(appModel) :
|
||||||
gristConfig.errPage === 'not-found' ? createNotFoundPage(appModel, message) :
|
gristConfig.errPage === 'not-found' ? createNotFoundPage(appModel, message) :
|
||||||
gristConfig.errPage === 'access-denied' ? createForbiddenPage(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.
|
* Creates a "Page not found" page.
|
||||||
*/
|
*/
|
||||||
|
@ -168,6 +168,7 @@ export const theme = {
|
|||||||
lightText: new CustomProp('theme-text-light', undefined, colors.slate),
|
lightText: new CustomProp('theme-text-light', undefined, colors.slate),
|
||||||
darkText: new CustomProp('theme-text-dark', undefined, 'black'),
|
darkText: new CustomProp('theme-text-dark', undefined, 'black'),
|
||||||
errorText: new CustomProp('theme-text-error', undefined, colors.error),
|
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'),
|
dangerText: new CustomProp('theme-text-danger', undefined, '#FFA500'),
|
||||||
disabledText: new CustomProp('theme-text-disabled', undefined, colors.slate),
|
disabledText: new CustomProp('theme-text-disabled', undefined, colors.slate),
|
||||||
|
|
||||||
|
@ -341,6 +341,8 @@ export interface ConfirmModalOptions {
|
|||||||
hideCancel?: boolean;
|
hideCancel?: boolean;
|
||||||
extraButtons?: DomContents;
|
extraButtons?: DomContents;
|
||||||
modalOptions?: IModalOptions;
|
modalOptions?: IModalOptions;
|
||||||
|
saveDisabled?: Observable<boolean>;
|
||||||
|
width?: ModalWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -352,7 +354,7 @@ export function confirmModal(
|
|||||||
title: DomElementArg,
|
title: DomElementArg,
|
||||||
btnText: DomElementArg,
|
btnText: DomElementArg,
|
||||||
onConfirm: () => Promise<void>,
|
onConfirm: () => Promise<void>,
|
||||||
{explanation, hideCancel, extraButtons, modalOptions}: ConfirmModalOptions = {},
|
{explanation, hideCancel, extraButtons, modalOptions, saveDisabled, width}: ConfirmModalOptions = {},
|
||||||
): void {
|
): void {
|
||||||
return saveModal((ctl, owner): ISaveModalOptions => ({
|
return saveModal((ctl, owner): ISaveModalOptions => ({
|
||||||
title,
|
title,
|
||||||
@ -360,8 +362,9 @@ export function confirmModal(
|
|||||||
saveLabel: btnText,
|
saveLabel: btnText,
|
||||||
saveFunc: onConfirm,
|
saveFunc: onConfirm,
|
||||||
hideCancel,
|
hideCancel,
|
||||||
width: 'normal',
|
width: width ?? 'normal',
|
||||||
extraButtons,
|
extraButtons,
|
||||||
|
saveDisabled,
|
||||||
}), modalOptions);
|
}), modalOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ export const ThemeColors = t.iface([], {
|
|||||||
"text-light": "string",
|
"text-light": "string",
|
||||||
"text-dark": "string",
|
"text-dark": "string",
|
||||||
"text-error": "string",
|
"text-error": "string",
|
||||||
|
"text-error-hover": "string",
|
||||||
"text-danger": "string",
|
"text-danger": "string",
|
||||||
"text-disabled": "string",
|
"text-disabled": "string",
|
||||||
"page-bg": "string",
|
"page-bg": "string",
|
||||||
|
@ -27,6 +27,7 @@ export interface ThemeColors {
|
|||||||
'text-light': string;
|
'text-light': string;
|
||||||
'text-dark': string;
|
'text-dark': string;
|
||||||
'text-error': string;
|
'text-error': string;
|
||||||
|
'text-error-hover': string;
|
||||||
'text-danger': string;
|
'text-danger': string;
|
||||||
'text-disabled': string;
|
'text-disabled': string;
|
||||||
|
|
||||||
|
@ -405,6 +405,20 @@ export interface UserAPI {
|
|||||||
getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps.
|
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.
|
forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
|
||||||
getWidgets(): Promise<ICustomWidget[]>;
|
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})});
|
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; }
|
public getBaseUrl(): string { return this._url; }
|
||||||
|
|
||||||
// Recomputes the URL on every call to pick up changes in the URL when switching orgs.
|
// 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> {
|
public async uploadAttachment(value: string | Blob, filename?: string): Promise<number> {
|
||||||
const formData = this.newFormData();
|
const formData = this.newFormData();
|
||||||
formData.append('upload', value, filename);
|
formData.append('upload', value as Blob, filename);
|
||||||
const response = await this.requestAxios(`${this._url}/attachments`, {
|
const response = await this.requestAxios(`${this._url}/attachments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: formData,
|
data: formData,
|
||||||
|
@ -694,6 +694,9 @@ export interface GristLoadConfig {
|
|||||||
|
|
||||||
// The org containing public templates and tutorials.
|
// The org containing public templates and tutorials.
|
||||||
templateOrg?: string|null;
|
templateOrg?: string|null;
|
||||||
|
|
||||||
|
// Whether to show the "Delete Account" button in the account page.
|
||||||
|
canCloseAccount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Features = StringUnion(
|
export const Features = StringUnion(
|
||||||
|
@ -5,7 +5,8 @@ export const GristDark: ThemeColors = {
|
|||||||
'text': '#EFEFEF',
|
'text': '#EFEFEF',
|
||||||
'text-light': '#A4A4B1',
|
'text-light': '#A4A4B1',
|
||||||
'text-dark': '#FFFFFF',
|
'text-dark': '#FFFFFF',
|
||||||
'text-error': '#FF6666',
|
'text-error': '#E63946',
|
||||||
|
'text-error-hover': '#FF5C5C',
|
||||||
'text-danger': '#FFA500',
|
'text-danger': '#FFA500',
|
||||||
'text-disabled': '#A4A4B1',
|
'text-disabled': '#A4A4B1',
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ export const GristLight: ThemeColors = {
|
|||||||
'text-light': '#929299',
|
'text-light': '#929299',
|
||||||
'text-dark': 'black',
|
'text-dark': 'black',
|
||||||
'text-error': '#D0021B',
|
'text-error': '#D0021B',
|
||||||
|
'text-error-hover': '#A10000',
|
||||||
'text-danger': '#FFA500',
|
'text-danger': '#FFA500',
|
||||||
'text-disabled': '#929299',
|
'text-disabled': '#929299',
|
||||||
|
|
||||||
|
@ -177,7 +177,7 @@ export class ApiServer {
|
|||||||
return sendReply(req, res, query);
|
return sendReply(req, res, query);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// // DELETE /api/orgs/:oid
|
// DELETE /api/orgs/:oid
|
||||||
// Delete the specified org and all included workspaces and docs.
|
// Delete the specified org and all included workspaces and docs.
|
||||||
this._app.delete('/api/orgs/:oid', expressWrap(async (req, res) => {
|
this._app.delete('/api/orgs/:oid', expressWrap(async (req, res) => {
|
||||||
const org = getOrgKey(req);
|
const org = getOrgKey(req);
|
||||||
|
@ -837,7 +837,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
await this._connection.transaction(async manager => {
|
await this._connection.transaction(async manager => {
|
||||||
const user = await manager.findOne(User, {where: {id: userIdToDelete},
|
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 (!user) { throw new ApiError('user not found', 404); }
|
||||||
if (name) {
|
if (name) {
|
||||||
if (user.name !== name) {
|
if (user.name !== name) {
|
||||||
@ -853,6 +853,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
.from('group_users')
|
.from('group_users')
|
||||||
.where('user_id = :userId', {userId: userIdToDelete})
|
.where('user_id = :userId', {userId: userIdToDelete})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
await manager.delete(User, userIdToDelete);
|
await manager.delete(User, userIdToDelete);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
@ -5,7 +5,7 @@ import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
|
|||||||
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
|
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
|
||||||
sanitizePathTail} from 'app/common/gristUrls';
|
sanitizePathTail} from 'app/common/gristUrls';
|
||||||
import {getOrgUrlInfo} 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 {InstallProperties} from 'app/common/InstallAPI';
|
||||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
import {tbind} from 'app/common/tbind';
|
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 {Activations} from 'app/gen-server/lib/Activations';
|
||||||
import {DocApiForwarder} from 'app/gen-server/lib/DocApiForwarder';
|
import {DocApiForwarder} from 'app/gen-server/lib/DocApiForwarder';
|
||||||
import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
|
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 {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
|
import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
|
||||||
import {Usage} from 'app/gen-server/lib/Usage';
|
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 {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
|
||||||
import {PluginManager} from 'app/server/lib/PluginManager';
|
import {PluginManager} from 'app/server/lib/PluginManager';
|
||||||
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
|
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,
|
RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET,
|
||||||
trustOrigin} from 'app/server/lib/requestUtils';
|
trustOrigin} from 'app/server/lib/requestUtils';
|
||||||
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
|
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
|
// 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.
|
// could create a mock SAML identity provider for testing this using the SAML flow.
|
||||||
const loginSystem = await (process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() :
|
const loginSystem = await this.resolveLoginSystem();
|
||||||
(this._getLoginSystem?.() || getLoginSystem()));
|
|
||||||
this._loginMiddleware = await loginSystem.getMiddleware(this);
|
this._loginMiddleware = await loginSystem.getMiddleware(this);
|
||||||
this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware);
|
this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware);
|
||||||
this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, 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.app.get('/logout', ...this._logoutMiddleware(), expressWrap(async (req, resp) => {
|
||||||
this._loginMiddleware.getLogoutMiddleware() :
|
|
||||||
[];
|
|
||||||
this.app.get('/logout', ...logoutMiddleware, expressWrap(async (req, resp) => {
|
|
||||||
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
|
|
||||||
const signedOutUrl = new URL(getOrgUrl(req) + 'signed-out');
|
const signedOutUrl = new URL(getOrgUrl(req) + 'signed-out');
|
||||||
const redirectUrl = await this._getLogoutRedirectUrl(req, signedOutUrl);
|
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);
|
resp.redirect(redirectUrl);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -1220,6 +1207,81 @@ export class FlexServer implements GristServer {
|
|||||||
this.app.get('/account', ...middleware, expressWrap(async (req, resp) => {
|
this.app.get('/account', ...middleware, expressWrap(async (req, resp) => {
|
||||||
return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
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() {
|
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.
|
// Adds endpoints that support imports and exports.
|
||||||
private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) {
|
private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) {
|
||||||
if (!this._docWorker) { throw new Error("need DocWorker"); }
|
if (!this._docWorker) { throw new Error("need DocWorker"); }
|
||||||
@ -2001,6 +2067,29 @@ export class FlexServer implements GristServer {
|
|||||||
});
|
});
|
||||||
return await response.json();
|
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;
|
getTag(): string;
|
||||||
sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;
|
sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;
|
||||||
getAccessTokens(): IAccessTokens;
|
getAccessTokens(): IAccessTokens;
|
||||||
|
resolveLoginSystem(): Promise<GristLoginSystem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GristLoginSystem {
|
export interface GristLoginSystem {
|
||||||
@ -133,6 +134,7 @@ export function createDummyGristServer(): GristServer {
|
|||||||
getTag() { return 'tag'; },
|
getTag() { return 'tag'; },
|
||||||
sendAppPage() { return Promise.resolve(); },
|
sendAppPage() { return Promise.resolve(); },
|
||||||
getAccessTokens() { throw new Error('no access tokens'); },
|
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) {
|
Notifier(dbManager, gristConfig) {
|
||||||
return notifier?.create(dbManager, gristConfig) ?? {
|
return notifier?.create(dbManager, gristConfig) ?? {
|
||||||
get testPending() { return false; },
|
get testPending() { return false; },
|
||||||
deleteUser() { throw new Error('deleteUser unavailable'); },
|
async deleteUser() { /* do nothing */ },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
ExternalStorage(purpose, extraPrefix) {
|
ExternalStorage(purpose, extraPrefix) {
|
||||||
|
@ -84,6 +84,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
|
|||||||
telemetry: server?.getTelemetry().getTelemetryConfig(),
|
telemetry: server?.getTelemetry().getTelemetryConfig(),
|
||||||
deploymentType: server?.getDeploymentType(),
|
deploymentType: server?.getDeploymentType(),
|
||||||
templateOrg: getTemplateOrg(),
|
templateOrg: getTemplateOrg(),
|
||||||
|
canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),
|
||||||
...extra,
|
...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();
|
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.
|
* Confirms dialog for removing rows. In the future, can be used for other dialogs.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user