mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user