diff --git a/.env.example b/.env.example index 2137af67..7530165e 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,24 @@ GIT_REPO_ADMIN_DATA="https://github.com/AVAnnotate/admin-data.git" GIT_REPO_ORG="AVAnnotate" GIT_ADMIN_REPO="admin" PUBLIC_GIT_REPO_PROJECT_TEMPLATE="project-template" -PUBLIC_REDIRECT_URL="http://localhost:4321" \ No newline at end of file +PUBLIC_REDIRECT_URL="http://localhost:4321" + +# UTexas GitHub Enterprise Managed User (EMU) OAuth credentials +# Create an OAuth App inside the UTexas enterprise at: +# https://github.com/enterprises/utexas-internal +# Set the Authorization callback URL to: {PUBLIC_REDIRECT_URL}/git-enterprise +# Leave blank to hide the UTexas EID login button. +PUBLIC_UTEXAS_GITHUB_CLIENT_ID= +UTEXAS_GITHUB_CLIENT_SECRET= + +# UTexas enterprise project template +# GitHub Enterprise Managed Users cannot access templates outside their enterprise. +# Fork AVAnnotate/project-template into a UTexas enterprise org, mark it as a +# template repository, then set these two variables so AVAnnotate can use it. +# +# UTEXAS_GIT_REPO_ORG – the GitHub org (inside the enterprise) that owns +# the forked template (e.g. "utexas-avannotate") +# UTEXAS_GIT_REPO_PROJECT_TEMPLATE – the repo name of the fork; defaults to +# "project-template" if left blank +UTEXAS_GIT_REPO_ORG= +UTEXAS_GIT_REPO_PROJECT_TEMPLATE= \ No newline at end of file diff --git a/README.md b/README.md index 7d23575e..3e207f95 100644 --- a/README.md +++ b/README.md @@ -10,57 +10,41 @@ You can also run a local environment without linking to Netlify by running `npm Note that running the site locally still requires authentication via GitHub, and changes made to projects will be applied, so be sure to create testing projects for playing around with any new and untested features. -# Astro Basics +### UTexas GitHub Enterprise Managed User (EMU) login -```sh -npm create astro@latest -- --template basics -``` +A second "Sign in with UTexas EID" button can be shown on the sign-in page to let UTexas users authenticate via the [GitHub Enterprise Managed Users SSO](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-iam/configuring-authentication-for-enterprise-managed-users/configuring-saml-single-sign-on-for-enterprise-managed-users) for the `utexas-internal` enterprise (). -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) -[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics) -[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json) +To enable this button you need a separate OAuth App registered inside the UTexas enterprise and two additional environment variables: -> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! +| Variable | Description | +| --- | --- | +| `PUBLIC_UTEXAS_GITHUB_CLIENT_ID` | Client ID of the OAuth App created inside the UTexas enterprise | +| `UTEXAS_GITHUB_CLIENT_SECRET` | Client secret for that OAuth App (server-side only) | -![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554) +**Creating the OAuth App:** +1. Navigate to (requires enterprise admin access). +2. Go to *Settings → OAuth Apps → New OAuth App*. (this is actually under the developer section at the very bottom of your org's settings on the left.) +3. Set **Authorization callback URL** to `{PUBLIC_REDIRECT_URL}/git-enterprise` (e.g. `https://avannotate.netlify.app/git-enterprise`). +4. Copy the **Client ID** into `PUBLIC_UTEXAS_GITHUB_CLIENT_ID` and generate/copy a **Client secret** into `UTEXAS_GITHUB_CLIENT_SECRET`. -## 🚀 Project Structure +When `PUBLIC_UTEXAS_GITHUB_CLIENT_ID` is blank (the default), the UTexas EID button is hidden, so the sign-in page works exactly as before for non-UTexas deployments. -Inside of your Astro project, you'll see the following folders and files: +#### Creating projects as a UTexas EMU user -```text -/ -├── public/ -│ └── favicon.svg -├── src/ -│ ├── components/ -│ │ └── Card.astro -│ ├── layouts/ -│ │ └── Layout.astro -│ └── pages/ -│ └── index.astro -└── package.json -``` +GitHub Enterprise Managed Users are isolated to their enterprise and cannot access GitHub repositories outside it — including the AVAnnotate project template (`AVAnnotate/project-template`). Without extra setup, UTexas users will see a "repo create failed" error when they try to create a new project. -Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. +To fix this, fork the template into the UTexas enterprise and tell AVAnnotate where to find it: -There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. +1. **Fork the template** — inside the UTexas enterprise, fork or import [`AVAnnotate/project-template`](https://github.com/AVAnnotate/project-template) into a UTexas enterprise org (e.g. `utexas-avannotate/project-template`). +2. **Mark it as a template** — in the forked repo's *Settings → General*, check **"Template repository"**. +3. **Set the two new environment variables**: -Any static assets, like images, can be placed in the `public/` directory. +| Variable | Description | +| --- | --- | +| `UTEXAS_GIT_REPO_ORG` | GitHub org (inside the enterprise) that owns the forked template, e.g. `utexas-avannotate` | +| `UTEXAS_GIT_REPO_PROJECT_TEMPLATE` | Repo name of the fork; leave blank to use `project-template` | -## 🧞 Commands +With these variables set, UTexas users' project creation calls will use the enterprise copy of the template instead of the one outside the enterprise. -All commands are run from the root of the project, from a terminal: - -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:4321` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | - -## 👀 Want to learn more? Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/src/apps/NewProject/NewProject.tsx b/src/apps/NewProject/NewProject.tsx index 1579dff8..cb54f784 100644 --- a/src/apps/NewProject/NewProject.tsx +++ b/src/apps/NewProject/NewProject.tsx @@ -44,7 +44,7 @@ export const NewProject = (props: NewProjectProps) => { additionalUsers: project.additional_users.map((u) => u.login_name), language: project.language, autoPopulateHomePage: project.auto_populate_home_page, - visibility: project.is_private ? 'private' : 'public', + is_private: !!project.is_private, generate_pages_site: !!project.generate_pages_site, tags: project.tags, }; diff --git a/src/backend/projectHelpers.ts b/src/backend/projectHelpers.ts index c6c2411e..b8bed48d 100644 --- a/src/backend/projectHelpers.ts +++ b/src/backend/projectHelpers.ts @@ -42,15 +42,23 @@ export const parseURL = (url: string) => { const TEMPLATE_BRANCH = import.meta.env.OVERRIDE_TEMPLATE_BRANCH || 'main'; export const getOrgs = async ( - userInfo: UserInfo + userInfo: UserInfo, + authProvider?: string ): Promise => { const orgs = await getUserOrgs(userInfo.token); - orgs.unshift({ - login: userInfo.profile.gitHubName, - url: `https://https://github.com/${userInfo.profile.gitHubName}`, - description: '', - }); + // Enterprise Managed Users (EMU) cannot create repositories in their personal + // account — only in organisations. Skip prepending the personal account when + // the session was initiated via an enterprise OAuth app (e.g. utexas). + const isEnterpriseUser = !!authProvider && authProvider !== ''; + + if (!isEnterpriseUser) { + orgs.unshift({ + login: userInfo.profile.gitHubName, + url: `https://github.com/${userInfo.profile.gitHubName}`, + description: '', + }); + } return orgs.map((o) => ({ orgName: o.login, diff --git a/src/components/ProjectForm/NewProjectForm.tsx b/src/components/ProjectForm/NewProjectForm.tsx index 6457b745..dbb45b8a 100644 --- a/src/components/ProjectForm/NewProjectForm.tsx +++ b/src/components/ProjectForm/NewProjectForm.tsx @@ -38,7 +38,7 @@ const FormContents = (props: NewProjectFormProps) => { const { headerMap } = useContext(SpreadsheetInputContext); const emptyProject: Project = { - github_org: props.orgs[0].orgName, + github_org: props.orgs[0]?.orgName ?? '', is_private: false, generate_pages_site: true, title: '', @@ -106,15 +106,25 @@ const FormContents = (props: NewProjectFormProps) => { return (

