diff --git a/package.json b/package.json index e12bd14d5db60..9c3110dd99972 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@sentry/browser": "7.55.2", "@sentry/webpack-plugin": "2.2.2", "@types/dompurify": "^3.0.2", + "@types/js-cookie": "^3.0.3", "@types/jest": "^29.5.3", "@types/js-yaml": "^3.0.0", "@types/node": "^20.3.1", @@ -54,6 +55,7 @@ "gatsby-transformer-json": "^4.0.0", "gatsby-transformer-remark": "^5.0.0", "gray-matter": "^4.0.2", + "js-cookie": "^3.0.5", "js-yaml": "^3.0.0", "jsdom": "^22.1.0", "platformicons": "^5.6.0", diff --git a/src/api/auth.mdx b/src/api/auth.mdx index 3847a4e7c6160..8c058fb5c1708 100644 --- a/src/api/auth.mdx +++ b/src/api/auth.mdx @@ -23,7 +23,7 @@ You can create authentication tokens within Sentry by [creating an internal inte ### User authentication tokens -Some API endpoints require an authentication token that's associated with your user account, rather than an authentication token from an internal integration. These auth tokens can be created within Sentry on the "User settings" page (**User settings > Auth Tokens**) and assigned specific scopes. +Some API endpoints require an authentication token that's associated with your user account, rather than an authentication token from an internal integration. These auth tokens can be created within Sentry on the "User settings" page (**User settings > User Auth Tokens**) and assigned specific scopes. The endpoints that require a user authentication token are specific to your user, such as [List Your Organizations](/api/organizations/list-your-organizations/). diff --git a/src/components/__tests__/__snapshots__/codeBlock.test.js.snap b/src/components/__tests__/__snapshots__/codeBlock.test.js.snap new file mode 100644 index 0000000000000..5a9974eb4eb26 --- /dev/null +++ b/src/components/__tests__/__snapshots__/codeBlock.test.js.snap @@ -0,0 +1,250 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CodeWrapper renders multiple placeholders 1`] = ` + + process.env.MY_ENV = + + + + + + + + example-org + + + + + + + + + + + + + example-project + + + + +`; + +exports[`CodeWrapper renders org auth token placeholder when not signed in 1`] = ` + + process.env.MY_ENV = + sntrys_YOUR_TOKEN_HERE + +`; + +exports[`CodeWrapper renders org auth token placeholder when signed in 1`] = ` + + process.env.MY_ENV = + + Click to generate token + + +`; + +exports[`CodeWrapper renders with placeholder 1`] = ` + + process.env.MY_ENV = + + + + + + + + example-org + + + + +`; + +exports[`CodeWrapper renders with placeholder in middle of text 1`] = ` + + process.env.MY_ENV = https:// + + + + + + + + example-org + + + + .sentry.io + +`; + +exports[`CodeWrapper renders without placeholder 1`] = ` + + process.env.MY_ENV = 'foo' + +`; diff --git a/src/components/__tests__/codeBlock.test.js b/src/components/__tests__/codeBlock.test.js new file mode 100644 index 0000000000000..0d6f5d39fd0c1 --- /dev/null +++ b/src/components/__tests__/codeBlock.test.js @@ -0,0 +1,74 @@ +import React from 'react'; +import {create} from 'react-test-renderer'; + +import {CodeWrapper} from '../codeBlock'; +import {_setCachedCodeKeywords, CodeContextProvider, DEFAULTS} from '../codeContext'; + +describe('CodeWrapper', () => { + beforeEach(() => { + _setCachedCodeKeywords(DEFAULTS); + }); + + it('renders without placeholder', () => { + const tree = create( + + process.env.MY_ENV = 'foo' + + ); + + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('renders with placeholder', () => { + const tree = create( + + process.env.MY_ENV = ___ORG_SLUG___ + + ); + + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('renders with placeholder in middle of text', () => { + const tree = create( + + process.env.MY_ENV = https://___ORG_SLUG___.sentry.io + + ); + + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('renders org auth token placeholder when not signed in', () => { + const tree = create( + + process.env.MY_ENV = ___ORG_AUTH_TOKEN___ + + ); + + expect(tree.toJSON()).toMatchSnapshot(); + }); + it('renders org auth token placeholder when signed in', () => { + _setCachedCodeKeywords({...DEFAULTS, USER: {ID: 123, NAME: 'test@sentry.io'}}); + + const tree = create( + + process.env.MY_ENV = ___ORG_AUTH_TOKEN___ + + ); + + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('renders multiple placeholders', () => { + const tree = create( + + + process.env.MY_ENV = ___ORG_SLUG___ + ___PROJECT_SLUG___ + + + ); + + expect(tree.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/components/basePage.tsx b/src/components/basePage.tsx index 6cabfb80450ce..54f6ecd9ce404 100644 --- a/src/components/basePage.tsx +++ b/src/components/basePage.tsx @@ -3,7 +3,7 @@ import React, {Fragment, useState} from 'react'; import {getCurrentTransaction} from '../utils'; import {Banner} from './banner'; -import {CodeContext, useCodeContextState} from './codeContext'; +import {CodeContextProvider} from './codeContext'; import {GitHubCTA} from './githubCta'; import {Layout} from './layout'; import {SEO} from './seo'; @@ -77,9 +77,7 @@ export function BasePage({ >

