mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
60423edc17
Summary: - Reading plans from Stripe, and allowing Stripe to define custom plans. - Storing product features (aka limits) in Stripe, that override those in db. - Adding hierarchical data in Stripe. All features are defined at Product level but can be overwritten on Price levels. - New options for Support user to -- Override product for team site (if he is added as a billing manager) -- Override subscription and customer id for a team site -- Attach an "offer", an custom plan configured in stripe that a team site can use -- Enabling wire transfer for subscription by allowing subscription to be created without a payment method (which is customizable) Test Plan: Updated and new. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4201
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import {AppModel} from 'app/client/models/AppModel';
|
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
|
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
|
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
|
|
import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
|
|
import {createUserImage} from 'app/client/ui/UserImage';
|
|
import * as viewport from 'app/client/ui/viewport';
|
|
import {bigPrimaryButtonLink, primaryButtonLink} from 'app/client/ui2018/buttons';
|
|
import {mediaDeviceNotSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
|
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
|
import * as roles from 'app/common/roles';
|
|
import {Disposable, dom, DomElementArg, styled} from 'grainjs';
|
|
import {cssMenuItem} from 'popweasel';
|
|
import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {getGristConfig} from 'app/common/urlUtils';
|
|
|
|
const t = makeT('AccountWidget');
|
|
|
|
/**
|
|
* Render the user-icon that opens the account menu.
|
|
*
|
|
* When no user is logged in, render "Sign In" and "Sign Up" buttons.
|
|
*
|
|
* When no user is logged in and a template document is open, render a "Use This Template"
|
|
* button.
|
|
*/
|
|
export class AccountWidget extends Disposable {
|
|
constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) {
|
|
super();
|
|
}
|
|
|
|
public buildDom() {
|
|
return cssAccountWidget(
|
|
dom.domComputed(use => {
|
|
const isTemplate = Boolean(this._docPageModel && use(this._docPageModel.isTemplate));
|
|
const user = this._appModel.currentValidUser;
|
|
if (!user && isTemplate) {
|
|
return this._buildUseThisTemplateButton();
|
|
} else if (!user) {
|
|
return this._buildSignInAndSignUpButtons();
|
|
} else {
|
|
return this._buildAccountMenuButton(user);
|
|
}
|
|
}),
|
|
testId('dm-account'),
|
|
);
|
|
}
|
|
|
|
private _buildAccountMenuButton(user: FullUser|null) {
|
|
return cssUserIcon(
|
|
createUserImage(user, 'medium', testId('user-icon')),
|
|
menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}),
|
|
);
|
|
}
|
|
|
|
private _buildSignInAndSignUpButtons() {
|
|
return [
|
|
cssSigninButton(t('Sign In'),
|
|
cssSigninButton.cls('-secondary'),
|
|
dom.on('click', () => { this._docPageModel?.clearUnsavedChanges(); }),
|
|
dom.attr('href', use => {
|
|
// Keep the redirect param of the login URL fresh.
|
|
use(urlState().state);
|
|
return getLoginUrl();
|
|
}),
|
|
testId('user-sign-in'),
|
|
),
|
|
cssSigninButton(t('Sign Up'),
|
|
dom.on('click', () => { this._docPageModel?.clearUnsavedChanges(); }),
|
|
dom.attr('href', use => {
|
|
// Keep the redirect param of the signup URL fresh.
|
|
use(urlState().state);
|
|
return getSignupUrl();
|
|
}),
|
|
testId('user-sign-up'),
|
|
),
|
|
];
|
|
}
|
|
|
|
private _buildUseThisTemplateButton() {
|
|
return cssUseThisTemplateButton(t('Use This Template'),
|
|
dom.attr('href', use => {
|
|
const {doc: srcDocId} = use(urlState().state);
|
|
return getLoginOrSignupUrl({srcDocId});
|
|
}),
|
|
dom.on('click', () => { this._docPageModel?.clearUnsavedChanges(); }),
|
|
testId('dm-account-use-this-template'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the content of the account menu, with a list of available orgs, settings, and sign-out.
|
|
* Note that `user` should NOT be anonymous (none of the items are really relevant).
|
|
*/
|
|
private _makeAccountMenu(user: FullUser|null): DomElementArg[] {
|
|
const currentOrg = this._appModel.currentOrg;
|
|
|
|
// The 'Document Settings' item, when there is an open document.
|
|
const documentSettingsItem = this._docPageModel ? menuItemLink(
|
|
urlState().setLinkUrl({docPage: 'settings'}),
|
|
t("Document Settings"),
|
|
testId('dm-doc-settings')
|
|
) : null;
|
|
|
|
// The item to toggle mobile mode (presence of viewport meta tag).
|
|
const mobileModeToggle = menuItem(viewport.toggleViewport,
|
|
cssSmallDeviceOnly.cls(''), // Only show this toggle on small devices.
|
|
t("Toggle Mobile Mode"),
|
|
cssCheckmark('Tick', dom.show(viewport.viewportEnabled)),
|
|
testId('usermenu-toggle-mobile'),
|
|
);
|
|
|
|
if (!user) {
|
|
return [
|
|
menuItemLink({href: getLoginOrSignupUrl()}, t("Sign in")),
|
|
menuDivider(),
|
|
documentSettingsItem,
|
|
menuItemLink({href: commonUrls.plans}, t("Pricing")),
|
|
mobileModeToggle,
|
|
];
|
|
}
|
|
|
|
const users = this._appModel.topAppModel.users;
|
|
const isExternal = user?.loginMethod === 'External';
|
|
return [
|
|
cssUserInfo(
|
|
createUserImage(user, 'large'),
|
|
cssUserName(dom('span', user.name, testId('usermenu-name')),
|
|
cssEmail(user.email, testId('usermenu-email'))
|
|
)
|
|
),
|
|
menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings"), testId('dm-account-settings')),
|
|
|
|
documentSettingsItem,
|
|
|
|
// Show 'Organization Settings' when on a home page of a valid org.
|
|
(!this._docPageModel && currentOrg && this._appModel.isTeamSite ?
|
|
menuItem(() => manageTeamUsers({org: currentOrg, user, api: this._appModel.api}),
|
|
roles.canEditAccess(currentOrg.access) ? t("Manage Team") : t("Access Details"),
|
|
testId('dm-org-access')) :
|
|
// Don't show on doc pages, or for personal orgs.
|
|
null),
|
|
|
|
this._maybeBuildBillingPageMenuItem(),
|
|
this._maybeBuildActivationPageMenuItem(),
|
|
this._maybeBuildAdminPanelMenuItem(),
|
|
this._maybeBuildSupportGristButton(),
|
|
mobileModeToggle,
|
|
|
|
// TODO Add section ("Here right now") listing icons of other users currently on this doc.
|
|
// (See Invision "Panels" near the bottom.)
|
|
|
|
// In case of a single-org setup, skip all the account-switching UI. We'll also skip the
|
|
// org-listing UI below.
|
|
this._appModel.topAppModel.isSingleOrg || !isFeatureEnabled("multiAccounts") ? [] : [
|
|
menuDivider(),
|
|
menuSubHeader(dom.text((use) => use(users).length > 1 ? t("Switch Accounts") : t("Accounts"))),
|
|
dom.forEach(users, (_user) => {
|
|
if (_user.id === user.id) { return null; }
|
|
return menuItem(() => this._switchAccount(_user),
|
|
cssSmallIconWrap(createUserImage(_user, 'small')),
|
|
cssOtherEmail(_user.email, testId('usermenu-other-email')),
|
|
);
|
|
}),
|
|
isExternal ? null : menuItemLink({href: getLoginUrl()}, t("Add Account"), testId('dm-add-account')),
|
|
],
|
|
|
|
menuItemLink({href: getLogoutUrl()}, t("Sign Out"), testId('dm-log-out')),
|
|
|
|
maybeAddSiteSwitcherSection(this._appModel),
|
|
];
|
|
}
|
|
|
|
// Switch BrowserSession to use the given user for the currently loaded org.
|
|
private async _switchAccount(user: FullUser) {
|
|
await this._appModel.switchUser(user);
|
|
if (urlState().state.get().doc) {
|
|
// Document access level may have changed.
|
|
// If it was not accessible but now is, we currently need to reload the page to get
|
|
// a complete gristConfig for the document from the server.
|
|
// If it was accessible but now is not, it would suffice to reconnect the web socket.
|
|
// For simplicity, just reload from server in either case.
|
|
// TODO: get fancier here to avoid reload.
|
|
window.location.reload(true);
|
|
return;
|
|
}
|
|
this._appModel.topAppModel.initialize();
|
|
}
|
|
|
|
private _maybeBuildBillingPageMenuItem() {
|
|
const {deploymentType} = getGristConfig();
|
|
if (deploymentType !== 'saas') { return null; }
|
|
|
|
const {currentValidUser, currentOrg, isTeamSite} = this._appModel;
|
|
const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount &&
|
|
(currentOrg.billingAccount.isManager || currentValidUser?.isSupport));
|
|
|
|
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 ?
|
|
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) :
|
|
menuItem(() => null, t('Billing Account'), dom.cls('disabled', true))
|
|
) :
|
|
menuItem(() => this._appModel.showUpgradeModal(), t('Upgrade Plan'));
|
|
}
|
|
|
|
private _maybeBuildActivationPageMenuItem() {
|
|
const {deploymentType} = getGristConfig();
|
|
if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) {
|
|
return null;
|
|
}
|
|
|
|
return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'}));
|
|
}
|
|
|
|
private _maybeBuildAdminPanelMenuItem() {
|
|
// Only show Admin Panel item to the installation admins.
|
|
if (this._appModel.currentUser?.isInstallAdmin) {
|
|
return menuItemLink(
|
|
getAdminPanelName(),
|
|
urlState().setLinkUrl({adminPanel: 'admin'}),
|
|
testId('usermenu-admin-panel'),
|
|
);
|
|
}
|
|
}
|
|
|
|
private _maybeBuildSupportGristButton() {
|
|
const {deploymentType} = getGristConfig();
|
|
const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
|
|
if (isEnabled) {
|
|
return menuItemLink(t('Support Grist'), ' 💛',
|
|
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
|
|
testId('usermenu-support-grist'),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const cssAccountWidget = styled('div', `
|
|
display: flex;
|
|
margin-right: 16px;
|
|
white-space: nowrap;
|
|
`);
|
|
|
|
export const cssUserIcon = styled('div', `
|
|
height: 48px;
|
|
width: 48px;
|
|
padding: 8px;
|
|
cursor: pointer;
|
|
`);
|
|
|
|
const cssUserInfo = styled('div', `
|
|
padding: 12px 24px 12px 16px;
|
|
min-width: 200px;
|
|
display: flex;
|
|
align-items: center;
|
|
`);
|
|
|
|
const cssUserName = styled('div', `
|
|
margin-left: 8px;
|
|
font-size: ${vars.mediumFontSize};
|
|
font-weight: ${vars.headerControlTextWeight};
|
|
color: ${theme.text};
|
|
`);
|
|
|
|
const cssEmail = styled('div', `
|
|
margin-top: 4px;
|
|
font-size: ${vars.smallFontSize};
|
|
font-weight: initial;
|
|
color: ${theme.lightText};
|
|
`);
|
|
|
|
const cssSmallIconWrap = styled('div', `
|
|
flex: none;
|
|
margin: -4px 8px -4px 0px;
|
|
`);
|
|
|
|
const cssOtherEmail = styled('div', `
|
|
color: ${theme.lightText};
|
|
.${cssMenuItem.className}-sel & {
|
|
color: ${theme.menuItemSelectedFg};
|
|
}
|
|
`);
|
|
|
|
const cssCheckmark = styled(icon, `
|
|
flex: none;
|
|
margin-left: 16px;
|
|
--icon-color: ${theme.accentIcon};
|
|
`);
|
|
|
|
// Note that this css class hides the item when the device width is small (not based on viewport
|
|
// width, which may be larger). This only appropriate for when to enable the "mobile mode" toggle.
|
|
const cssSmallDeviceOnly = styled(menuItem, `
|
|
@media ${mediaDeviceNotSmall} {
|
|
& {
|
|
display: none;
|
|
}
|
|
}
|
|
`);
|
|
|
|
const cssSigninButton = styled(bigPrimaryButtonLink, `
|
|
display: flex;
|
|
align-items: center;
|
|
font-weight: 700;
|
|
min-height: unset;
|
|
height: 36px;
|
|
padding: 8px 16px 8px 16px;
|
|
font-size: ${vars.mediumFontSize};
|
|
|
|
&-secondary, &-secondary:hover {
|
|
background-color: transparent;
|
|
border-color: transparent;
|
|
color: ${theme.text};
|
|
}
|
|
`);
|
|
|
|
const cssUseThisTemplateButton = styled(primaryButtonLink, `
|
|
margin: 8px;
|
|
`);
|