feat: add github device flow for authentication (#8232)

* feat: add github device flow for authentication

This will allow us to add a GitHub OAuth provider out-of-the-box
to reduce setup requirements.

* Improve askpass view

* Add routes to improve clarity of git auth

* Redesign the git auth page

* Refactor to add a page view

* Fix sideways layout

* Remove legacy notify

* Fix git auth redirects

* Add E2E tests

* Fix route documentation

* Fix imports

* Remove unused imports

* Fix E2E web test

* Fix friendly message appearance

* Fix layout shifting for full-screen sign-in

* Fix height going to 100%

* Fix comments
This commit is contained in:
Kyle Carberry
2023-06-29 18:58:01 +00:00
committed by GitHub
parent 7f82eca64a
commit 34467a3289
43 changed files with 2743 additions and 511 deletions
+2 -9
View File
@@ -1,7 +1,6 @@
import CssBaseline from "@mui/material/CssBaseline"
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
import { MemoryRouter } from "react-router-dom"
import { withRouter } from "storybook-addon-react-router-v6"
import { HelmetProvider } from "react-helmet-async"
import { dark } from "../src/theme"
import "../src/theme/globalFonts"
@@ -16,13 +15,7 @@ export const decorators = [
</ThemeProvider>
</StyledEngineProvider>
),
(Story) => {
return (
<MemoryRouter>
<Story />
</MemoryRouter>
)
},
withRouter,
(Story) => {
return (
<HelmetProvider>
+16
View File
@@ -5,3 +5,19 @@ export const defaultPort = 3000
export const username = "admin"
export const password = "SomeSecurePassword!"
export const email = "admin@coder.com"
export const gitAuth = {
deviceProvider: "device",
webProvider: "web",
// These ports need to be hardcoded so that they can be
// used in `playwright.config.ts` to set the environment
// variables for the server.
devicePort: 50515,
webPort: 50516,
authPath: "/auth",
tokenPath: "/token",
codePath: "/code",
validatePath: "/validate",
installationsPath: "/installations",
}
+32
View File
@@ -2,6 +2,7 @@ import { expect, Page } from "@playwright/test"
import { spawn } from "child_process"
import { randomUUID } from "crypto"
import path from "path"
import express from "express"
import { TarWriter } from "utils/tar"
import {
Agent,
@@ -227,3 +228,34 @@ const createTemplateVersionTar = async (
const randomName = () => {
return randomUUID().slice(0, 8)
}
// Awaiter is a helper that allows you to wait for a callback to be called.
// It is useful for waiting for events to occur.
export class Awaiter {
private promise: Promise<void>
private callback?: () => void
constructor() {
this.promise = new Promise((r) => (this.callback = r))
}
public done(): void {
if (this.callback) {
this.callback()
} else {
this.promise = Promise.resolve()
}
}
public wait(): Promise<void> {
return this.promise
}
}
export const createServer = async (
port: number,
): Promise<ReturnType<typeof express>> => {
const e = express()
await new Promise<void>((r) => e.listen(port, r))
return e
}
+48 -4
View File
@@ -1,6 +1,6 @@
import { defineConfig } from "@playwright/test"
import path from "path"
import { defaultPort } from "./constants"
import { defaultPort, gitAuth } from "./constants"
export const port = process.env.CODER_E2E_PORT
? Number(process.env.CODER_E2E_PORT)
@@ -10,7 +10,11 @@ const coderMain = path.join(__dirname, "../../enterprise/cmd/coder/main.go")
export const STORAGE_STATE = path.join(__dirname, ".auth.json")
const config = defineConfig({
const localURL = (port: number, path: string): string => {
return `http://localhost:${port}${path}`
}
export default defineConfig({
projects: [
{
name: "setup",
@@ -39,9 +43,49 @@ const config = defineConfig({
`--provisioner-daemons 10 ` +
`--provisioner-daemons-echo ` +
`--provisioner-daemon-poll-interval 50ms`,
env: {
...process.env,
// This is the test provider for git auth with devices!
CODER_GITAUTH_0_ID: gitAuth.deviceProvider,
CODER_GITAUTH_0_TYPE: "github",
CODER_GITAUTH_0_CLIENT_ID: "client",
CODER_GITAUTH_0_DEVICE_FLOW: "true",
CODER_GITAUTH_0_APP_INSTALL_URL:
"https://github.com/apps/coder/installations/new",
CODER_GITAUTH_0_APP_INSTALLATIONS_URL: localURL(
gitAuth.devicePort,
gitAuth.installationsPath,
),
CODER_GITAUTH_0_TOKEN_URL: localURL(
gitAuth.devicePort,
gitAuth.tokenPath,
),
CODER_GITAUTH_0_DEVICE_CODE_URL: localURL(
gitAuth.devicePort,
gitAuth.codePath,
),
CODER_GITAUTH_0_VALIDATE_URL: localURL(
gitAuth.devicePort,
gitAuth.validatePath,
),
CODER_GITAUTH_1_ID: gitAuth.webProvider,
CODER_GITAUTH_1_TYPE: "github",
CODER_GITAUTH_1_CLIENT_ID: "client",
CODER_GITAUTH_1_CLIENT_SECRET: "secret",
CODER_GITAUTH_1_AUTH_URL: localURL(gitAuth.webPort, gitAuth.authPath),
CODER_GITAUTH_1_TOKEN_URL: localURL(gitAuth.webPort, gitAuth.tokenPath),
CODER_GITAUTH_1_DEVICE_CODE_URL: localURL(
gitAuth.webPort,
gitAuth.codePath,
),
CODER_GITAUTH_1_VALIDATE_URL: localURL(
gitAuth.webPort,
gitAuth.validatePath,
),
},
port,
reuseExistingServer: false,
},
})
export default config
+140
View File
@@ -0,0 +1,140 @@
import { test } from "@playwright/test"
import { gitAuth } from "../constants"
import { Endpoints } from "@octokit/types"
import { GitAuthDevice } from "api/typesGenerated"
import { Awaiter, createServer } from "../helpers"
// Ensures that a Git auth provider with the device flow functions and completes!
test("git auth device", async ({ page }) => {
const device: GitAuthDevice = {
device_code: "1234",
user_code: "1234-5678",
expires_in: 900,
interval: 1,
verification_uri: "",
}
// Start a server to mock the GitHub API.
const srv = await createServer(gitAuth.devicePort)
srv.use(gitAuth.validatePath, (req, res) => {
res.write(JSON.stringify(ghUser))
res.end()
})
srv.use(gitAuth.codePath, (req, res) => {
res.write(JSON.stringify(device))
res.end()
})
srv.use(gitAuth.installationsPath, (req, res) => {
res.write(JSON.stringify(ghInstall))
res.end()
})
const token = {
access_token: "",
error: "authorization_pending",
error_description: "",
}
// First we send a result from the API that the token hasn't been
// authorized yet to ensure the UI reacts properly.
const sentPending = new Awaiter()
srv.use(gitAuth.tokenPath, (req, res) => {
res.write(JSON.stringify(token))
res.end()
sentPending.done()
})
await page.goto(`/gitauth/${gitAuth.deviceProvider}`, {
waitUntil: "networkidle",
})
await page.getByText(device.user_code).isVisible()
await sentPending.wait()
// Update the token to be valid and ensure the UI updates!
token.error = ""
token.access_token = "hello-world"
await page.waitForSelector("text=1 organization authorized")
})
test("git auth web", async ({ baseURL, page }) => {
const srv = await createServer(gitAuth.webPort)
// The GitHub validate endpoint returns the currently authenticated user!
srv.use(gitAuth.validatePath, (req, res) => {
res.write(JSON.stringify(ghUser))
res.end()
})
srv.use(gitAuth.tokenPath, (req, res) => {
res.write(JSON.stringify({ access_token: "hello-world" }))
res.end()
})
srv.use(gitAuth.authPath, (req, res) => {
res.redirect(
`${baseURL}/gitauth/${gitAuth.webProvider}/callback?code=1234&state=` +
req.query.state,
)
})
await page.goto(`/gitauth/${gitAuth.webProvider}`, {
waitUntil: "networkidle",
})
// This endpoint doesn't have the installations URL set intentionally!
await page.waitForSelector("text=You've authenticated with GitHub!")
})
const ghUser: Endpoints["GET /user"]["response"]["data"] = {
login: "kylecarbs",
id: 7122116,
node_id: "MDQ6VXNlcjcxMjIxMTY=",
avatar_url: "https://avatars.githubusercontent.com/u/7122116?v=4",
gravatar_id: "",
url: "https://api.github.com/users/kylecarbs",
html_url: "https://github.com/kylecarbs",
followers_url: "https://api.github.com/users/kylecarbs/followers",
following_url:
"https://api.github.com/users/kylecarbs/following{/other_user}",
gists_url: "https://api.github.com/users/kylecarbs/gists{/gist_id}",
starred_url: "https://api.github.com/users/kylecarbs/starred{/owner}{/repo}",
subscriptions_url: "https://api.github.com/users/kylecarbs/subscriptions",
organizations_url: "https://api.github.com/users/kylecarbs/orgs",
repos_url: "https://api.github.com/users/kylecarbs/repos",
events_url: "https://api.github.com/users/kylecarbs/events{/privacy}",
received_events_url: "https://api.github.com/users/kylecarbs/received_events",
type: "User",
site_admin: false,
name: "Kyle Carberry",
company: "@coder",
blog: "https://carberry.com",
location: "Austin, TX",
email: "kyle@carberry.com",
hireable: null,
bio: "hey there",
twitter_username: "kylecarbs",
public_repos: 52,
public_gists: 9,
followers: 208,
following: 31,
created_at: "2014-04-01T02:24:41Z",
updated_at: "2023-06-26T13:03:09Z",
}
const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
installations: [
{
id: 1,
access_tokens_url: "",
account: ghUser,
app_id: 1,
app_slug: "coder",
created_at: "2014-04-01T02:24:41Z",
events: [],
html_url: "",
permissions: {},
repositories_url: "",
repository_selection: "all",
single_file_name: "",
suspended_at: null,
suspended_by: null,
target_id: 1,
target_type: "",
updated_at: "2023-06-26T13:03:09Z",
},
],
total_count: 1,
}
+2
View File
@@ -100,6 +100,7 @@
"yup": "0.32.11"
},
"devDependencies": {
"@octokit/types": "10.0.0",
"@playwright/test": "1.35.1",
"@storybook/addon-actions": "7.0.4",
"@storybook/addon-essentials": "7.0.4",
@@ -150,6 +151,7 @@
"resize-observer": "1.0.4",
"semver": "7.5.2",
"storybook": "7.0.4",
"storybook-addon-react-router-v6": "1.0.2",
"storybook-react-context": "0.6.0",
"ts-proto": "1.150.0",
"typescript": "4.8.2",
+1 -1
View File
@@ -193,7 +193,7 @@ export const AppRouter: FC = () => {
<Route element={<DashboardLayout />}>
<Route index element={<IndexPage />} />
<Route path="gitauth" element={<GitAuthPage />} />
<Route path="gitauth/:provider" element={<GitAuthPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
+22
View File
@@ -799,6 +799,28 @@ export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
}
}
export const getGitAuthProvider = async (
provider: string,
): Promise<TypesGen.GitAuth> => {
const resp = await axios.get(`/api/v2/gitauth/${provider}`)
return resp.data
}
export const getGitAuthDevice = async (
provider: string,
): Promise<TypesGen.GitAuthDevice> => {
const resp = await axios.get(`/api/v2/gitauth/${provider}/device`)
return resp.data
}
export const exchangeGitAuthDevice = async (
provider: string,
req: TypesGen.GitAuthDeviceExchange,
): Promise<void> => {
const resp = await axios.post(`/api/v2/gitauth/${provider}/device`, req)
return resp.data
}
export const getAuditLogs = async (
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {
+44
View File
@@ -415,6 +415,24 @@ export interface GetUsersResponse {
readonly count: number
}
// From codersdk/gitauth.go
export interface GitAuth {
readonly authenticated: boolean
readonly device: boolean
readonly type: string
readonly user?: GitAuthUser
readonly app_installable: boolean
readonly installations: GitAuthAppInstallation[]
readonly app_install_url: string
}
// From codersdk/gitauth.go
export interface GitAuthAppInstallation {
readonly id: number
readonly account: GitAuthUser
readonly configure_url: string
}
// From codersdk/deployment.go
export interface GitAuthConfig {
readonly id: string
@@ -423,9 +441,35 @@ export interface GitAuthConfig {
readonly auth_url: string
readonly token_url: string
readonly validate_url: string
readonly app_install_url: string
readonly app_installations_url: string
readonly regex: string
readonly no_refresh: boolean
readonly scopes: string[]
readonly device_flow: boolean
readonly device_code_url: string
}
// From codersdk/gitauth.go
export interface GitAuthDevice {
readonly device_code: string
readonly user_code: string
readonly verification_uri: string
readonly expires_in: number
readonly interval: number
}
// From codersdk/gitauth.go
export interface GitAuthDeviceExchange {
readonly device_code: string
}
// From codersdk/gitauth.go
export interface GitAuthUser {
readonly login: string
readonly avatar_url: string
readonly profile_url: string
readonly name: string
}
// From codersdk/gitsshkey.go
@@ -110,5 +110,7 @@ const useStyles = makeStyles({
siteContent: {
flex: 1,
paddingBottom: dashboardContentBottomPadding, // Add bottom space since we don't use a footer
display: "flex",
flexDirection: "column",
},
})
-1
View File
@@ -20,7 +20,6 @@ const useStyles = makeStyles(() => ({
margin: "0 auto",
maxWidth: ({ maxWidth }: { maxWidth: number }) => maxWidth,
padding: `0 ${sidePadding}px`,
flex: 1,
width: "100%",
},
}))
@@ -2,8 +2,15 @@ import { makeStyles } from "@mui/styles"
import { FC, ReactNode } from "react"
export const useStyles = makeStyles((theme) => ({
"@global": {
// Necessary for when this is on lonely pages!
"html, body, #root, #storybook-root": {
height: "100vh",
},
},
root: {
height: "100vh",
flex: 1,
height: "-webkit-fill-available",
display: "flex",
justifyContent: "center",
alignItems: "center",
@@ -14,8 +21,10 @@ export const useStyles = makeStyles((theme) => ({
alignItems: "center",
},
container: {
marginTop: theme.spacing(-8),
maxWidth: 385,
display: "flex",
flexDirection: "column",
alignItems: "center",
},
footer: {
fontSize: 12,
+1 -1
View File
@@ -44,7 +44,7 @@ const useStyles = makeStyles((theme) => ({
margin: 0,
marginBottom: theme.spacing(2),
marginTop: theme.spacing(2),
lineHeight: 1,
lineHeight: 1.25,
"& strong": {
fontWeight: 600,
@@ -1,12 +0,0 @@
import { ComponentMeta, Story } from "@storybook/react"
import GitAuthPage from "./GitAuthPage"
export default {
title: "pages/GitAuthPage",
component: GitAuthPage,
} as ComponentMeta<typeof GitAuthPage>
const Template: Story = (args) => <GitAuthPage {...args} />
export const Default = Template.bind({})
Default.args = {}
+85 -47
View File
@@ -1,64 +1,102 @@
import Button from "@mui/material/Button"
import { makeStyles } from "@mui/styles"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Welcome } from "components/Welcome/Welcome"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import {
exchangeGitAuthDevice,
getGitAuthDevice,
getGitAuthProvider,
} from "api/api"
import { usePermissions } from "hooks"
import { FC, useEffect } from "react"
import { Link as RouterLink } from "react-router-dom"
import { useParams } from "react-router-dom"
import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService"
import GitAuthPageView from "./GitAuthPageView"
import { ApiErrorResponse } from "api/errors"
import { isAxiosError } from "axios"
const GitAuthPage: FC = () => {
const styles = useStyles()
const { provider } = useParams()
if (!provider) {
throw new Error("provider must exist")
}
const permissions = usePermissions()
const queryClient = useQueryClient()
const getGitAuthProviderQuery = useQuery({
queryKey: ["gitauth", provider],
queryFn: () => getGitAuthProvider(provider),
refetchOnWindowFocus: true,
})
const getGitAuthDeviceQuery = useQuery({
enabled:
Boolean(!getGitAuthProviderQuery.data?.authenticated) &&
Boolean(getGitAuthProviderQuery.data?.device),
queryFn: () => getGitAuthDevice(provider),
queryKey: ["gitauth", provider, "device"],
refetchOnMount: false,
})
const exchangeGitAuthDeviceQuery = useQuery({
queryFn: () =>
exchangeGitAuthDevice(provider, {
device_code: getGitAuthDeviceQuery.data?.device_code || "",
}),
queryKey: ["gitauth", provider, getGitAuthDeviceQuery.data?.device_code],
enabled: Boolean(getGitAuthDeviceQuery.data),
onSuccess: () => {
// Force a refresh of the Git auth status.
queryClient.invalidateQueries(["gitauth", provider]).catch((ex) => {
console.error("invalidate queries", ex)
})
},
retry: true,
retryDelay: (getGitAuthDeviceQuery.data?.interval || 5) * 1000,
refetchOnWindowFocus: (query) =>
query.state.status === "success" ? false : "always",
})
useEffect(() => {
if (!getGitAuthProviderQuery.data?.authenticated) {
return
}
// This is used to notify the parent window that the Git auth token has been refreshed.
// It's critical in the create workspace flow!
// eslint-disable-next-line compat/compat -- It actually is supported... not sure why it's complaining.
const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL)
// The message doesn't matter, any message refreshes the page!
bc.postMessage("noop")
window.close()
}, [])
}, [getGitAuthProviderQuery.data?.authenticated])
if (getGitAuthProviderQuery.isLoading || !getGitAuthProviderQuery.data) {
return null
}
let deviceExchangeError: ApiErrorResponse | undefined
if (isAxiosError(exchangeGitAuthDeviceQuery.failureReason)) {
deviceExchangeError =
exchangeGitAuthDeviceQuery.failureReason.response?.data
}
if (
!getGitAuthProviderQuery.data.authenticated &&
!getGitAuthProviderQuery.data.device
) {
window.location.href = `/gitauth/${provider}/callback`
return null
}
return (
<SignInLayout>
<Welcome message="Authenticated with Git!" />
<p className={styles.text}>
Your Git authentication token will be refreshed to keep you signed in.
</p>
<div className={styles.links}>
<Button component={RouterLink} size="large" to="/workspaces" fullWidth>
Go to workspaces
</Button>
</div>
</SignInLayout>
<GitAuthPageView
gitAuth={getGitAuthProviderQuery.data}
onReauthenticate={() => {
queryClient.setQueryData(["gitauth", provider], {
...getGitAuthProviderQuery.data,
authenticated: false,
})
}}
viewGitAuthConfig={permissions.viewGitAuthConfig}
deviceExchangeError={deviceExchangeError}
gitAuthDevice={getGitAuthDeviceQuery.data}
/>
)
}
export default GitAuthPage
const useStyles = makeStyles((theme) => ({
title: {
fontSize: theme.spacing(4),
fontWeight: 400,
lineHeight: "140%",
margin: 0,
},
text: {
fontSize: 16,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(4),
textAlign: "center",
lineHeight: "160%",
},
lineBreak: {
whiteSpace: "nowrap",
},
links: {
display: "flex",
justifyContent: "flex-end",
paddingTop: theme.spacing(1),
},
}))
@@ -0,0 +1,119 @@
import { Meta, StoryFn } from "@storybook/react"
import GitAuthPageView, { GitAuthPageViewProps } from "./GitAuthPageView"
export default {
title: "pages/GitAuthPageView",
component: GitAuthPageView,
} as Meta<typeof GitAuthPageView>
const Template: StoryFn<GitAuthPageViewProps> = (args) => (
<GitAuthPageView {...args} />
)
export const WebAuthenticated = Template.bind({})
WebAuthenticated.args = {
gitAuth: {
type: "BitBucket",
authenticated: true,
device: false,
installations: [],
app_install_url: "",
app_installable: false,
user: {
avatar_url: "",
login: "kylecarbs",
name: "Kyle Carberry",
profile_url: "",
},
},
}
export const DeviceUnauthenticated = Template.bind({})
DeviceUnauthenticated.args = {
gitAuth: {
type: "GitHub",
authenticated: false,
device: true,
installations: [],
app_install_url: "",
app_installable: false,
},
gitAuthDevice: {
device_code: "1234-5678",
expires_in: 900,
interval: 5,
user_code: "ABCD-EFGH",
verification_uri: "",
},
}
export const DeviceUnauthenticatedError = Template.bind({})
DeviceUnauthenticatedError.args = {
gitAuth: {
type: "GitHub",
authenticated: false,
device: true,
installations: [],
app_install_url: "",
app_installable: false,
},
gitAuthDevice: {
device_code: "1234-5678",
expires_in: 900,
interval: 5,
user_code: "ABCD-EFGH",
verification_uri: "",
},
deviceExchangeError: {
message: "Error exchanging device code.",
detail: "expired_token",
},
}
export const DeviceAuthenticatedNotInstalled = Template.bind({})
DeviceAuthenticatedNotInstalled.args = {
viewGitAuthConfig: true,
gitAuth: {
type: "GitHub",
authenticated: true,
device: true,
installations: [],
app_install_url: "https://example.com",
app_installable: true,
user: {
avatar_url: "",
login: "kylecarbs",
name: "Kyle Carberry",
profile_url: "",
},
},
}
export const DeviceAuthenticatedInstalled = Template.bind({})
DeviceAuthenticatedInstalled.args = {
gitAuth: {
type: "GitHub",
authenticated: true,
device: true,
installations: [
{
configure_url: "https://example.com",
id: 1,
account: {
avatar_url: "https://github.com/coder.png",
login: "coder",
name: "Coder",
profile_url: "https://github.com/coder",
},
},
],
app_install_url: "https://example.com",
app_installable: true,
user: {
avatar_url: "",
login: "kylecarbs",
name: "Kyle Carberry",
profile_url: "",
},
},
}
@@ -0,0 +1,277 @@
import OpenInNewIcon from "@mui/icons-material/OpenInNew"
import RefreshIcon from "@mui/icons-material/Refresh"
import CircularProgress from "@mui/material/CircularProgress"
import Link from "@mui/material/Link"
import Tooltip from "@mui/material/Tooltip"
import { makeStyles } from "@mui/styles"
import { ApiErrorResponse } from "api/errors"
import { GitAuth, GitAuthDevice } from "api/typesGenerated"
import { Alert } from "components/Alert/Alert"
import { Avatar } from "components/Avatar/Avatar"
import { CopyButton } from "components/CopyButton/CopyButton"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Welcome } from "components/Welcome/Welcome"
import { FC, useEffect } from "react"
import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService"
export interface GitAuthPageViewProps {
gitAuth: GitAuth
viewGitAuthConfig: boolean
gitAuthDevice?: GitAuthDevice
deviceExchangeError?: ApiErrorResponse
onReauthenticate: () => void
}
const GitAuthPageView: FC<GitAuthPageViewProps> = ({
deviceExchangeError,
gitAuth,
gitAuthDevice,
onReauthenticate,
viewGitAuthConfig,
}) => {
const styles = useStyles()
useEffect(() => {
if (!gitAuth.authenticated) {
return
}
// This is used to notify the parent window that the Git auth token has been refreshed.
// It's critical in the create workspace flow!
// eslint-disable-next-line compat/compat -- It actually is supported... not sure why it's complaining.
const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL)
// The message doesn't matter, any message refreshes the page!
bc.postMessage("noop")
}, [gitAuth.authenticated])
if (!gitAuth.authenticated) {
return (
<SignInLayout>
<Welcome message={`Authenticate with ${gitAuth.type}`} />
{gitAuth.device && (
<GitDeviceAuth
deviceExchangeError={deviceExchangeError}
gitAuthDevice={gitAuthDevice}
/>
)}
</SignInLayout>
)
}
const hasInstallations = gitAuth.installations.length > 0
// We only want to wrap this with a link if an install URL is available!
let installTheApp: JSX.Element = <>{`install the ${gitAuth.type} App`}</>
if (gitAuth.app_install_url) {
installTheApp = (
<Link href={gitAuth.app_install_url} target="_blank" rel="noreferrer">
{installTheApp}
</Link>
)
}
return (
<SignInLayout>
<Welcome message={`You've authenticated with ${gitAuth.type}!`} />
<p className={styles.text}>
Hey @{gitAuth.user?.login} 👋!{" "}
{(!gitAuth.app_installable || gitAuth.installations.length > 0) &&
"You are now authenticated with Git. Feel free to close this window!"}
</p>
{gitAuth.installations.length > 0 && (
<div className={styles.authorizedInstalls}>
{gitAuth.installations.map((install) => {
if (!install.account) {
return
}
return (
<Tooltip key={install.id} title={install.account.login}>
<Link
href={install.account.profile_url}
target="_blank"
rel="noreferrer"
>
<Avatar
size="sm"
src={install.account.avatar_url}
colorScheme="darken"
>
{install.account.login}
</Avatar>
</Link>
</Tooltip>
)
})}
&nbsp;
{gitAuth.installations.length} organization
{gitAuth.installations.length !== 1 && "s are"} authorized
</div>
)}
<div className={styles.links}>
{!hasInstallations && gitAuth.app_installable && (
<Alert severity="warning" className={styles.installAlert}>
You must {installTheApp} to clone private repositories. Accounts
will appear here once authorized.
</Alert>
)}
{viewGitAuthConfig &&
gitAuth.app_install_url &&
gitAuth.app_installable && (
<Link
href={gitAuth.app_install_url}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<OpenInNewIcon fontSize="small" />
{gitAuth.installations.length > 0
? "Configure"
: "Install"} the {gitAuth.type} App
</Link>
)}
<Link
className={styles.link}
href="#"
onClick={() => {
onReauthenticate()
}}
>
<RefreshIcon /> Reauthenticate
</Link>
</div>
</SignInLayout>
)
}
const GitDeviceAuth: FC<{
gitAuthDevice?: GitAuthDevice
deviceExchangeError?: ApiErrorResponse
}> = ({ gitAuthDevice, deviceExchangeError }) => {
const styles = useStyles()
let status = (
<p className={styles.status}>
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
Checking for authentication...
</p>
)
if (deviceExchangeError) {
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
switch (deviceExchangeError.detail) {
case "authorization_pending":
break
case "expired_token":
status = (
<Alert severity="error">
The one-time code has expired. Refresh to get a new one!
</Alert>
)
break
case "access_denied":
status = (
<Alert severity="error">Access to the Git provider was denied.</Alert>
)
break
default:
status = (
<Alert severity="error">
An unknown error occurred. Please try again:{" "}
{deviceExchangeError.message}
</Alert>
)
break
}
}
if (!gitAuthDevice) {
return <CircularProgress />
}
return (
<div>
<p className={styles.text}>
Copy your one-time code:&nbsp;
<div className={styles.copyCode}>
<span className={styles.code}>{gitAuthDevice.user_code}</span>&nbsp;{" "}
<CopyButton text={gitAuthDevice.user_code} />
</div>
<br />
Then open the link below and paste it:
</p>
<div className={styles.links}>
<Link
className={styles.link}
href={gitAuthDevice.verification_uri}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon fontSize="small" />
Open and Paste
</Link>
</div>
{status}
</div>
)
}
export default GitAuthPageView
const useStyles = makeStyles((theme) => ({
text: {
fontSize: 16,
color: theme.palette.text.secondary,
textAlign: "center",
lineHeight: "160%",
margin: 0,
},
copyCode: {
display: "inline-flex",
alignItems: "center",
},
code: {
fontWeight: "bold",
color: theme.palette.text.primary,
},
installAlert: {
margin: theme.spacing(2),
},
links: {
display: "flex",
gap: theme.spacing(0.5),
margin: theme.spacing(2),
flexDirection: "column",
},
link: {
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
gap: theme.spacing(1),
},
status: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing(1),
color: theme.palette.text.disabled,
},
authorizedInstalls: {
display: "flex",
gap: 4,
color: theme.palette.text.disabled,
margin: theme.spacing(4),
},
}))
+1
View File
@@ -1581,6 +1581,7 @@ export const MockPermissions: Permissions = {
viewDeploymentValues: true,
viewUpdateCheck: true,
viewDeploymentStats: true,
viewGitAuthConfig: true,
}
export const MockDeploymentConfig: Types.DeploymentConfig = {
+7
View File
@@ -17,6 +17,7 @@ export const checks = {
viewDeploymentValues: "viewDeploymentValues",
createGroup: "createGroup",
viewUpdateCheck: "viewUpdateCheck",
viewGitAuthConfig: "viewGitAuthConfig",
viewDeploymentStats: "viewDeploymentStats",
} as const
@@ -75,6 +76,12 @@ export const permissionsToCheck = {
},
action: "read",
},
[checks.viewGitAuthConfig]: {
object: {
resource_type: "deployment_config",
},
action: "read",
},
[checks.viewDeploymentStats]: {
object: {
resource_type: "deployment_stats",
+24
View File
@@ -1902,6 +1902,18 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@octokit/openapi-types@^18.0.0":
version "18.0.0"
resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-18.0.0.tgz#f43d765b3c7533fd6fb88f3f25df079c24fccf69"
integrity sha512-V8GImKs3TeQRxRtXFpG2wl19V7444NIOTDF24AWuIbmNaNYOQMWRbjcGDXV5B+0n887fgDcuMNOmlul+k+oJtw==
"@octokit/types@10.0.0":
version "10.0.0"
resolved "https://registry.yarnpkg.com/@octokit/types/-/types-10.0.0.tgz#7ee19c464ea4ada306c43f1a45d444000f419a4a"
integrity sha512-Vm8IddVmhCgU1fxC1eyinpwqzXPEYu0NrYzD3YZjlGjyftdLBTeqNblRC0jmJmgxbJIsQlyogVeGnrNaaMVzIg==
dependencies:
"@octokit/openapi-types" "^18.0.0"
"@open-draft/until@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca"
@@ -10061,6 +10073,11 @@ react-inspector@^6.0.0:
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.1.tgz#1a37f0165d9df81ee804d63259eaaeabe841287d"
integrity sha512-cxKSeFTf7jpSSVddm66sKdolG90qURAX3g1roTeaN6x0YEbtWc8JpmFN9+yIqLNH2uEkYerWLtJZIXRIFuBKrg==
react-inspector@^6.0.1:
version "6.0.2"
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.2.tgz#aa3028803550cb6dbd7344816d5c80bf39d07e9d"
integrity sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==
react-is@18.1.0:
version "18.1.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67"
@@ -10979,6 +10996,13 @@ store2@^2.12.0, store2@^2.14.2:
resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068"
integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==
storybook-addon-react-router-v6@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/storybook-addon-react-router-v6/-/storybook-addon-react-router-v6-1.0.2.tgz#0f1239de31291821c93e8266079b8f6235a9c717"
integrity sha512-38W+9D2sIrYAi+oRSbsLhR/umNoLVw2DWF84Jp4f/ZoB8Cg0Qtbvwk043oHqzNOpZrfgj0FaV006oaJBVpE8Kw==
dependencies:
react-inspector "^6.0.1"
storybook-react-context@0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/storybook-react-context/-/storybook-react-context-0.6.0.tgz#06c7b48dc95f4619cf12e59429305fbd6f2b1373"