Skip to content

Commit

Permalink
Merge pull request #1145 from jetstreamapp/feat/billing-updates
Browse files Browse the repository at this point in the history
Billing updates
  • Loading branch information
paustint authored Jan 24, 2025
2 parents cfc2f11 + 31cf3b2 commit cfd2be7
Show file tree
Hide file tree
Showing 34 changed files with 292 additions and 115 deletions.
30 changes: 25 additions & 5 deletions apps/api/src/app/controllers/web-extension.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ENV } from '@jetstream/api-config';
import { getCookieConfig, InvalidSession, MissingEntitlement } from '@jetstream/auth/server';
import { getErrorMessageAndStackObj } from '@jetstream/shared/utils';
import { UserProfileUi } from '@jetstream/types';
import { serialize } from 'cookie';
import { addDays, fromUnixTime, isAfter } from 'date-fns';
import { z } from 'zod';
import * as userDbService from '../db/user.db';
import { checkUserEntitlement } from '../db/user.db';
import * as webExtDb from '../db/web-extension.db';
import * as webExtensionService from '../services/auth-web-extension.service';
Expand Down Expand Up @@ -87,11 +89,29 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({

let accessToken = '';

const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id }).then(
(user): UserProfileUi => ({
id: user.id,
userId: user.userId,
email: user.email,
name: user.name,
emailVerified: user.emailVerified,
picture: user.picture,
preferences: { skipFrontdoorLogin: false },
billingAccount: user.billingAccount,
entitlements: {
chromeExtension: true,
recordSync: user.entitlements?.recordSync ?? false,
googleDrive: user.entitlements?.googleDrive ?? false,
},
subscriptions: [],
})
);
let storedRefreshToken = await webExtDb.findByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH });

