mirror of
https://github.com/coder/coder.git
synced 2026-10-29 17:44:33 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
{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:
|
||||
<div className={styles.copyCode}>
|
||||
<span className={styles.code}>{gitAuthDevice.user_code}</span> {" "}
|
||||
<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),
|
||||
},
|
||||
}))
|
||||
@@ -1581,6 +1581,7 @@ export const MockPermissions: Permissions = {
|
||||
viewDeploymentValues: true,
|
||||
viewUpdateCheck: true,
|
||||
viewDeploymentStats: true,
|
||||
viewGitAuthConfig: true,
|
||||
}
|
||||
|
||||
export const MockDeploymentConfig: Types.DeploymentConfig = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user