{t['General']}

- ({ - value: o.orgName, - label: o.orgName, - }))} - required - /> + {props.orgs.length === 0 ? ( +
+ { + t[ + '_no_orgs_warning_' + ] + } +
+ ) : ( + ({ + value: o.orgName, + label: o.orgName, + }))} + required + /> + )} { diff --git a/src/components/ProjectForm/ProjectForm.css b/src/components/ProjectForm/ProjectForm.css index f188f4d1..e3546104 100644 --- a/src/components/ProjectForm/ProjectForm.css +++ b/src/components/ProjectForm/ProjectForm.css @@ -28,3 +28,12 @@ .project-form-actions-container > button { min-width: 200px; } + +.project-form-no-orgs-warning { + padding: 12px 16px; + border: 1px solid var(--amber-7, #f5a623); + border-radius: 6px; + background-color: var(--amber-2, #fff8eb); + color: var(--amber-11, #7e4a0d); + margin-bottom: 16px; +} diff --git a/src/components/SignInGithub/SignInGitHub.css b/src/components/SignInGithub/SignInGitHub.css index d276d824..8c26ca82 100644 --- a/src/components/SignInGithub/SignInGitHub.css +++ b/src/components/SignInGithub/SignInGitHub.css @@ -18,7 +18,12 @@ border: 1px solid black; color: white !important; background-color: #015f86; - width: 2o0px; + width: 200px; +} + +/* UTexas burnt-orange accent for the Enterprise Managed User button */ +.sign-in-anchor-utexas { + background-color: #bf5700 !important; } .authorize-anchor { diff --git a/src/components/SignInGithub/SignInGitHub.tsx b/src/components/SignInGithub/SignInGitHub.tsx index f6b75df7..73d1d3fa 100644 --- a/src/components/SignInGithub/SignInGitHub.tsx +++ b/src/components/SignInGithub/SignInGitHub.tsx @@ -6,24 +6,41 @@ import type { Translations } from '@ty/Types.ts'; interface SignInGitHubProps { i18n: Translations; } -// href= + +// GitHub logo SVG shared by all sign-in buttons. +const GitHubLogo = () => ( + + + +); export const SignInGitHub = (props: SignInGitHubProps) => { + // Show the loading overlay whenever any sign-in link is clicked. useEffect(() => { - const fireEvent = document.getElementById('sign-in'); - - if (fireEvent) { - fireEvent.addEventListener('click', () => { - const elOverlay = document.getElementById('page-loader-overlay'); - const elSpinner = document.getElementById('page-loader-spinner'); + const showLoader = () => { + const elOverlay = document.getElementById('page-loader-overlay'); + const elSpinner = document.getElementById('page-loader-spinner'); + if (elOverlay && elSpinner) { + elOverlay.classList.add('loading-state'); + elSpinner.classList.add('loading'); + } + }; - if (elOverlay && elSpinner) { - elOverlay.classList.add('loading-state'); - elSpinner.classList.add('loading'); - } - }); - } + ['sign-in', 'sign-in-utexas'].forEach((id) => { + document.getElementById(id)?.addEventListener('click', showLoader); + }); }, []); + + // Only show the UTexas EID button when the enterprise OAuth app is configured. + const utexasClientId = import.meta.env.PUBLIC_UTEXAS_GITHUB_CLIENT_ID; + return (
{ }/git&scope=repo%20workflow`} >
- - - +
{props.i18n.t['Sign in with GitHub']}
+ {utexasClientId && ( + +
+ +
+ {props.i18n.t['Sign in with UTexas EID']} +
+
+
+ )}
- - - +
{props.i18n.t['Reauthorize App and Organizations']}
diff --git a/src/i18n/en/error.json b/src/i18n/en/error.json index 3ae92ab9..e7a75285 100644 --- a/src/i18n/en/error.json +++ b/src/i18n/en/error.json @@ -3,6 +3,7 @@ "Something went wrong.": "Something went wrong.", "_repo_exists_": "The repository name already exists in the GitHub Organization.", "_repo_create_failed_": "The repository was not successfully created", + "_repo_visibility_change_failed_": "Repository was created but could not be set to internal visibility. Please delete the repository from GitHub and try again.", "_failed_pages_enable_": "Failed to enable GitHub Pages for this repository", "_failed_adding_topic_": "Failed to add the AVAnnotate-Project topis to repository", "_failed_adding_collaborators_": "Failed to add the additional collaborators to the project.", diff --git a/src/i18n/en/new-project.json b/src/i18n/en/new-project.json index 4da1dfeb..586784ca 100644 --- a/src/i18n/en/new-project.json +++ b/src/i18n/en/new-project.json @@ -37,5 +37,6 @@ "Use Private Repository": "Private Repository", "_private_repository_helper_text_": "Private repositories do not generate GitHub Pages sites.", "Generate GitHub Pages Site": "Generate GitHub Pages Site", - "_generate_pages_site_helper_text_": "Generate a GitHub Pages site. If unchecked a static site which you can deploy will be created and checked into your project repository." + "_generate_pages_site_helper_text_": "Generate a GitHub Pages site. If unchecked a static site which you can deploy will be created and checked into your project repository.", + "_no_orgs_warning_": "Your account is a GitHub Enterprise Managed User account. You must be a member of a GitHub organization to create a project. Please contact your organization administrator to be added to an organization." } diff --git a/src/i18n/en/sign-in.json b/src/i18n/en/sign-in.json index 4ea7b1b1..e0231476 100644 --- a/src/i18n/en/sign-in.json +++ b/src/i18n/en/sign-in.json @@ -1,5 +1,6 @@ { "Sign in with GitHub": "Sign in with GitHub", + "Sign in with UTexas EID": "Sign in with UTexas EID", "Welcome To": "Welcome To", "Reauthorize App and Organizations": "Reauthorize App and Organizations" } \ No newline at end of file diff --git a/src/lib/GitHub/config.ts b/src/lib/GitHub/config.ts new file mode 100644 index 00000000..fe072590 --- /dev/null +++ b/src/lib/GitHub/config.ts @@ -0,0 +1,33 @@ +export type RepoVisibility = 'private' | 'public' | 'internal'; + +export const getEnterpriseGitHubOrgs = (): string[] => { + const enterpriseOrgs = + import.meta.env.PUBLIC_GITHUB_ENTERPRISE_ORGS || + import.meta.env.GITHUB_ENTERPRISE_ORGS || + ''; + + return enterpriseOrgs + .split(',') + .map((org: string) => org.trim().toLowerCase()) + .filter(Boolean); +}; + +export const isEnterpriseGitHubOrg = (org: string): boolean => + getEnterpriseGitHubOrgs().includes(org.trim().toLowerCase()); + +export const shouldIncludeSlugInBase = ( + org: string, + authProvider?: string +): boolean => + !( + (authProvider ?? '').trim().toLowerCase() === 'utexas' || + isEnterpriseGitHubOrg(org) + ); + +export const getTemplateOwnerForDestinationOrg = (org: string): string => { + if (isEnterpriseGitHubOrg(org) && import.meta.env.GIT_REPO_ORG_ENTERPRISE) { + return import.meta.env.GIT_REPO_ORG_ENTERPRISE; + } + + return import.meta.env.GIT_REPO_ORG; +}; diff --git a/src/lib/GitHub/index.ts b/src/lib/GitHub/index.ts index 06578a0d..aba64854 100644 --- a/src/lib/GitHub/index.ts +++ b/src/lib/GitHub/index.ts @@ -1,3 +1,19 @@ +import { + getEnterpriseGitHubOrgs, + getTemplateOwnerForDestinationOrg, + isEnterpriseGitHubOrg, + shouldIncludeSlugInBase, +} from './config.ts'; +import type { RepoVisibility } from './config.ts'; + +export { + getEnterpriseGitHubOrgs, + getTemplateOwnerForDestinationOrg, + isEnterpriseGitHubOrg, + shouldIncludeSlugInBase, +}; +export type { RepoVisibility } from './config.ts'; + export const paginate = async (url: string, token: string) => { let results: any[] = []; @@ -153,29 +169,39 @@ export const createRepositoryFromTemplate = async ( token: string, newRepoName: string, description: string, - visibility?: 'private' | 'public' // Defaults to private + visibility?: 'private' | 'public' | 'internal', // Defaults to private + templateOwner?: string // Defaults to GIT_REPO_ORG ): Promise => { + const owner = templateOwner || import.meta.env.GIT_REPO_ORG; const body = { owner: org, name: newRepoName, description: description, - private: visibility !== 'public', + private: visibility === 'private' || visibility === 'internal', + }; + const url = `https://api.github.com/repos/${owner}/${templateRepo}/generate`; + const headers = { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }; + const requestOptions = { + method: 'POST', + headers, + body: JSON.stringify(body), }; - return await fetch( - `https://api.github.com/repos/${ - import.meta.env.GIT_REPO_ORG - }/${templateRepo}/generate`, - { - method: 'POST', - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - body: JSON.stringify(body), - } - ); + console.info('Creating repository from template', { + url, + method: requestOptions.method, + body, + headers: { + ...headers, + Authorization: 'Bearer [redacted]', + }, + }); + + return await fetch(url, requestOptions); }; export const addRepositoryHomepage = async ( @@ -225,10 +251,6 @@ export const enablePages = async ( token: string ): Promise => { const body = { - source: { - branch: 'main', - path: '/', - }, build_type: 'workflow', }; return await fetch(`https://api.github.com/repos/${org}/${repo}/pages`, { @@ -372,8 +394,13 @@ export const changeRepoVisibility = async ( token: string, org: string, slug: string, - isPrivate: boolean + visibility: boolean | RepoVisibility ): Promise => { + const body = + typeof visibility === 'boolean' + ? { private: visibility } + : { visibility: visibility }; + return await fetch(`https://api.github.com/repos/${org}/${slug}`, { method: 'PATCH', headers: { @@ -381,9 +408,7 @@ export const changeRepoVisibility = async ( Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28', }, - body: JSON.stringify({ - private: isPrivate, - }), + body: JSON.stringify(body), }); }; diff --git a/src/pages/[lang]/projects/new.astro b/src/pages/[lang]/projects/new.astro index f6d38bdd..ea4338f3 100644 --- a/src/pages/[lang]/projects/new.astro +++ b/src/pages/[lang]/projects/new.astro @@ -19,7 +19,8 @@ if (!info) { return Astro.redirect(`/${lang}/sign-in`); } -const orgs = await getOrgs(info); +const authProvider = Astro.cookies.get('auth-provider')?.value ?? ''; +const orgs = await getOrgs(info, authProvider); --- diff --git a/src/pages/api/projects/[projectName]/index.ts b/src/pages/api/projects/[projectName]/index.ts index 795890c1..c2870f8b 100644 --- a/src/pages/api/projects/[projectName]/index.ts +++ b/src/pages/api/projects/[projectName]/index.ts @@ -7,6 +7,8 @@ import { changeRepoVisibility, disablePages, removeRepositoryHomepage, + isEnterpriseGitHubOrg, + shouldIncludeSlugInBase, } from '@lib/GitHub/index.ts'; import type { APIRoute } from 'astro'; import type { apiProjectPut, apiProjectsProjectNamePost } from '@ty/api.ts'; @@ -24,6 +26,53 @@ import { import { v4 as uuidv4 } from 'uuid'; import { updateProjectLastUpdated } from '@lib/pages/index.ts'; +const logGitHubFailure = async (stage: string, response: Response) => { + const requestId = response.headers.get('x-github-request-id'); + const responseBody = await response.clone().text(); + + console.error(`GitHub ${stage} failed`, { + status: response.status, + statusText: response.statusText, + requestId, + responseBody, + }); +}; + +const logGitHubErrorResponse = async ( + stage: string, + response: Response, + context?: Record +) => { + const requestId = + response.headers.get('x-github-request-id') || + response.headers.get('x-request-id') || + 'unknown'; + const rateLimitRemaining = response.headers.get('x-ratelimit-remaining'); + const scope = response.headers.get('x-oauth-scopes'); + const acceptedScope = response.headers.get('x-accepted-oauth-scopes'); + + let responseBody: unknown = null; + const rawBody = await response.text(); + if (rawBody) { + try { + responseBody = JSON.parse(rawBody); + } catch { + responseBody = rawBody; + } + } + + console.error(`[GitHub ${stage}] request failed`, { + status: response.status, + statusText: response.statusText, + requestId, + rateLimitRemaining, + scope, + acceptedScope, + responseBody, + ...context, + }); +}; + // Note: this POST route is the only /api/projects route that expects the // `projectName` param to be the bare name instead of the slug version that // combines org and name. All other /api/projects API routes expect the full slug. @@ -51,6 +100,14 @@ export const POST: APIRoute = async ({ const body: apiProjectsProjectNamePost = await request.json(); + const isUtexasSession = cookies.get('auth-provider')?.value === 'utexas'; + + const repoVisibility = body.is_private + ? 'private' + : isEnterpriseGitHubOrg(body.gitHubOrg) || isUtexasSession + ? 'internal' + : 'public'; + // First see if we can create this repo const check: Response = await getRepo( token?.value as string, @@ -70,31 +127,90 @@ export const POST: APIRoute = async ({ } ); } + // For UTexas EMU users the standard AVAnnotate template is outside their + // enterprise and cannot be accessed with their token. If a UTexas-specific + // template org is configured and this session was started via the EMU login + // path, use that org's copy of the template instead. + const utexasTemplateOrg = import.meta.env.UTEXAS_GIT_REPO_ORG; + const utexasTemplateRepo = + import.meta.env.UTEXAS_GIT_REPO_PROJECT_TEMPLATE || body.templateRepo; + + const templateOwner = + isUtexasSession && utexasTemplateOrg ? utexasTemplateOrg : undefined; + const templateRepo = + isUtexasSession && utexasTemplateOrg ? utexasTemplateRepo : body.templateRepo; + // Create the new repo from template const resp: Response = await createRepositoryFromTemplate( - body.templateRepo, + templateRepo, body.gitHubOrg, token?.value as string, projectName as string, body.title, - body.visibility + repoVisibility, + templateOwner ); if (!resp.ok) { - console.error('Failed to create project repo: ', resp.statusText); - console.error('Body: ', body); + await logGitHubErrorResponse('repo-create-from-template', resp, { + gitHubOrg: body.gitHubOrg, + templateRepo: body.templateRepo, + projectName, + visibility: repoVisibility, + }); return new Response( JSON.stringify({ avaError: '_repo_create_failed_', }), - { status: 500, statusText: resp.statusText } + { status: resp.status || 500, statusText: resp.statusText } ); } const repo: FullRepository = await resp.json(); - if (body.generate_pages_site) { - // Enable pages + // Two-step visibility change for GitHub EMU organizations: the template + // generation API does not support `visibility: 'internal'`, so the repo + // is first created as private (above) and then patched to internal here. + // A short delay is required because GitHub initializes the repository + // asynchronously after returning 201, and the PATCH can fail if issued + // immediately. + if (repoVisibility === 'internal') { + await delay(2000); + let visibilityResp = await changeRepoVisibility( + token?.value as string, + body.gitHubOrg, + projectName as string, + 'internal' + ); + + if (!visibilityResp.ok) { + // One retry after an additional short delay to handle transient failures. + await logGitHubFailure('repo-visibility-internal-attempt-1', visibilityResp); + await delay(3000); + visibilityResp = await changeRepoVisibility( + token?.value as string, + body.gitHubOrg, + projectName as string, + 'internal' + ); + } + + if (!visibilityResp.ok) { + await logGitHubFailure('repo-visibility-internal-attempt-2', visibilityResp); + return new Response( + JSON.stringify({ + avaError: '_repo_visibility_change_failed_', + }), + { + status: 500, + statusText: visibilityResp.statusText, + } + ); + } + } + + if (isUtexasSession || body.generate_pages_site) { + // Enable pages; for EMU (UTexas) sessions this is always required. const respPages: Response = await enablePages( body.gitHubOrg, projectName as string, @@ -102,14 +218,16 @@ export const POST: APIRoute = async ({ ); if (!respPages.ok) { - console.error('Status: ', respPages.status); - console.error('Failed to enable GitHub pages: ', respPages.statusText); + await logGitHubErrorResponse('pages-enable', respPages, { + gitHubOrg: body.gitHubOrg, + projectName, + }); return new Response( JSON.stringify({ avaError: '_failed_pages_enable_', }), { - status: 500, + status: respPages.status || 500, statusText: respPages.statusText, } ); @@ -127,14 +245,16 @@ export const POST: APIRoute = async ({ ); if (!respTopics.ok) { - console.error('Status: ', respTopics.status); - console.error('Failed to add topic: ', respTopics.statusText); + await logGitHubErrorResponse('topics-replace', respTopics, { + gitHubOrg: body.gitHubOrg, + projectName, + }); return new Response( JSON.stringify({ avaError: '_failed_adding_topic_', }), { - status: 500, + status: respTopics.status || 500, statusText: respTopics.statusText, } ); @@ -181,13 +301,19 @@ export const POST: APIRoute = async ({ const project = { publish: { publish_pages_app: body.generate_pages_site, + publish_static_site: false, publish_sha: '', publish_iso_date: '', + include_slug_in_base: shouldIncludeSlugInBase( + body.gitHubOrg, + cookies.get('auth-provider')?.value + ), }, users: collabs, project: { github_org: body.gitHubOrg, - is_private: body.visibility === 'private', + // Internal GitHub repos are represented as non-private in project metadata. + is_private: body.is_private, title: body.title, description: body.description, language: body.language, @@ -357,11 +483,21 @@ export const PUT: APIRoute = async ({ cookies, params, request, redirect }) => { // Has repo visibility changed? if (projectConfig.project.is_private !== body.is_private) { + // For GitHub EMU organizations, repos can only be private or internal (not + // public). Sending `private: false` to the API would attempt a public + // visibility change which would fail for EMU orgs. Use `'internal'` + // instead so the PATCH sets visibility explicitly. + const isEnterprise = + isEnterpriseGitHubOrg(slugContents.org) || + cookies.get('auth-provider')?.value === 'utexas'; + const targetVisibility: boolean | 'internal' = + !body.is_private && isEnterprise ? 'internal' : body.is_private; + const visResponse = await changeRepoVisibility( info?.token as string, slugContents.org, slugContents.repo, - body.is_private + targetVisibility ); if (!visResponse.ok) { @@ -425,6 +561,7 @@ export const PUT: APIRoute = async ({ cookies, params, request, redirect }) => { ); if (!respPages.ok) { + await logGitHubFailure('pages-enable', respPages); console.error('Status: ', respPages.status); console.error('Failed to enable GitHub pages: ', respPages.statusText); return new Response( @@ -477,6 +614,9 @@ export const PUT: APIRoute = async ({ cookies, params, request, redirect }) => { // override existing properties with new ones from the request const newConfig: ProjectData = { ...projectConfig, + publish: { + ...projectConfig.publish, + }, project: { ...projectConfig.project, ...body, diff --git a/src/pages/git-enterprise/index.ts b/src/pages/git-enterprise/index.ts new file mode 100644 index 00000000..b3f8d1c2 --- /dev/null +++ b/src/pages/git-enterprise/index.ts @@ -0,0 +1,57 @@ +import type { APIRoute } from 'astro'; + +// OAuth callback for UTexas GitHub Enterprise Managed User (EMU) login. +// GitHub redirects here after the user authorises the enterprise OAuth app at +// https://github.com/enterprises/utexas-internal. +// Required environment variables: +// PUBLIC_UTEXAS_GITHUB_CLIENT_ID – Client ID of the OAuth App registered +// inside the UTexas enterprise. +// UTEXAS_GITHUB_CLIENT_SECRET – Corresponding client secret (server-side +// only; never exposed to the browser). +export const GET: APIRoute = async ({ request }) => { + const code = new URL(request.url).searchParams.get('code'); + const data = new FormData(); + data.append('client_id', import.meta.env.PUBLIC_UTEXAS_GITHUB_CLIENT_ID); + data.append('client_secret', import.meta.env.UTEXAS_GITHUB_CLIENT_SECRET); + data.append('code', code ?? ''); + + // Exchange the authorisation code for an access token using the standard + // GitHub OAuth endpoint (same endpoint used for both regular and EMU apps). + return await fetch(`https://github.com/login/oauth/access_token`, { + method: 'POST', + body: data, + }) + .then((response) => response.text()) + .then((paramsString) => { + const params = new URLSearchParams(paramsString); + const access_token = params.get('access_token'); + if (!access_token) { + console.error( + 'UTexas OAuth token exchange failed: no access_token in response', + paramsString + ); + return new Response(undefined, { status: 401 }); + } + // Set both the access token and an auth-provider marker so the server + // can identify this session as a UTexas EMU login and use the correct + // template repo when creating projects. + const headers = new Headers({ + Location: `${new URL(request.url).origin}/en/projects`, + }); + headers.append( + 'Set-Cookie', + `access-token=${access_token}; HttpOnly; SameSite=Lax; Path=/` + ); + headers.append( + 'Set-Cookie', + `auth-provider=utexas; HttpOnly; SameSite=Lax; Path=/` + ); + return new Response(undefined, { status: 302, headers }); + }) + .catch((error) => { + console.error('UTexas OAuth token exchange failed:', error); + return new Response(undefined, { + status: 500, + }); + }); +}; diff --git a/src/types/Types.ts b/src/types/Types.ts index 721db946..4e4e3814 100644 --- a/src/types/Types.ts +++ b/src/types/Types.ts @@ -71,6 +71,7 @@ export type Publish = { publish_static_site: boolean; publish_sha: string; publish_iso_date: string; + include_slug_in_base?: boolean; }; export type CaptionSet = { diff --git a/src/types/api.ts b/src/types/api.ts index 4b0c5e5a..68db4e81 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -32,7 +32,7 @@ export type apiAnnotationSetPost = { export type apiProjectsProjectNamePost = { templateRepo: string; description: string; - visibility: 'private' | 'public'; + is_private: boolean; generate_pages_site: boolean; title: string; slug: string;