// if token is expiring within 7 days, issue a new token
if (!storedRefreshToken || isAfter(storedRefreshToken.expiresAt, addDays(new Date(), -webExtensionService.TOKEN_AUTO_REFRESH_DAYS))) {
accessToken = await webExtensionService.issueAccessToken({ userId: user.id, email: user.email, name: user.name });
accessToken = await webExtensionService.issueAccessToken(userProfile);
storedRefreshToken = await webExtDb.create(user.id, {
type: 'AUTH_TOKEN',
token: accessToken,
Expand All @@ -101,7 +121,7 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({
expiresAt: fromUnixTime(webExtensionService.decodeToken(accessToken).exp),
});
} else {
accessToken = await webExtensionService.issueAccessToken({ userId: user.id, email: user.email, name: user.name });
accessToken = await webExtensionService.issueAccessToken(userProfile);
}

sendJson(res, { accessToken });
Expand All @@ -125,9 +145,9 @@ const logout = createRoute(routeDefinition.logout.validators, async ({ body }, r
try {
const { accessToken, deviceId } = body;
// This validates the token against the database record
const { userId } = await webExtensionService.verifyToken({ token: accessToken, deviceId });
webExtDb.deleteByUserIdAndDeviceId({ userId, deviceId, type: webExtDb.TOKEN_TYPE_AUTH });
res.log.info({ userId, deviceId }, 'User logged out of chrome extension');
const { id } = await webExtensionService.verifyToken({ token: accessToken, deviceId });
webExtDb.deleteByUserIdAndDeviceId({ userId: id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH });
res.log.info({ userId: id, deviceId }, 'User logged out of chrome extension');

sendJson(res, { success: true });
} catch (ex) {
Expand Down
24 changes: 10 additions & 14 deletions apps/api/src/app/services/auth-web-extension.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ENV } from '@jetstream/api-config';
import { InvalidAccessToken } from '@jetstream/auth/server';
import { UserProfileUi } from '@jetstream/types';
import jwt from 'fast-jwt';
import * as webExtDb from '../db/web-extension.db';

Expand All @@ -9,13 +10,8 @@ const ISSUER = 'https://getjetstream.app';
export const TOKEN_AUTO_REFRESH_DAYS = 7;
const TOKEN_EXPIRATION = 60 * 60 * 24 * 90 * 1000; // 90 days

interface JwtPayload {
userId: string;
name: string;
email: string;
}

interface JwtDecodedPayload extends JwtPayload {
interface JwtDecodedPayload {
userProfile: UserProfileUi;
aud: typeof AUDIENCE;
iss: typeof ISSUER;
sub: string;
Expand Down Expand Up @@ -45,13 +41,13 @@ function prepareJwtFns(userId: string, durationMs: number = TOKEN_EXPIRATION) {
};
}

async function generateJwt({ payload, durationMs }: { payload: JwtPayload; durationMs: number }) {
const { jwtSigner } = prepareJwtFns(payload.userId, durationMs);
const token = await jwtSigner(payload);
async function generateJwt({ payload, durationMs }: { payload: UserProfileUi; durationMs: number }) {
const { jwtSigner } = prepareJwtFns(payload.id, durationMs);
const token = await jwtSigner({ userProfile: payload });
return token;
}

export async function issueAccessToken(payload: JwtPayload) {
export async function issueAccessToken(payload: UserProfileUi) {
return await generateJwt({ payload, durationMs: TOKEN_EXPIRATION });
}

Expand All @@ -60,19 +56,19 @@ export function decodeToken(token: string): JwtDecodedPayload {
return decoder(token) as JwtDecodedPayload;
}

export async function verifyToken({ token, deviceId }: { token: string; deviceId: string }): Promise<JwtPayload> {
export async function verifyToken({ token, deviceId }: { token: string; deviceId: string }): Promise<UserProfileUi> {
const decoder = jwt.createDecoder();
const decodedPayload = decoder(token) as JwtDecodedPayload;

const userAccessToken = await webExtDb.findByAccessTokenAndDeviceId({ deviceId, token, type: webExtDb.TOKEN_TYPE_AUTH });
if (!userAccessToken) {
throw new InvalidAccessToken('Access token is invalid for device');
} else if (decodedPayload.userId !== userAccessToken.userId) {
} else if (decodedPayload?.userProfile?.id !== userAccessToken.userId) {
throw new InvalidAccessToken('Access token is invalid for user');
} else if (!userAccessToken.user.entitlements?.chromeExtension) {
throw new InvalidAccessToken('Chrome extension is not enabled');
}

const { jwtVerifier } = prepareJwtFns(userAccessToken.userId, TOKEN_EXPIRATION);
return (await jwtVerifier(token)) as JwtPayload;
return (await jwtVerifier(token)) as UserProfileUi;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 83 additions & 0 deletions apps/docs/docs/chrome-extension/chrome-extension.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
id: chrome-extension
title: Chrome Extension
description: The Jetstream Chrome Extension allows you to quickly access your Salesforce orgs and metadata from anywhere on the web.
keywords: [salesforce, salesforce admin, salesforce developer, salesforce automation, salesforce workbench, chrome extension]
sidebar_label: Chrome Extension
slug: /chrome-extension
---

import styles from './chrome-extension.module.css';

export function PopupLogin() {
return (
<div className={styles.container}>
<img src={require('./chrome-extension-login.png').default} alt="Chrome Extension popup prior to login" />
<img src={require('./chrome-extension-logged-in.png').default} alt="Chrome Extension popup after login" />
</div>
);
}

export function PagePopupDisplay() {
return (
<div className={styles.container}>
<img src={require('./chrome-extension-page-button.png').default} alt="Chrome Extension page button" />
<img src={require('./chrome-extension-page-menu-no-record.png').default} alt="Chrome Extension page popup" />
<img src={require('./chrome-extension-page-menu-record.png').default} alt="Chrome Extension page record page popup" />
</div>
);
}

export function PopupOptions() {
return (
<div className={styles.container}>
<img src={require('./chrome-extension-popup-options-1.png').default} alt="Chrome Extension options first" />
<img src={require('./chrome-extension-popup-options-2.png').default} alt="Chrome Extension options second" />
</div>
);
}

:::note

The Chrome Extension requires a Professional plan or higher.

:::

:::info

The Chrome Extension is in beta. If you have any feedback or suggestions, please let us know!

:::

The Jetstream Chrome Extension allows you to quickly access your Salesforce orgs and metadata on any Salesforce environment without having to connect the org in Jetstream.

When you use the Chrome Extension, none of your data is processed or stored by Jetstream. The Chrome Extension is a standalone tool that only interacts with the Salesforce API directly from your browser.
Aside from logging in, there is not any communication with the Jetstream server.

## Installation

Install the Chrome Extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/jetstream/nhahnhcpbhlkmpkdgbbadffnhblhlomm).

Once installed, you may want to consider pinning the extension to your browser toolbar for easy access.

### Logging In

After installing the Chrome Extension, click on the Jetstream icon in your browser toolbar to open the popup to login.

Once you are logged in, the chrome extension will indicate that you are logged in and give you some configuration options.

<PopupLogin />

### Usage

Visit any Salesforce page and you will see a Jetstream Icon conveniently located in the right side of the page. Click on the icon to open the Jetstream Chrome Extension.

<PagePopupDisplay />

### Options

You can temporarily disable the Salesforce page floating button by clicking on the Jetstream icon in the browser toolbar and toggling the "Jetstream Page Button" to the disabled position.

You can also adust the floating button position, size, and opacity byt choosing the "Show Configuration Options button".

<PopupOptions />
9 changes: 9 additions & 0 deletions apps/docs/docs/chrome-extension/chrome-extension.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.container {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}

.container > img {
width: 300px;
}
22 changes: 21 additions & 1 deletion apps/jetstream-e2e/src/tests/authentication/login1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,29 @@ test.describe.configure({ mode: 'parallel' });
test.use({ storageState: { cookies: [], origins: [] } });

test.describe('Login 1', () => {
test('Sign up, login, disable 2fa, login again', async ({ page, authenticationPage, playwrightPage }) => {
test('Sign up, login, disable 2fa, login again', async ({ page, authenticationPage, request, playwrightPage }) => {
const { email, password, name } = await test.step('Sign up and verify email', async () => {
const { email, password, name } = await authenticationPage.signUpAndVerifyEmail();

// Verify user profile
const profileResponse = await page.request.get('/api/me', { headers: { Accept: 'application/json' }, failOnStatusCode: true });
expect(profileResponse.ok()).toBeTruthy();
const userProfile = await profileResponse.json().then(({ data }) => data);
expect(userProfile).toBeTruthy();
expect(userProfile.id).toBeTruthy();
expect(userProfile.name).toContain('Test User');
expect(userProfile.email).toContain('test-');
expect(userProfile.emailVerified).toEqual(true);
expect(userProfile).toHaveProperty('picture');
expect(userProfile.preferences).toBeTruthy();
expect(userProfile.preferences.skipFrontdoorLogin).toEqual(false);
expect(userProfile.entitlements).toBeTruthy();
expect(userProfile.entitlements.chromeExtension).toEqual(false);
expect(userProfile.entitlements.googleDrive).toEqual(false);
expect(userProfile.entitlements.recordSync).toEqual(false);
expect(userProfile.subscriptions).toBeTruthy();
expect(userProfile.subscriptions).toHaveLength(0);

await playwrightPage.logout();
await expect(page.getByTestId('home-hero-container')).toBeVisible();
return { email, password, name };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function SfdcPageButton() {
message: 'GET_SESSION',
data: { salesforceHost },
});
console.log('sessionInfo', sessionInfo);
logger.log('sessionInfo', sessionInfo);
if (sessionInfo) {
const { org } = await sendMessage({
message: 'INIT_ORG',
Expand All @@ -184,7 +184,7 @@ export function SfdcPageButton() {
})();
})
.catch((err) => {
console.log(err);
logger.log(err);
});
}
}, [isOnSalesforcePage, setSalesforceOrgs, setSelectedOrgId]);
Expand Down
42 changes: 17 additions & 25 deletions apps/jetstream-web-extension/src/controllers/route.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="chrome" />
/// <reference lib="WebWorker" />
import { ApiConnection } from '@jetstream/salesforce-api';
import { logger } from '@jetstream/shared/client-logger';
import { getErrorMessage } from '@jetstream/shared/utils';
import { Maybe, SalesforceOrgUi } from '@jetstream/types';
import { z } from 'zod';
Expand Down Expand Up @@ -58,50 +59,41 @@ export function createRoute<TParamsSchema extends z.ZodTypeAny, TBodySchema exte
return async (req: RequestOptions) => {
const url = req.urlOverride || new URL(req.event.request.url);
const queryParams = Object.fromEntries(url.searchParams.entries());
// FIXME: this does not work when the body is not JSON
const parsedBody = req.event.request.body ? await req.event.request.json() : undefined;

let parsedBody: unknown = undefined;

if (req.event.request.body) {
if (req.event.request.headers.get('content-type') === 'application/json') {
parsedBody = await req.event.request.json();
} else {
parsedBody = await req.event.request.text();
}
}

// const parsedBody = req.event.request.body ? await req.event.request.json() : undefined;
try {
console.info(`Handling route ${url}`);
logger.info(`Handling route ${url}`);
const data = {
params: params ? params.parse(req.params) : undefined,
// FIXME: is this always JSON?
body: body && req.event.request.body ? body.parse(parsedBody) : undefined,
query: query ? query.parse(queryParams) : undefined,
jetstreamConn: req.jetstreamConn,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
targetJetstreamConn: req.targetJetstreamConn,
org: req.org,
// this will exist if targetJetstreamConn exists, otherwise will throw
targetOrg: req.targetOrg,
// user: req.user,
// requestId: res.locals.requestId,
};
if (hasSourceOrg && !data.jetstreamConn) {
// FIXME: add logger
console.info('[INIT-ORG][ERROR] A source org did not exist on locals');
logger.info('[INIT-ORG][ERROR] A source org did not exist on locals');
throw new Error('An org is required for this action');
}
if (hasTargetOrg && !data.targetJetstreamConn) {
// FIXME: add logger
console.info('[INIT-ORG][ERROR] A target org did not exist on locals');
logger.info('[INIT-ORG][ERROR] A target org did not exist on locals');
throw new Error('A source and target org are required for this action');
}
return await controllerFn(data, req);
} catch (ex) {
// rollbarServer.error('Route Validation Error', req, {
// context: `route#createRoute`,
// custom: {
// ...getErrorMessageAndStackObj(ex),
// url: url.toString(),
// params: req.params,
// query: queryParams,
// body: parsedBody,
// // FIXME:
// // userId: (req.user as UserProfileServer)?.id,
// // requestId: res.locals.requestId,
// },
// });
console.error(ex, '[ROUTE][VALIDATION ERROR]');
logger.error(ex, '[ROUTE][VALIDATION ERROR]');
throw ex;
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const routeDefinition = {
controllerFn: () => addBatchToJob,
validators: {
params: z.object({ jobId: z.string().min(1) }),
body: z.any(),
body: z.string(),
query: z.object({
closeJob: BooleanQueryParamSchema,
}),
Expand Down
Loading

0 comments on commit cfd2be7

Please sign in to comment.