diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 3a968a56..7dccd2e9 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -196,13 +196,17 @@ export class AccountWidget extends Disposable { if (deploymentType !== 'saas') { return null; } const {currentValidUser, currentOrg, isTeamSite} = this._appModel; - const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount && - (currentOrg.billingAccount.isManager || currentValidUser?.isSupport)); + const canViewBillingPage = Boolean( + currentOrg && // have accecc to org + currentOrg.billingAccount && // have access to billing account + (currentOrg.billingAccount.isManager // is billing manager + || currentValidUser?.isSupport // or support + || this._appModel.isInstallAdmin())); // or install admin return isTeamSite ? // For links, disabling with just a class is hard; easier to just not make it a link. // TODO weasel menus should support disabling menuItemLink. - (isBillingManager ? + (canViewBillingPage ? menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) : menuItem(() => null, t('Billing Account'), dom.cls('disabled', true)) ) : diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 1db45714..84bee9fa 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -17,7 +17,7 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {GristServer} from 'app/server/lib/GristServer'; import {getTemplateOrg} from 'app/server/lib/gristSettings'; import log from 'app/server/lib/log'; -import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, +import {clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {getCookieDomain} from 'app/server/lib/gristSessions'; @@ -392,7 +392,7 @@ export class ApiServer { // Get user access information regarding an org this._app.get('/api/orgs/:oid/access', expressWrap(async (req, res) => { const org = getOrgKey(req); - const query = await this._withSupportUserAllowedToView( + const query = await this._withPrivilegedViewForUser( org, req, (scope) => this._dbManager.getOrgAccess(scope, org) ); return sendReply(req, res, query); @@ -534,7 +534,7 @@ export class ApiServer { this._app.get('/api/session/access/active', expressWrap(async (req, res) => { const fullUser = await this._getFullUser(req, {includePrefs: true}); const domain = getOrgFromRequest(req); - const org = domain ? (await this._withSupportUserAllowedToView( + const org = domain ? (await this._withPrivilegedViewForUser( domain, req, (scope) => this._dbManager.getOrg(scope, domain) )) : null; const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined; @@ -617,26 +617,32 @@ export class ApiServer { /** - * Run a query, and, if it is denied and the user is the support + * Run a query, and, if it is denied and the user is the support or admin * user, rerun the query with permission to view the current - * org. This is a bit inefficient, but only affects the support + * org. This is a bit inefficient, but only affects the support/admin * user. We wait to add the special permission only if needed, since - * it will in fact override any other access the support user has + * it will in fact override any other access the special user has * been granted, which could reduce their apparent access if that is * part of what is returned by the query. */ - private async _withSupportUserAllowedToView( + private async _withPrivilegedViewForUser( org: string|number, req: express.Request, op: (scope: Scope) => Promise> ): Promise> { const scope = getScope(req); const userId = getUserId(req); const result = await op(scope); - if (result.status === 200 || userId !== this._dbManager.getSupportUserId()) { + + if (result.status === 200) { return result; } - const extendedScope = addPermit(scope, this._dbManager.getSupportUserId(), {org}); - return await op(extendedScope); + + if (userId === this._dbManager.getSupportUserId() || + await this._gristServer.getInstallAdmin()?.isAdminReq(req)) { + const extendedScope: Scope = {...scope, specialPermit: {org}}; + return await op(extendedScope); + } + return result; } private _logInvitedDocUserTelemetryEvents(mreq: RequestWithLogin, delta: PermissionDelta) { diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index 111f370d..09587870 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -1810,12 +1810,13 @@ export class HomeDBManager extends EventEmitter { // // Returns an empty query result with status 200 on success. public async updateBillingAccount( - userId: number, + scopeOrUser: number|Scope, orgKey: string|number, callback: (billingAccount: BillingAccount, transaction: EntityManager) => void|Promise - ): Promise> { + ): Promise> { return await this._connection.transaction(async transaction => { - const billingAccount = await this.getBillingAccount({userId}, orgKey, false, transaction); + const scope = typeof scopeOrUser === 'number' ? {userId: scopeOrUser} : scopeOrUser; + const billingAccount = await this.getBillingAccount(scope, orgKey, false, transaction); const billingAccountCopy = Object.assign({}, billingAccount); await callback(billingAccountCopy, transaction); // Pick out properties that are allowed to be changed, to prevent accidental updating diff --git a/app/server/lib/InstallAdmin.ts b/app/server/lib/InstallAdmin.ts index f7fdba0d..1a17282a 100644 --- a/app/server/lib/InstallAdmin.ts +++ b/app/server/lib/InstallAdmin.ts @@ -18,7 +18,7 @@ export abstract class InstallAdmin { // the Grist installation. This should not fail, only return true or false. public async isAdminReq(req: express.Request): Promise { const user = (req as RequestWithLogin).user; - return user ? this.isAdminUser(user) : false; + return user ? (await this.isAdminUser(user)) : false; } // Returns middleware that fails unless the request includes an authenticated user and this user diff --git a/test/nbrowser/WebhookOverflow.ts b/test/nbrowser/WebhookOverflow.ts index 80ae9857..9b269570 100644 --- a/test/nbrowser/WebhookOverflow.ts +++ b/test/nbrowser/WebhookOverflow.ts @@ -117,7 +117,9 @@ async function openWebhookPageWithoutWaitForServer() { async function waitForWebhookPage() { await driver.findContentWait('button', /Clear Queue/, 3000); // No section, so no easy utility for setting focus. Click on a random cell. - await gu.getDetailCell({col: 'Webhook Id', rowNum: 1}).click(); + await gu.waitToPass(async () => { + await gu.getDetailCell({col: 'Webhook Id', rowNum: 1}).click(); + }); } export async function openAccountMenu() {