diff --git a/web/packages/teleport/src/Apps/AddApp/AddApp.story.tsx b/web/packages/teleport/src/Apps/AddApp/AddApp.story.tsx new file mode 100644 index 0000000000000..e4c7c135699e8 --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/AddApp.story.tsx @@ -0,0 +1,80 @@ +/** + * Copyright 2020 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { AddApp } from './AddApp'; + +export default { + title: 'Teleport/Apps/Add', +}; + +export const Created = () => ( + +); + +export const Loaded = () => { + return ; +}; + +export const Processing = () => ( + +); + +export const Failed = () => ( + +); + +export const ManuallyProcessing = () => ( + +); + +export const ManuallyWithToken = () => ; + +export const ManuallyWithoutTokenLocal = () => ( + +); + +export const ManuallyWithoutTokenSSO = () => ( + +); + +const props = { + isEnterprise: false, + isAuthTypeLocal: true, + user: 'sam', + automatic: true, + setAutomatic: () => null, + createToken: () => Promise.resolve(true), + onClose: () => null, + setCmdParams: () => null, + createJoinToken: () => Promise.resolve(null), + version: '5.0.0-dev', + reset: () => null, + attempt: { + status: '', + statusText: '', + } as any, + token: { id: 'join-token', expiryText: '1 hour', expiry: null }, +}; diff --git a/web/packages/teleport/src/Apps/AddApp/AddApp.tsx b/web/packages/teleport/src/Apps/AddApp/AddApp.tsx new file mode 100644 index 0000000000000..7ae7af6a162a9 --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/AddApp.tsx @@ -0,0 +1,108 @@ +/** + * Copyright 2020 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Flex } from 'design'; +import Dialog, { DialogTitle } from 'design/Dialog'; + +import * as Icons from 'design/Icon'; + +import useTeleport from 'teleport/useTeleport'; + +import { TabIcon } from 'teleport/components/TabIcon'; + +import { Manually } from './Manually'; + +import { Automatically } from './Automatically'; +import useAddApp, { State } from './useAddApp'; + +export default function Container(props: Props) { + const ctx = useTeleport(); + const state = useAddApp(ctx); + return ; +} + +export function AddApp({ + user, + onClose, + createToken, + isEnterprise, + version, + attempt, + automatic, + setAutomatic, + isAuthTypeLocal, + token, +}: State & Props) { + return ( + ({ + maxWidth: '600px', + width: '100%', + minHeight: '330px', + })} + disableEscapeKeyDown={false} + onClose={onClose} + open={true} + > + + + Add Application + {isEnterprise && ( + <> + setAutomatic(true)} + /> + setAutomatic(false)} + /> + + )} + + {automatic && ( + + )} + {!automatic && ( + + )} + + + ); +} + +type Props = { + onClose(): void; +}; diff --git a/web/packages/teleport/src/Apps/AddApp/Automatically.test.tsx b/web/packages/teleport/src/Apps/AddApp/Automatically.test.tsx new file mode 100644 index 0000000000000..59d648287f48d --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/Automatically.test.tsx @@ -0,0 +1,64 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render, screen } from 'design/utils/testing'; + +import { Automatically, createAppBashCommand } from './Automatically'; + +test('render command only after form submit', async () => { + const token = { id: 'token', expiryText: '', expiry: null }; + render( + {}} + onCreate={() => Promise.resolve(true)} + /> + ); + + // initially, should not show the command + let cmd = createAppBashCommand(token.id, '', ''); + expect(screen.queryByText(cmd)).not.toBeInTheDocument(); + + // set app name + const appNameInput = screen.getByPlaceholderText('jenkins'); + fireEvent.change(appNameInput, { target: { value: 'app-name' } }); + + // set app url + const appUriInput = screen.getByPlaceholderText('https://localhost:4000'); + fireEvent.change(appUriInput, { + target: { value: 'https://gravitational.com' }, + }); + + // click button + screen.getByRole('button', { name: /Generate Script/i }).click(); + + // after form submission should show the command + cmd = createAppBashCommand(token.id, 'app-name', 'https://gravitational.com'); + expect(screen.getByText(cmd)).toBeInTheDocument(); +}); + +test('app bash command encoding', () => { + const token = '86'; + const appName = 'jenkins'; + const appUri = `http://myapp/test?b='d'&a="1"&c=|`; + + const cmd = createAppBashCommand(token, appName, appUri); + expect(cmd).toBe( + `sudo bash -c "$(curl -fsSL 'http://localhost/scripts/86/install-app.sh?name=jenkins&uri=http%3A%2F%2Fmyapp%2Ftest%3Fb%3D%27d%27%26a%3D%221%22%26c%3D%7C')"` + ); +}); diff --git a/web/packages/teleport/src/Apps/AddApp/Automatically.tsx b/web/packages/teleport/src/Apps/AddApp/Automatically.tsx new file mode 100644 index 0000000000000..2ed338adab6e4 --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/Automatically.tsx @@ -0,0 +1,271 @@ +/** + * Copyright 2020 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { KeyboardEvent } from 'react'; +import { + Link, + Text, + Flex, + Alert, + ButtonSecondary, + ButtonPrimary, +} from 'design'; +import { DialogContent, DialogFooter } from 'design/Dialog'; +import Validation, { Validator } from 'shared/components/Validation'; +import FieldInput from 'shared/components/FieldInput'; +import { Attempt } from 'shared/hooks/useAttemptNext'; + +import cfg from 'teleport/config'; +import TextSelectCopy from 'teleport/components/TextSelectCopy'; + +import { State } from './useAddApp'; + +export function Automatically(props: Props) { + const { onClose, attempt, token } = props; + + const [name, setName] = React.useState(''); + const [uri, setUri] = React.useState(''); + const [cmd, setCmd] = React.useState(''); + + React.useEffect(() => { + if (name && uri) { + const cmd = createAppBashCommand(token.id, name, uri); + setCmd(cmd); + } + }, [token]); + + function handleRegenerate(validator: Validator) { + if (!validator.validate()) { + return; + } + + props.onCreate(name, uri); + } + + function handleGenerate(validator: Validator) { + if (!validator.validate()) { + return; + } + + const cmd = createAppBashCommand(token.id, name, uri); + setCmd(cmd); + } + + function handleEnterPress( + e: KeyboardEvent, + validator: Validator + ) { + if (e.key === 'Enter') { + if (cmd) { + handleRegenerate(validator); + } else { + handleGenerate(validator); + } + } + } + + return ( + + {({ validator }) => ( + <> + + + handleEnterPress(e, validator)} + onChange={e => setName(e.target.value.toLowerCase())} + /> + handleEnterPress(e, validator)} + onChange={e => setUri(e.target.value)} + /> + + {!cmd && ( + + Teleport can automatically set up application access. Provide + the name and URL of your application to generate our + auto-installer script. + + The script will install the Teleport agent to provide secure + access to your application. + + + )} + {attempt.status === 'failed' && ( + + )} + {cmd && ( + <> + + Use the script below to add an application to your cluster.{' '} + The script will be valid for + + {` ${token.expiryText}`}. + + {renderUrl(name)} + + + + )} + + + {!cmd && ( + handleGenerate(validator)} + > + Generate Script + + )} + {cmd && ( + handleRegenerate(validator)} + > + Regenerate + + )} + + Close + + + + )} + + ); +} + +function renderUrl(name = '') { + const url = `https://${name}.${window.location.host}`; + return ( + + This app will be available on {` `} + + {`${url}`} + + + ); +} + +// Validation logic matches backend checks for app URI +const ALLOWED_APPURI_REGEXP = /^[-\w/:. ]+$/; +const requiredAppUri = value => () => { + if (!value) { + return { + valid: false, + message: 'Required', + }; + } + + try { + new URL(value); + } catch { + return { + valid: false, + message: 'URL is invalid', + }; + } + + const appUriMatch = value.match(ALLOWED_APPURI_REGEXP); + if (!appUriMatch) { + return { + valid: false, + message: 'Invalid app URI', + }; + } + + return { + valid: true, + }; +}; + +/** + * Conforms to rfc 1035 name syntax where: + * - name should start with alphabets and end with alphanumerics + * - interior characters are only alphanumerics and hyphens + * - string must be 63 chars or less + */ +const REGEX_DNS1035LABEL = /^[a-z]([-a-z0-9]*[a-z0-9])?$/; +const DNS1035LABEL_MAXLENGTH = 63; +const requiredAppName = value => () => { + if (!value || value.length === 0) { + return { + valid: false, + message: 'Required', + }; + } + + if (value.length > DNS1035LABEL_MAXLENGTH) { + return { + valid: false, + message: 'Must be 63 chars or less', + }; + } + + const match = value.match(REGEX_DNS1035LABEL); + if (!match) { + return { + valid: false, + message: 'Invalid DNS sub-domain name', + }; + } + + return { + valid: true, + }; +}; + +export const createAppBashCommand = ( + tokenId: string, + appName: string, + appUri: string +): string => { + // encode uri so it can be passed around as URL query parameter + const encoded = encodeURIComponent(appUri) + // encode single quotes so they do not break the curl parameters + .replace(/'/g, '%27'); + const bashUrl = + cfg.baseUrl + + cfg.api.appNodeScriptPath + .replace(':token', tokenId) + .replace(':name', appName) + .replace(':uri', encoded); + + return `sudo bash -c "$(curl -fsSL '${bashUrl}')"`; +}; + +type Props = { + onClose(): void; + onCreate(name: string, uri: string): Promise; + token: State['token']; + attempt: Attempt; +}; diff --git a/web/packages/teleport/src/Apps/AddApp/Manually.tsx b/web/packages/teleport/src/Apps/AddApp/Manually.tsx new file mode 100644 index 0000000000000..ea49f3fcf3067 --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/Manually.tsx @@ -0,0 +1,186 @@ +/** + * Copyright 2020 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Text, + Box, + ButtonSecondary, + Link, + Indicator, + ButtonLink, +} from 'design'; +import { DialogContent, DialogFooter } from 'design/Dialog'; + +import TextSelectCopy from 'teleport/components/TextSelectCopy'; +import DownloadLinks from 'teleport/components/DownloadLinks'; +import cfg from 'teleport/config'; + +import { State } from './useAddApp'; + +export function Manually({ + isEnterprise, + user, + version, + onClose, + isAuthTypeLocal, + token, + createToken, + attempt, +}: Props) { + const { hostname, port } = window.document.location; + const host = `${hostname}:${port || '443'}`; + let tshLoginCmd = `tsh login --proxy=${host}`; + + if (isAuthTypeLocal) { + tshLoginCmd = `${tshLoginCmd} --auth=local --user=${user}`; + } + + if (attempt.status === 'processing') { + return ( + + + + ); + } + + return ( + <> + + + + Step 1 + {' '} + - Download Teleport package to your computer + + + {attempt.status === 'failed' ? ( + + ) : ( + + )} + + + Close + + + ); +} + +const configFile = `${cfg.configDir}/app_config.yaml`; +const startCmd = `teleport start --config=${configFile}`; + +function getConfigCmd(token: string, host: string) { + return `teleport configure --output=${configFile} --app-name=[example-app] --app-uri=http://localhost/ \ +--roles=app --token=${token} --proxy=${host} --data-dir=${cfg.configDir}`; +} + +type StepsWithoutTokenProps = { + tshLoginCmd: string; + host: string; +}; + +const StepsWithoutToken = ({ tshLoginCmd, host }: StepsWithoutTokenProps) => ( + <> + + + Step 2 + + {' - Login to Teleport'} + + + + + Step 3 + + {' - Generate a join token'} + + + + + Step 4 + + {` - Configure your teleport agent`} + + + + + Step 5 + + {` - Start the Teleport agent with the generated configuration file`} + + + + {`* Note: For a self-hosted Teleport version, you may need to update DNS and obtain a TLS certificate for this application. + Learn more about application access `} + + here + + . + + +); + +type StepsWithTokenProps = { + token: State['token']; + host: string; + createToken: State['createToken']; +}; + +const StepsWithToken = ({ token, host, createToken }: StepsWithTokenProps) => ( + <> + + + Step 2 + + {` - Configure your teleport agent`} + + The token will be valid for{' '} + + {token.expiryText}. + + + + + Regenerate Token + + + + + Step 3 + + {` - Start the Teleport agent with the configuration file`} + + + +); + +type Props = { + onClose(): void; + isEnterprise: boolean; + version: string; + user: string; + isAuthTypeLocal: boolean; + token: State['token']; + createToken: State['createToken']; + attempt: State['attempt']; +}; diff --git a/web/packages/teleport/src/Apps/AddApp/index.ts b/web/packages/teleport/src/Apps/AddApp/index.ts new file mode 100644 index 0000000000000..b8be46436408a --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2020 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AddApp from './AddApp'; + +export default AddApp; diff --git a/web/packages/teleport/src/Apps/AddApp/useAddApp.ts b/web/packages/teleport/src/Apps/AddApp/useAddApp.ts new file mode 100644 index 0000000000000..94143f9c5662c --- /dev/null +++ b/web/packages/teleport/src/Apps/AddApp/useAddApp.ts @@ -0,0 +1,56 @@ +/* +Copyright 2020 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useEffect, useState } from 'react'; +import useAttempt from 'shared/hooks/useAttemptNext'; + +import TeleportContext from 'teleport/teleportContext'; + +import type { JoinToken } from 'teleport/services/joinToken'; + +export default function useAddApp(ctx: TeleportContext) { + const { attempt, run } = useAttempt(''); + const user = ctx.storeUser.state.username; + const version = ctx.storeUser.state.cluster.authVersion; + const isAuthTypeLocal = !ctx.storeUser.isSso(); + const isEnterprise = ctx.isEnterprise; + const [automatic, setAutomatic] = useState(isEnterprise); + const [token, setToken] = useState(); + + useEffect(() => { + createToken(); + }, []); + + function createToken() { + return run(() => + ctx.joinTokenService.fetchJoinToken({ roles: ['App'] }).then(setToken) + ); + } + + return { + user, + version, + createToken, + attempt, + automatic, + setAutomatic, + isAuthTypeLocal, + isEnterprise, + token, + }; +} + +export type State = ReturnType; diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index 27b8a534dc78d..039a067c524d5 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -34,6 +34,7 @@ import { getResourcePretitle, RESOURCES, } from 'teleport/Discover/SelectResource/resources'; +import AddApp from 'teleport/Apps/AddApp'; import { icons } from './icons'; @@ -52,6 +53,7 @@ export function SelectResource(props: SelectResourceProps) { const [search, setSearch] = useState(''); const [resources, setResources] = useState([]); const [defaultResources, setDefaultResources] = useState([]); + const [showApp, setShowApp] = useState(false); function onSearch(s: string, customList?: ResourceSpec[]) { const list = customList || defaultResources; @@ -133,22 +135,43 @@ export function SelectResource(props: SelectResourceProps) { const title = r.name; const pretitle = getResourcePretitle(r); - // There can be two types of click behavior with the resource cards: + let resourceCardProps; + if (r.kind === ResourceKind.Application) { + resourceCardProps = { + onClick: () => { + if (r.hasAccess) { + props.onSelect(r); + setShowApp(true); + } + }, + }; + } else if (r.unguidedLink) { + resourceCardProps = { + as: Link, + href: r.hasAccess ? r.unguidedLink : null, + target: '_blank', + style: { textDecoration: 'none' }, + }; + } else { + resourceCardProps = { + onClick: () => r.hasAccess && props.onSelect(r), + }; + } + + // There can be three types of click behavior with the resource cards: // 1) If the resource has no interactive UI flow ("unguided"), // clicking on the card will take a user to our docs page // on a new tab. // 2) If the resource is guided, we start the "flow" by // taking user to the next step. + // 3) If the resource is kind 'Application', it will render the legacy + // popup modal where it shows user to add app manually or automatically. return ( r.hasAccess && props.onSelect(r)} - className={r.unguidedLink ? 'unguided' : ''} + {...resourceCardProps} > {!r.unguidedLink && r.hasAccess && ( Guided @@ -195,6 +218,7 @@ export function SelectResource(props: SelectResourceProps) { )} + {showApp && setShowApp(false)} />} ); } @@ -329,10 +353,6 @@ const ResourceCard = styled.div` opacity: ${props => (props.hasAccess ? '1' : '0.45')}; - &.unguided { - text-decoration: none; - } - :hover { background: ${props => props.theme.colors.spotBackground[1]}; } diff --git a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap index ee60c86353484..d5a9ddd1f4303 100644 --- a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap +++ b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap @@ -145,10 +145,6 @@ exports[`render with URL loc state set to "server" 1`] = ` opacity: 1; } -.c11.unguided { - text-decoration: none; -} - .c11:hover { background: rgba(255,255,255,0.13); } @@ -492,10 +488,11 @@ exports[`render with URL loc state set to "server" 1`] = `
Guided
Kubernetes
@@ -912,40 +904,40 @@ exports[`render with all access 1`] = `
Guided
Windows Desktop
Active Directory
@@ -953,40 +945,40 @@ exports[`render with all access 1`] = `
Guided
Server
Ubuntu 14.04+
@@ -994,40 +986,40 @@ exports[`render with all access 1`] = `
Guided
Server
Debian 8+
@@ -1035,40 +1027,40 @@ exports[`render with all access 1`] = `
Guided
Server
RHEL/CentOS 7+
@@ -1076,19 +1068,19 @@ exports[`render with all access 1`] = `
Guided
Server
Amazon Linux 2
@@ -1135,38 +1127,38 @@ exports[`render with all access 1`] = `
Guided
Server
macOS (Intel)
@@ -1174,19 +1166,19 @@ exports[`render with all access 1`] = `
Guided
Amazon Web Services (AWS)
RDS PostgreSQL
@@ -1233,19 +1225,19 @@ exports[`render with all access 1`] = `
Guided
Amazon Web Services (AWS)
Aurora PostgreSQL
@@ -1292,19 +1284,19 @@ exports[`render with all access 1`] = `
Guided
Amazon Web Services (AWS)
RDS MySQL/MariaDB
@@ -1351,19 +1343,19 @@ exports[`render with all access 1`] = `
Guided
Amazon Web Services (AWS)
Aurora MySQL/MariaDB
@@ -1410,40 +1402,40 @@ exports[`render with all access 1`] = `
Guided
Self-Hosted
PostgreSQL
@@ -1451,40 +1443,40 @@ exports[`render with all access 1`] = `
Guided
Self-Hosted
MySQL/MariaDB
@@ -1492,38 +1484,39 @@ exports[`render with all access 1`] = `
Amazon Web Services (AWS)
DynamoDB @@ -1532,17 +1525,18 @@ exports[`render with all access 1`] = `
Amazon Web Services (AWS)
ElastiCache & MemoryDB @@ -1590,17 +1584,18 @@ exports[`render with all access 1`] = `
Amazon Web Services (AWS)
Keyspaces (Apache Cassandra) @@ -1648,38 +1643,39 @@ exports[`render with all access 1`] = `
Amazon Web Services (AWS)
Redshift PostgreSQL @@ -1688,38 +1684,39 @@ exports[`render with all access 1`] = `
Amazon Web Services (AWS)
Redshift Serverless @@ -1728,38 +1725,39 @@ exports[`render with all access 1`] = `
Azure
Cache for Redis @@ -1768,38 +1766,39 @@ exports[`render with all access 1`] = `
Azure
PostgreSQL @@ -1808,38 +1807,39 @@ exports[`render with all access 1`] = `
Azure
MySQL @@ -1848,38 +1848,39 @@ exports[`render with all access 1`] = `
Azure
SQL Server (Preview) @@ -1888,38 +1889,39 @@ exports[`render with all access 1`] = `
Microsoft
SQL Server (Preview) @@ -1928,38 +1930,39 @@ exports[`render with all access 1`] = `
Google Cloud Provider (GCP)
Cloud SQL MySQL @@ -1968,38 +1971,39 @@ exports[`render with all access 1`] = `
Google Cloud Provider (GCP)
Cloud SQL PostgreSQL @@ -2008,32 +2012,33 @@ exports[`render with all access 1`] = `
MongoDB Atlas @@ -2042,38 +2047,39 @@ exports[`render with all access 1`] = `
Self-Hosted
Cassandra & ScyllaDB @@ -2082,38 +2088,39 @@ exports[`render with all access 1`] = `
Self-Hosted
CockroachDB @@ -2122,38 +2129,39 @@ exports[`render with all access 1`] = `
Self-Hosted
Elasticsearch @@ -2162,38 +2170,39 @@ exports[`render with all access 1`] = `
Self-Hosted
MongoDB @@ -2202,38 +2211,39 @@ exports[`render with all access 1`] = `
Self-Hosted
Redis @@ -2242,38 +2252,39 @@ exports[`render with all access 1`] = `
Self-Hosted
Redis Cluster @@ -2282,32 +2293,33 @@ exports[`render with all access 1`] = `
Snowflake (Preview) @@ -2316,17 +2328,18 @@ exports[`render with all access 1`] = `
Amazon Web Services (AWS)
RDS Proxy @@ -2374,38 +2387,39 @@ exports[`render with all access 1`] = `
Database
High Availability @@ -2414,38 +2428,39 @@ exports[`render with all access 1`] = `
Database
Dynamic Registration @@ -2473,7 +2488,7 @@ exports[`render with all access 1`] = ` `; exports[`render with no access 1`] = ` -.c17 { +.c16 { display: inline-block; transition: color 0.3s; color: #FFFFFF; @@ -2491,7 +2506,7 @@ exports[`render with no access 1`] = ` width: 600px; } -.c13 { +.c12 { box-sizing: border-box; } @@ -2512,7 +2527,7 @@ exports[`render with no access 1`] = ` margin-bottom: 32px; } -.c14 { +.c13 { overflow: hidden; text-overflow: ellipsis; font-weight: 600; @@ -2520,14 +2535,14 @@ exports[`render with no access 1`] = ` color: #FFFFFF; } -.c15 { +.c14 { overflow: hidden; text-overflow: ellipsis; font-weight: 600; margin: 0px; } -.c16 { +.c15 { overflow: hidden; text-overflow: ellipsis; font-size: 12px; @@ -2543,7 +2558,7 @@ exports[`render with no access 1`] = ` margin-top: 40px; } -.c7 { +.c17 { color: #009EFF; font-weight: normal; background: none; @@ -2560,14 +2575,14 @@ exports[`render with no access 1`] = ` margin-left: 8px; } -.c12 { +.c11 { display: block; outline: none; width: 23.9px; height: 24px; } -.c10 { +.c9 { box-sizing: border-box; padding-left: 8px; padding-right: 8px; @@ -2575,7 +2590,7 @@ exports[`render with no access 1`] = ` align-items: center; } -.c11 { +.c10 { box-sizing: border-box; margin-right: 16px; width: 24px; @@ -2583,7 +2598,7 @@ exports[`render with no access 1`] = ` justify-content: center; } -.c9 { +.c8 { box-sizing: border-box; background-color: red; border-top-right-radius: 4px; @@ -2602,7 +2617,7 @@ exports[`render with no access 1`] = ` row-gap: 15px; } -.c8 { +.c7 { display: flex; position: relative; align-items: center; @@ -2616,11 +2631,7 @@ exports[`render with no access 1`] = ` opacity: 0.45; } -.c8.unguided { - text-decoration: none; -} - -.c8:hover { +.c7:hover { background: rgba(255,255,255,0.13); } @@ -2684,75 +2695,73 @@ exports[`render with no access 1`] = `
Lacking Permissions
Kubernetes
@@ -2760,41 +2769,41 @@ exports[`render with no access 1`] = `
Lacking Permissions
Windows Desktop
Active Directory
@@ -2802,41 +2811,41 @@ exports[`render with no access 1`] = `
Lacking Permissions
Server
Ubuntu 14.04+
@@ -2844,41 +2853,41 @@ exports[`render with no access 1`] = `
Lacking Permissions
Server
Debian 8+
@@ -2886,41 +2895,41 @@ exports[`render with no access 1`] = `
Lacking Permissions
Server
RHEL/CentOS 7+
@@ -2928,20 +2937,20 @@ exports[`render with no access 1`] = `
Lacking Permissions
Server
Amazon Linux 2
@@ -2988,39 +2997,39 @@ exports[`render with no access 1`] = `
Lacking Permissions
Server
macOS (Intel)
@@ -3028,20 +3037,20 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
RDS PostgreSQL
@@ -3088,20 +3097,20 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Aurora PostgreSQL
@@ -3148,20 +3157,20 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
RDS MySQL/MariaDB
@@ -3208,20 +3217,20 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Aurora MySQL/MariaDB
@@ -3268,41 +3277,41 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
PostgreSQL
@@ -3310,41 +3319,41 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
MySQL/MariaDB
@@ -3352,43 +3361,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
DynamoDB @@ -3397,22 +3407,23 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
ElastiCache & MemoryDB @@ -3460,22 +3471,23 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Keyspaces (Apache Cassandra) @@ -3523,43 +3535,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Redshift PostgreSQL @@ -3568,43 +3581,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Redshift Serverless @@ -3613,43 +3627,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Azure
Cache for Redis @@ -3658,43 +3673,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Azure
PostgreSQL @@ -3703,43 +3719,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Azure
MySQL @@ -3748,43 +3765,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Azure
SQL Server (Preview) @@ -3793,43 +3811,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Microsoft
SQL Server (Preview) @@ -3838,43 +3857,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Google Cloud Provider (GCP)
Cloud SQL MySQL @@ -3883,43 +3903,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Google Cloud Provider (GCP)
Cloud SQL PostgreSQL @@ -3928,37 +3949,38 @@ exports[`render with no access 1`] = `
Lacking Permissions
MongoDB Atlas @@ -3967,43 +3989,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
Cassandra & ScyllaDB @@ -4012,43 +4035,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
CockroachDB @@ -4057,43 +4081,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
Elasticsearch @@ -4102,43 +4127,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
MongoDB @@ -4147,43 +4173,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
Redis @@ -4192,43 +4219,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Self-Hosted
Redis Cluster @@ -4237,37 +4265,38 @@ exports[`render with no access 1`] = `
Lacking Permissions
Snowflake (Preview) @@ -4276,22 +4305,23 @@ exports[`render with no access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
RDS Proxy @@ -4339,43 +4369,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Database
High Availability @@ -4384,43 +4415,44 @@ exports[`render with no access 1`] = `
Lacking Permissions
Database
Dynamic Registration @@ -4448,7 +4480,7 @@ exports[`render with no access 1`] = ` `; exports[`render with partial access 1`] = ` -.c17 { +.c16 { display: inline-block; transition: color 0.3s; color: #FFFFFF; @@ -4466,7 +4498,7 @@ exports[`render with partial access 1`] = ` width: 600px; } -.c12 { +.c11 { box-sizing: border-box; } @@ -4487,7 +4519,7 @@ exports[`render with partial access 1`] = ` margin-bottom: 32px; } -.c13 { +.c12 { overflow: hidden; text-overflow: ellipsis; font-weight: 600; @@ -4495,14 +4527,14 @@ exports[`render with partial access 1`] = ` color: #FFFFFF; } -.c15 { +.c14 { overflow: hidden; text-overflow: ellipsis; font-weight: 600; margin: 0px; } -.c16 { +.c15 { overflow: hidden; text-overflow: ellipsis; font-size: 12px; @@ -4518,7 +4550,7 @@ exports[`render with partial access 1`] = ` margin-top: 40px; } -.c7 { +.c19 { color: #009EFF; font-weight: normal; background: none; @@ -4535,14 +4567,14 @@ exports[`render with partial access 1`] = ` margin-left: 8px; } -.c11 { +.c10 { display: block; outline: none; width: 23.9px; height: 24px; } -.c9 { +.c8 { box-sizing: border-box; padding-left: 8px; padding-right: 8px; @@ -4550,7 +4582,7 @@ exports[`render with partial access 1`] = ` align-items: center; } -.c10 { +.c9 { box-sizing: border-box; margin-right: 16px; width: 24px; @@ -4558,7 +4590,7 @@ exports[`render with partial access 1`] = ` justify-content: center; } -.c19 { +.c18 { box-sizing: border-box; background-color: red; border-top-right-radius: 4px; @@ -4577,7 +4609,7 @@ exports[`render with partial access 1`] = ` row-gap: 15px; } -.c8 { +.c7 { display: flex; position: relative; align-items: center; @@ -4591,15 +4623,11 @@ exports[`render with partial access 1`] = ` opacity: 1; } -.c8.unguided { - text-decoration: none; -} - -.c8:hover { +.c7:hover { background: rgba(255,255,255,0.13); } -.c18 { +.c17 { display: flex; position: relative; align-items: center; @@ -4613,15 +4641,11 @@ exports[`render with partial access 1`] = ` opacity: 0.45; } -.c18.unguided { - text-decoration: none; -} - -.c18:hover { +.c17:hover { background: rgba(255,255,255,0.13); } -.c14 { +.c13 { position: absolute; background: #9F85FF; color: #000000; @@ -4693,69 +4717,66 @@ exports[`render with partial access 1`] = `
Guided
Kubernetes
@@ -4763,40 +4784,40 @@ exports[`render with partial access 1`] = `
Guided
Windows Desktop
Active Directory
@@ -4804,40 +4825,40 @@ exports[`render with partial access 1`] = `
Guided
Server
Ubuntu 14.04+
@@ -4845,40 +4866,40 @@ exports[`render with partial access 1`] = `
Guided
Server
Debian 8+
@@ -4886,40 +4907,40 @@ exports[`render with partial access 1`] = `
Guided
Server
RHEL/CentOS 7+
@@ -4927,19 +4948,19 @@ exports[`render with partial access 1`] = `
Guided
Server
Amazon Linux 2
@@ -4986,38 +5007,38 @@ exports[`render with partial access 1`] = `
Guided
Server
macOS (Intel)
@@ -5025,20 +5046,20 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
RDS PostgreSQL
@@ -5085,20 +5106,20 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Aurora PostgreSQL
@@ -5145,20 +5166,20 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
RDS MySQL/MariaDB
@@ -5205,20 +5226,20 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Aurora MySQL/MariaDB
@@ -5265,41 +5286,41 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Self-Hosted
PostgreSQL
@@ -5307,41 +5328,41 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Self-Hosted
MySQL/MariaDB
@@ -5349,43 +5370,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
DynamoDB @@ -5394,22 +5416,23 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
ElastiCache & MemoryDB @@ -5457,22 +5480,23 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Keyspaces (Apache Cassandra) @@ -5520,43 +5544,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Redshift PostgreSQL @@ -5565,43 +5590,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Amazon Web Services (AWS)
Redshift Serverless @@ -5610,43 +5636,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Azure
Cache for Redis @@ -5655,43 +5682,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Azure
PostgreSQL @@ -5700,43 +5728,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Azure
MySQL @@ -5745,43 +5774,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Azure
SQL Server (Preview) @@ -5790,43 +5820,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions
Microsoft
SQL Server (Preview) @@ -5835,43 +5866,44 @@ exports[`render with partial access 1`] = `
Lacking Permissions