(core) Admin Panel and InstallAdmin class to identify installation admins.

Summary:
- Add InstallAdmin class to identify users who can manage Grist installation.

  This is overridable by different Grist flavors (e.g. different in SaaS).
  It generalizes previous logic used to decide who can control Activation
  settings (e.g. enable telemetry).

- Implement a basic Admin Panel at /admin, and move items previously in the
  "Support Grist" page into the "Support Grist" section of the Admin Panel.

- Replace "Support Grist" menu items with "Admin Panel" and show only to admins.

- Add "Support Grist" links to Github sponsorship to user-account menu.

- Add "Support Grist" button to top-bar, which
  - for admins, replaces the previous "Contribute" button and reopens the "Support Grist / opt-in to telemetry" nudge (unchanged)
  - for everyone else, links to Github sponsorship
  - in either case, user can dismiss it.

Test Plan: Shuffled some test cases between Support Grist and the new Admin Panel, and added some new cases.

Reviewers: jarek, paulfitz

Reviewed By: jarek, paulfitz

Differential Revision: https://phab.getgrist.com/D4194
This commit is contained in:
Dmitry S
2024-03-23 13:11:06 -04:00
parent 0c05f4cdc4
commit e380fcfa90
32 changed files with 875 additions and 524 deletions

View File

@@ -25,8 +25,9 @@ import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs,
ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils';
import {ExtendedUser} from 'app/common/UserAPI';
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs';
import {getUserPrefObs, getUserPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
const t = makeT('AppModel');
@@ -40,7 +41,7 @@ export type PageType =
| "billing"
| "welcome"
| "account"
| "support"
| "admin"
| "activation";
const G = getBrowserGlobals('document', 'window');
@@ -99,8 +100,8 @@ export interface AppModel {
topAppModel: TopAppModel;
api: UserAPI;
currentUser: FullUser|null;
currentValidUser: FullUser|null; // Like currentUser, but null when anonymous
currentUser: ExtendedUser|null;
currentValidUser: ExtendedUser|null; // Like currentUser, but null when anonymous
currentOrg: Organization|null; // null if no access to currentSubdomain
currentOrgName: string; // Our best guess for human-friendly name.
@@ -141,8 +142,8 @@ export interface AppModel {
isSupport(): boolean; // If user is a Support user
isOwner(): boolean; // If user is an owner of this org
isOwnerOrEditor(): boolean; // If user is an owner or editor of this org
/** Creates an computed observable to dismiss a popup or check if it was dismissed */
dismissedPopup(name: DismissedPopup): Observable<boolean>;
isInstallAdmin(): boolean; // Is user an admin of this installation
dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not.
switchUser(user: FullUser, org?: string): Promise<void>;
}
@@ -283,7 +284,7 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly api: UserAPI = this.topAppModel.api;
// Compute currentValidUser, turning anonymous into null.
public readonly currentValidUser: FullUser|null =
public readonly currentValidUser: ExtendedUser|null =
this.currentUser && !this.currentUser.anonymous ? this.currentUser : null;
// Figure out the org name, or blank if details are unavailable.
@@ -324,8 +325,8 @@ export class AppModelImpl extends Disposable implements AppModel {
return 'welcome';
} else if (state.account) {
return 'account';
} else if (state.supportGrist) {
return 'support';
} else if (state.adminPanel) {
return 'admin';
} else if (state.activation) {
return 'activation';
} else {
@@ -340,7 +341,7 @@ export class AppModelImpl extends Disposable implements AppModel {
state.billing === 'scheduled' ||
Boolean(state.account) ||
Boolean(state.activation) ||
Boolean(state.supportGrist)
Boolean(state.adminPanel)
);
});
@@ -353,7 +354,7 @@ export class AppModelImpl extends Disposable implements AppModel {
constructor(
public readonly topAppModel: TopAppModel,
public readonly currentUser: FullUser|null,
public readonly currentUser: ExtendedUser|null,
public readonly currentOrg: Organization|null,
public readonly orgError?: OrgError,
) {
@@ -419,6 +420,10 @@ export class AppModelImpl extends Disposable implements AppModel {
return Boolean(this.currentOrg && isOwnerOrEditor(this.currentOrg));
}
public isInstallAdmin(): boolean {
return Boolean(this.currentUser?.isInstallAdmin);
}
/**
* Fetch and update the current org's usage.
*/
@@ -435,16 +440,8 @@ export class AppModelImpl extends Disposable implements AppModel {
}
}
public dismissedPopup(name: DismissedPopup): Computed<boolean> {
const computed = Computed.create(null, use => use(this.dismissedPopups).includes(name));
computed.onWrite(value => {
if (value) {
markAsSeen(this.dismissedPopups, name);
} else {
markAsUnSeen(this.dismissedPopups, name);
}
});
return computed;
public dismissPopup(name: DismissedPopup, isSeen: boolean): void {
markAsSeen(this.dismissedPopups, name, isSeen);
}
public async switchUser(user: FullUser, org?: string) {

View File

@@ -89,12 +89,16 @@ export const {getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs} = makePr
// For preferences that store a list of items (such as seen docTours), this helper updates the
// preference to add itemId to it (e.g. to avoid auto-starting the docTour again in the future).
// prefKey is used only to log a more informative warning on error.
export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T) {
export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T, isSeen = true) {
const seenIds = seenIdsObs.get() || [];
try {
if (!seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.add(itemId);
if (isSeen) {
seen.add(itemId);
} else {
seen.delete(itemId);
}
seenIdsObs.set([...seen].sort());
}
} catch (e) {
@@ -104,17 +108,3 @@ export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T
console.warn("Failed to save preference in markAsSeen", e);
}
}
export function markAsUnSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T) {
const seenIds = seenIdsObs.get() || [];
try {
if (seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.delete(itemId);
seenIdsObs.set([...seen].sort());
}
} catch (e) {
// tslint:disable-next-line:no-console
console.warn("Failed to save preference in markAsUnSeen", e);
}
}

View File

@@ -171,7 +171,7 @@ export class UrlStateImpl {
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) ||
newState.account || newState.billing || newState.activation || newState.welcome ||
newState.supportGrist) ?
newState.adminPanel) ?
(prevState.org ? {org: prevState.org} : {}) :
prevState;
return {...keepState, ...newState};
@@ -205,10 +205,10 @@ export class UrlStateImpl {
const signupReload = [prevState.login, newState.login].includes('signup')
&& prevState.login !== newState.login;
// Reload when moving to/from the support Grist page.
const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist);
const adminPanelReload = Boolean(prevState.adminPanel) !== Boolean(newState.adminPanel);
return Boolean(orgReload || accountReload || billingReload || activationReload ||
gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload ||
supportGristReload);
adminPanelReload);
}
/**