{title}

- - {children} - + {children} {file && ( [number] | React.ReactNode; + function makeKeywordsClickable(children: React.ReactNode) { const items = Children.toArray(children); - KEYWORDS_REGEX.lastIndex = 0; - - return items.reduce((arr: any[], child) => { + return items.reduce((arr: ChildrenItem[], child) => { if (typeof child !== 'string') { arr.push(child); return arr; } - let match; - let lastIndex = 0; - // eslint-disable-next-line no-cond-assign - while ((match = KEYWORDS_REGEX.exec(child)) !== null) { - const afterMatch = KEYWORDS_REGEX.lastIndex - match[0].length; - const before = child.substring(lastIndex, afterMatch); - if (before.length > 0) { - arr.push(before); - } - arr.push( - - ); - lastIndex = KEYWORDS_REGEX.lastIndex; + if (ORG_AUTH_TOKEN_REGEX.test(child)) { + makeOrgAuthTokenClickable(arr, child); + } else if (KEYWORDS_REGEX.test(child)) { + makeProjectKeywordsClickable(arr, child); + } else { + arr.push(child); } - const after = child.substring(lastIndex); - if (after.length > 0) { - arr.push(after); + return arr; + }, [] as ChildrenItem[]); +} + +function makeOrgAuthTokenClickable(arr: ChildrenItem[], str: string) { + runRegex(arr, str, ORG_AUTH_TOKEN_REGEX, lastIndex => ( + + )); +} + +function makeProjectKeywordsClickable(arr: ChildrenItem[], str: string) { + runRegex(arr, str, KEYWORDS_REGEX, (lastIndex, match) => ( + + )); +} + +function runRegex( + arr: ChildrenItem[], + str: string, + regex: RegExp, + cb: (lastIndex: number, match: any[]) => React.ReactNode +): void { + regex.lastIndex = 0; + + let match; + let lastIndex = 0; + // eslint-disable-next-line no-cond-assign + while ((match = regex.exec(str)) !== null) { + const afterMatch = regex.lastIndex - match[0].length; + const before = str.substring(lastIndex, afterMatch); + + if (before.length > 0) { + arr.push(before); } - return arr; - }, []); + arr.push(cb(lastIndex, match)); + + lastIndex = regex.lastIndex; + } + + const after = str.substring(lastIndex); + if (after.length > 0) { + arr.push(after); + } } const getPortal = memoize((): HTMLElement => { @@ -73,6 +105,57 @@ type KeywordSelectorProps = { keyword: string; }; +function OrgAuthTokenCreator() { + const codeContext = useContext(CodeContext); + + const [tokenState, setTokenState] = useState<'none' | 'loading' | 'success' | 'error'>( + 'none' + ); + const [token, setToken] = useState(null); + const [sharedSelection] = codeContext.sharedKeywordSelection; + + const {codeKeywords} = useContext(CodeContext); + + const choices = codeKeywords?.PROJECT; + + // When not signed in, we just show a placeholder, as the user can't generate a token in this case + if (!codeKeywords.USER) { + return sntrys_YOUR_TOKEN_HERE; + } + + const currentSelectionIdx = sharedSelection.PROJECT ?? 0; + const currentSelection = choices[currentSelectionIdx]; + + const name = `Generated by Docs for ${currentSelection.PROJECT_SLUG} on ${new Date() + .toISOString() + .slice(0, 10)}`; + + const updateToken = async () => { + if (tokenState !== 'none') { + return; + } + setTokenState('loading'); + const tokenStr = await createOrgAuthToken({ + orgSlug: currentSelection.ORG_SLUG, + name, + }); + setTokenState(token ? 'success' : 'error'); + setToken(tokenStr); + }; + + return ( + + {tokenState === 'none' + ? 'Click to generate token' + : tokenState === 'loading' + ? 'Generating...' + : token + ? token + : 'Error generating token'} + + ); +} + function KeywordSelector({keyword, group, index}: KeywordSelectorProps) { const codeContext = useContext(CodeContext); @@ -319,7 +402,7 @@ const ItemButton = styled('button')<{isActive: boolean}>` `} `; -function CodeWrapper(props) { +export function CodeWrapper(props) { const {children, class: className, ...rest} = props; return ( diff --git a/src/components/codeContext.tsx b/src/components/codeContext.tsx index e1d8a70a722aa..b587835ec67a4 100644 --- a/src/components/codeContext.tsx +++ b/src/components/codeContext.tsx @@ -1,6 +1,5 @@ -import {createContext, useEffect, useState} from 'react'; - -type CodeContextStatus = 'loading' | 'loaded'; +import React, {createContext, useEffect, useState} from 'react'; +import Cookies from 'js-cookie'; type ProjectCodeKeywords = { API_URL: string; @@ -60,7 +59,7 @@ type UserApiResult = { // only fetch them once let cachedCodeKeywords: CodeKeywords | null = null; -const DEFAULTS: CodeKeywords = { +export const DEFAULTS: CodeKeywords = { PROJECT: [ { DSN: 'https://examplePublicKey@o0.ingest.sentry.io/0', @@ -84,12 +83,12 @@ const DEFAULTS: CodeKeywords = { type CodeContextType = { codeKeywords: CodeKeywords; + isLoading: boolean; sharedCodeSelection: [string | null, React.Dispatch]; sharedKeywordSelection: [ Record, React.Dispatch> ]; - status: CodeContextStatus; }; export const CodeContext = createContext(null); @@ -191,23 +190,71 @@ export async function fetchCodeKeywords(): Promise { }; } -export function useCodeContextState(fetcher = fetchCodeKeywords): CodeContextType { +function getCsrfToken(): string | null { + // is sentry-sc in production, but may also be sc in other envs + // So we just try both variants + const cookieNames = ['sentry-sc', 'sc']; + + return cookieNames + .map(cookieName => Cookies.get(cookieName)) + .find(token => token !== null); +} + +export async function createOrgAuthToken({ + orgSlug, + name, +}: { + name: string; + orgSlug: string; +}) { + const baseUrl = + process.env.NODE_ENV === 'development' + ? 'http://dev.getsentry.net:8000/' + : 'https://sentry.io'; + + const url = `${baseUrl}/api/0/organizations/${orgSlug}/org-auth-tokens/`; + + const body = {name}; + + try { + const resp = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + credentials: 'include', + headers: { + Accept: 'application/json; charset=utf-8', + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken(), + }, + }); + + if (!resp.ok) { + return null; + } + + const json = await resp.json(); + + return json.token; + } catch { + return null; + } +} + +export function CodeContextProvider({children}: {children: React.ReactNode}) { const [codeKeywords, setCodeKeywords] = useState(cachedCodeKeywords ?? DEFAULTS); - const [status, setStatus] = useState( - cachedCodeKeywords ? 'loaded' : 'loading' - ); + const [isLoading, setIsLoading] = useState(cachedCodeKeywords ? false : true); useEffect(() => { if (cachedCodeKeywords === null) { - setStatus('loading'); - fetcher().then((config: CodeKeywords) => { + setIsLoading(true); + fetchCodeKeywords().then((config: CodeKeywords) => { cachedCodeKeywords = config; setCodeKeywords(config); - setStatus('loaded'); + setIsLoading(false); }); } - }, [setStatus, setCodeKeywords, fetcher]); + }, [setIsLoading, setCodeKeywords]); // sharedKeywordSelection maintains a global mapping for each "keyword" // namespace to the index of the selected item. @@ -223,8 +270,13 @@ export function useCodeContextState(fetcher = fetchCodeKeywords): CodeContextTyp codeKeywords, sharedCodeSelection, sharedKeywordSelection, - status, + isLoading, }; - return result; + return {children}; +} + +/** For tests only. */ +export function _setCachedCodeKeywords(codeKeywords: CodeKeywords) { + cachedCodeKeywords = codeKeywords; } diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx index c7ccc69189efe..04c0483d91850 100644 --- a/src/components/markdown.tsx +++ b/src/components/markdown.tsx @@ -17,6 +17,7 @@ import {IncludePlatformContent} from './includePlatformContent'; import {JsCdnTag} from './jsCdnTag'; import {LambdaLayerDetail} from './lambdaLayerDetail'; import {Note} from './note'; +import {OrgAuthTokenNote} from './orgAuthTokenNote'; import {PageGrid} from './pageGrid'; import {ParamTable} from './paramTable'; import {PlatformContent} from './platformContent'; @@ -61,6 +62,7 @@ const mdxComponents = { SandboxLink, SandboxOnly, SignInNote, + OrgAuthTokenNote, }; export function Markdown({value}) { diff --git a/src/components/orgAuthTokenNote.tsx b/src/components/orgAuthTokenNote.tsx new file mode 100644 index 0000000000000..f7f25723375ac --- /dev/null +++ b/src/components/orgAuthTokenNote.tsx @@ -0,0 +1,49 @@ +import React, {Fragment} from 'react'; +import {useLocation} from '@reach/router'; +import {graphql, useStaticQuery} from 'gatsby'; + +import {ExternalLink} from './externalLink'; +import {Note} from './note'; +import {SignedInCheck} from './signedInCheck'; + +const siteMetaQuery = graphql` + query SignInNoteQuery { + site { + siteMetadata { + siteUrl + } + } + } +`; + +export function OrgAuthTokenNote() { + const location = useLocation(); + const data = useStaticQuery(siteMetaQuery); + + const url = data.site.siteMetadata.siteUrl + location.pathname; + + return ( + + + + You can{' '} + + manually create an Auth Token + {' '} + or{' '} + + sign in + {' '} + to create a token directly from the docs. + + + + + + A created token will only be visible once right after creation - make sure to + copy it! + + + + ); +} diff --git a/src/components/signedInCheck.tsx b/src/components/signedInCheck.tsx index c6f8ca09276d0..cc6239c3a49be 100644 --- a/src/components/signedInCheck.tsx +++ b/src/components/signedInCheck.tsx @@ -2,6 +2,16 @@ import React, {Fragment, useContext} from 'react'; import {CodeContext} from './codeContext'; +/** + * This component checks if the user is signed in at sentry.io or not. + * If the signed in status matches the given `isUserAuthenticated`, + * we render the children, else nothing. + * + * Example usage: + * + *
Only render this if the user is not signed in
+ *
+ */ export function SignedInCheck({ children, isUserAuthenticated, @@ -9,10 +19,10 @@ export function SignedInCheck({ children: React.ReactNode; isUserAuthenticated: boolean; }) { - const {codeKeywords, status} = useContext(CodeContext); + const {codeKeywords, isLoading} = useContext(CodeContext); // Never render until loaded - if (status !== 'loaded') { + if (isLoading) { return null; } diff --git a/src/platform-includes/sourcemaps/upload/webpack/javascript.mdx b/src/platform-includes/sourcemaps/upload/webpack/javascript.mdx index f0c5e2d4bac36..b018056e711de 100644 --- a/src/platform-includes/sourcemaps/upload/webpack/javascript.mdx +++ b/src/platform-includes/sourcemaps/upload/webpack/javascript.mdx @@ -32,10 +32,22 @@ yarn add @sentry/webpack-plugin --dev Learn more about configuring the plugin in our [Sentry Webpack Plugin documentation](https://www.npmjs.com/package/@sentry/webpack-plugin). -Example: - +You'll have to setup your environment variables first. In most cases, you'll either want to add the env. variables to a `.env` file (e.g. when deploying locally), or you'll have to add them to your CI/CD environment. + + + +```bash {filename:.env} +SENTRY_ORG=___ORG_SLUG___ +SENTRY_PROJECT=___PROJECT_SLUG___ +# Auth tokens can be obtained from +# https://sentry.io/settings/auth-tokens/ +SENTRY_AUTH_TOKEN=___ORG_AUTH_TOKEN___ +``` + +And the following Webpack config: + ```javascript {filename:webpack.config.js} const { sentryWebpackPlugin } = require("@sentry/webpack-plugin"); @@ -45,11 +57,8 @@ module.exports = { devtool: "source-map", // Source map generation must be turned on plugins: [ sentryWebpackPlugin({ - org: "___ORG_SLUG___", - project: "___PROJECT_SLUG___", - - // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ - // and need `project:releases` and `org:read` scopes + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, authToken: process.env.SENTRY_AUTH_TOKEN, }), ], diff --git a/yarn.lock b/yarn.lock index a4562b03165d5..aaf6b0c89759c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3056,6 +3056,11 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/js-cookie@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.3.tgz#d6bfbbdd0c187354ca555213d1962f6d0691ff4e" + integrity sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww== + "@types/js-yaml@^3.0.0": version "3.12.7" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.7.tgz#330c5d97a3500e9c903210d6e49f02964af04a0e" @@ -10078,6 +10083,11 @@ joi@^17.4.2: "@sideway/formula" "^3.0.1" "@sideway/pinpoint" "^2.0.0" +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"