diff --git a/.env.example b/.env.example
index f4e7ac1..2482ac7 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,7 @@
# GitHub OAuth App client ID (public — safe to embed in the SPA bundle).
# Use the dev OAuth App's id locally; the dev API is configured with the same id.
+# Only the "Sign in with GitHub" path needs it. The Emergent (Entra) sign-in has
+# no variables: its tenant, client id and scope are constants in src/lib/entra.ts.
VITE_GITHUB_OAUTH_CLIENT_ID=
# Base URL of the ATK API (no /api prefix, no trailing slash).
diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md
index db25e07..384511b 100644
--- a/PROJECT_OVERVIEW.md
+++ b/PROJECT_OVERVIEW.md
@@ -46,7 +46,7 @@ Developers should continue to use the `atk` CLI. ATK Web deliberately does **not
- Fields for name, description, README, type, tags.
- On submit, opens a **pull request** against `agentic-toolkit-registry` (mirroring the CLI's `atk publish` flow) so the existing security review pipeline runs.
- **Org support** — users can view assets scoped to their org or global assets.
-- **Auth via GitHub** (the ATK API validates the token, gates on EmergentSoftware org membership, and opens PRs with it so they are authored by the user).
+- **Auth via an Emergent (Microsoft Entra) account or GitHub** (the ATK API validates either token: Entra tokens for Emergent Software tenant members, GitHub tokens for EmergentSoftware org members; GitHub publishes open PRs with the user's token, Entra publishes use the API's publish identity with the commit authored as the user).
### Out of scope
@@ -79,7 +79,7 @@ Intentionally small and conventional. No server in this repo — this is a fully
### ATK API Integration
- **`@hey-api/openapi-ts`** — generates a typed fetch client (`src/lib/api/`) from the API's vendored OpenAPI contract (`openapi/openapi.json`). `src/lib/api-client.ts` wraps it with the base URL (`VITE_ATK_API_URL`), bearer auth from the session token, retries, and error mapping.
-- Auth via a **GitHub OAuth App** (standard web flow), with the `code`-for-token exchange handled by the ATK API's `POST /auth/github/exchange` (see §7 Authentication).
+- Auth via **`@azure/msal-browser`** (Microsoft Entra, auth-code + PKCE with the v5 redirect bridge page `auth-redirect.html`) and, until the cutoff, a **GitHub OAuth App** (standard web flow, `code`-for-token exchange handled by the ATK API's `POST /auth/github/exchange`). See §7 Authentication.
### Tooling
- **ESLint** + **Prettier** — match the conventions used in `agentic-toolkit`.
@@ -118,47 +118,44 @@ Assets and bundles may be org-scoped (`org` in the manifest; `@{org}/` in regist
- **Static SPA.** Built with Vite, deployed to GitHub Pages via GitHub Actions.
- **The ATK API as the backend.** Every registry read (`GET /registry`, manifests, READMEs, file listings), every download (server-built zips), and every publish goes through the shared API; the browser never talks to GitHub's REST API directly. The CLI uses the same endpoints, so both clients see identical behaviour.
-- **Token passthrough.** The API validates the user's GitHub token and EmergentSoftware membership on each request and opens publish PRs with that same token, so PR authorship and the review workflow are unchanged from the CLI. The API never persists user tokens.
-- **Tokens live in the browser.** Access tokens are held in `sessionStorage` and never persisted to any server we operate.
+- **Token passthrough.** The API validates the user's token on each request (an Entra access token against the Emergent Software tenant, or a GitHub token against EmergentSoftware membership) and never persists it. GitHub publishes open PRs with that same token, so PR authorship is unchanged from the CLI; Entra publishes are opened by the API's publish identity with the commit authored as the user and a "Published by" line in the PR body.
+- **Tokens live in the browser.** Access tokens are held in `sessionStorage` (MSAL's cache for Entra, one key for GitHub) and never persisted to any server we operate.
- **Registry schema parity with the CLI.** The web app validates and renders manifests using the same Zod schemas defined in `agentic-toolkit/src/lib/schemas/`. A valid asset in the CLI is a valid asset in the web UI, and vice versa.
- **No duplicate registry.** The web app reads the canonical `registry.json` published by the registry repo's CI — the same artifact the CLI consumes.
## 7. Authentication
-### Approach: GitHub OAuth App + ATK API token exchange
+Two providers, one session (`src/providers/SessionProvider.tsx`): the **Emergent account** (Microsoft Entra ID, primary) and **GitHub** (secondary, kept until the GitHub sign-in cutoff). Whichever the user picks, the result is a bearer token the ATK API validates on every call; the API picks the scheme from the token's shape, so the SPA never says which kind it is sending. The contract is `docs/design/Auth.md` in the monorepo.
-GitHub Pages is static-only, and GitHub's OAuth token-exchange endpoint does not support CORS from arbitrary browser origins. That rules out a pure-browser OAuth handshake. The ATK API holds the OAuth App's `client_secret` and performs the one `code`-for-token exchange at `POST /auth/github/exchange` (this replaced the repo's earlier standalone `auth-function`).
+### Emergent account (MSAL)
-### Components
+- `@azure/msal-browser` 5 behind the small `EntraClient` interface in `src/lib/entra.ts` (tests inject a fake; there is no `msal-react`). Constants in code: tenant `25ee13ae-…`, SPA client `ES ATK Web` (`07a23158-…`), scope `api://8da5aa72-…/access_as_user`. No `VITE_*` variables.
+- **Redirect flow with the v5 redirect bridge.** "Sign in with your Emergent account" calls `loginRedirect` with the current `#/route` as the start page. Microsoft returns to `auth-redirect.html` (a second Vite entry whose only script calls `broadcastResponseToMainFrame()`), which hands the response to MSAL and navigates back to the start page, so the hash router never sees `#code=…`. On that load `SessionProvider` awaits `initialize()` and `handleRedirectPromise()` before reporting any status other than `verifying`.
+- **Tokens.** The API client's auth callback asks MSAL for an access token on every request (`acquireTokenSilent`); MSAL serves it from its `sessionStorage` cache and refreshes it with the SPA refresh token. A failure that needs interaction ends the session with a "Your Emergent sign-in has expired" notice; the app never starts an interactive flow from inside a request.
+- **Sign-out** is `logoutRedirect` with the app root as the post-logout target: clears the cache, ends the Microsoft web session, lands on the signed-out landing.
-1. **GitHub OAuth App** registered under the EmergentSoftware org.
- - Callback URL: the deployed GitHub Pages URL.
- - Required scopes: `read:org` (to verify EmergentSoftware membership) and `repo` (to read the private registry, fork it, push to the user's fork, and open PRs).
-2. **ATK API** (`func-atk-prod` / `func-atk-dev`, .NET on Azure Functions; deployed from the monorepo).
- - `POST /auth/github/exchange` accepts an OAuth `code`, calls `github.com/login/oauth/access_token` with the stored `client_secret`, and returns GitHub's token response verbatim.
- - The client secret lives in Key Vault; CORS is restricted to the SPA origins.
- - Two OAuth Apps: the **dev** app's id is configured on the dev API (used by `pnpm dev`), the **prod** app's id on the prod API (used by GitHub Pages).
-3. **SPA auth flow.**
- - User clicks "Sign in with GitHub" → redirected to the OAuth App authorize screen.
- - GitHub redirects back to the SPA with a `code`.
- - SPA `POST`s the code to `{VITE_ATK_API_URL}/auth/github/exchange` → receives the access token.
- - SPA stores the token in `sessionStorage` and sends it as `Authorization: Bearer …` on every ATK API call.
+### GitHub OAuth App (until the cutoff)
-### Org membership gate
+GitHub Pages is static-only, and GitHub's OAuth token-exchange endpoint does not support CORS from arbitrary browser origins, so the ATK API holds the OAuth App's `client_secret` and performs the one `code`-for-token exchange at `POST /auth/github/exchange` (this replaced the repo's earlier standalone `auth-function`). "Sign in with GitHub" (or `/sign-in?provider=github`) redirects to the OAuth App's authorize screen with scopes `read:org repo`; GitHub returns to `/#/auth/callback?code=…&state=…`; the SPA checks `state`, `POST`s the code to the API, and stores the token in `sessionStorage` under `atk:session:token`. Two OAuth Apps: the **dev** app's id is configured on the dev API (used by `pnpm dev`), the **prod** app's id on the prod API (used by GitHub Pages).
-Immediately after auth, the SPA calls the API's `GET /me`. The API validates the token and checks EmergentSoftware membership itself:
+### Membership gate
-- **`200`:** active member — proceeds into the app; the response supplies the login, name, and avatar for display.
-- **`403 not_org_member` / `org_membership_unverifiable`:** shown a friendly blocking screen explaining they must be a member of EmergentSoftware to use this tool, with contact guidance for being added (the unverifiable case logs SAML / OAuth-App-approval hints to the console).
-- **`401`:** the stored token is dead; the app returns to the signed-out landing.
+Immediately after sign-in the SPA calls the API's `GET /me`, which validates the token and enforces membership itself:
+
+- **`200`:** allowed in. The response is the principal (`scheme`, `id`, `login`, `name?`, `email?`, `avatarUrl?`, `githubAuthSunset?`); the header shows `login` (the UPN for Entra users, the GitHub login otherwise).
+- **`403 not_org_member` / `org_membership_unverifiable`** (GitHub) or **`403 guest_not_allowed`** (Entra): the blocking "Not authorized" page, with copy per scheme (the unverifiable case logs SAML / OAuth-App-approval hints to the console).
+- **`401`:** the token is dead; the app returns to the signed-out landing. `401 github_auth_retired` (after the cutoff) keeps the API's message as a notice on the landing.
+
+### GitHub sign-in cutoff
+
+While the API has a cutoff scheduled, `githubAuthSunset` on the principal renders a banner under the header for GitHub sessions ("GitHub sign-in to ATK ends on YYYY-MM-DD. Switch to your Emergent account before then." with a **Switch now** button; dismissible per tab). Entra sessions never see it.
### End-user prerequisites
-To use ATK Web, a non-technical user needs:
+To use ATK Web, a non-technical user needs one of:
-1. A **GitHub account** (free signup at github.com).
-2. **Membership in the `EmergentSoftware` GitHub organization** — granted by an org admin; one-time.
-3. On first visit, **authorize the ATK Web OAuth App** via the standard GitHub consent screen — one click.
+1. An **Emergent Software Microsoft account** (tenant member; guests are refused). No consent screen: the app is pre-authorized on the API scope.
+2. A **GitHub account** that is an active member of the **`EmergentSoftware`** organization, plus one-time authorization of the ATK Web OAuth App on GitHub's consent screen (until the cutoff).
No PATs, no CLI, no terminal, no git knowledge required.
@@ -179,17 +176,19 @@ agentic-toolkit-web/
│ ├── routes/ # page components (browse, detail, contribute, bundles)
│ ├── lib/
│ │ ├── api/ # GENERATED typed client + types (do not edit)
-│ │ ├── api-client.ts # base URL, bearer auth, retries, error mapping
-│ │ ├── session.ts # OAuth redirect + code exchange helpers
+│ │ ├── api-client.ts # base URL, bearer auth (GitHub token or Entra token getter), retries, error mapping
+│ │ ├── entra.ts # MSAL (Entra) client behind the EntraClient interface, plus a fake for tests
+│ │ ├── session.ts # session types, GitHub OAuth redirect + code exchange helpers
│ │ ├── registry-client.ts # registry index, manifests, READMEs, file listings
│ │ ├── download-service.ts # server-built zip / .skill downloads
│ │ ├── publish-service.ts # POST /publish and /publish/plan payloads
│ │ └── schemas/ # Zod schemas (vendored from agentic-toolkit-cli)
│ ├── hooks/ # TanStack Query hooks (useRegistry, useAssetFiles, …)
-│ ├── providers/ # SessionProvider (token, GET /me, status machine)
+│ ├── providers/ # SessionProvider (Entra + GitHub, GET /me, status machine)
│ └── main.tsx
├── public/
├── .github/workflows/ # validate PRs; build + deploy Pages on main
+├── auth-redirect.html # MSAL redirect bridge page (second Vite entry; Entra returns here)
├── vite.config.ts
├── tsconfig.json
├── eslint.config.js
diff --git a/auth-redirect.html b/auth-redirect.html
new file mode 100644
index 0000000..e2e8459
--- /dev/null
+++ b/auth-redirect.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Signing in…
+
+
+
+
+
diff --git a/docs/deployment.md b/docs/deployment.md
index 7675ee9..b5bebcc 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -27,9 +27,9 @@ Related docs:
POST /publish, /publish/plan (open a registry PR)
```
-- The SPA runs entirely in the browser using `HashRouter`; no server-side routing is required.
-- The SPA calls `POST /auth/github/exchange` to swap a GitHub OAuth authorization code for an access token. The API holds the OAuth App's client secret (in Key Vault) and never exposes it to the browser.
-- Every other call carries `Authorization: Bearer `. The API validates the token and EmergentSoftware org membership and, for publishing, opens the pull request **with that same token** so the PR is authored by the user.
+- The SPA runs entirely in the browser using `HashRouter`; no server-side routing is required. `auth-redirect.html` is a second static page: the MSAL redirect bridge that Microsoft Entra returns to after sign-in (see §2.4).
+- Two sign-in providers, one session. **Entra** (primary, "Sign in with your Emergent account"): MSAL runs the auth-code + PKCE flow against `login.microsoftonline.com` entirely in the browser; the API is not involved until the first call. **GitHub** (secondary, kept until the GitHub cutoff): the SPA calls `POST /auth/github/exchange` to swap an OAuth authorization code for an access token; the API holds the OAuth App's client secret (in Key Vault) and never exposes it to the browser.
+- Every other call carries `Authorization: Bearer `. The API picks the scheme by the token's shape and validates it: Entra access tokens against the Emergent Software tenant (members only, guests refused), GitHub tokens against EmergentSoftware org membership. Publishing with a GitHub token opens the pull request **with that same token** so the PR is authored by the user; publishing with an Entra token uses the API's publish identity, with the commit authored as the user and a "Published by … via Entra" line in the PR body.
- The API's OpenAPI contract is vendored at `openapi/openapi.json`; `src/lib/api/` is generated from it (`pnpm refresh-openapi && pnpm generate-api`).
---
@@ -67,6 +67,26 @@ On the `EmergentSoftware/agentic-toolkit-web` repo's **Settings → Secrets and
The names must match exactly what `deploy-pages.yml` reads and `src/lib/api-client.ts` / `src/lib/session.ts` expect. No repository secrets are required for the SPA deploy.
+### 2.4 Entra sign-in (nothing to configure per environment)
+
+The Emergent-account sign-in uses the `ES ATK Web` app registration in the Emergent Software tenant (client id `07a23158-19c4-4180-a2a9-41cb80882a65`, tenant `25ee13ae-a8a5-4bc2-bb23-aea90536fb0c`, scope `api://8da5aa72-0565-4ae8-bf5a-db89d8bd3186/access_as_user`). One registration serves dev and prod, so the values are constants in `src/lib/entra.ts`; there are no `VITE_*` variables, secrets, or per-environment settings for it. The registration is owned in the monorepo (`infra/README.md`); the web app needs these on it:
+
+| Setting | Value |
+|---|---|
+| Platform | Single-page application |
+| Redirect URIs (the MSAL redirect bridge, `auth-redirect.html`) | `http://localhost:5173/agentic-toolkit-web/auth-redirect.html`, `https://emergentsoftware.github.io/agentic-toolkit-web/auth-redirect.html` |
+| Redirect URIs (post-logout targets, the app root) | `http://localhost:5173/agentic-toolkit-web/`, `https://emergentsoftware.github.io/agentic-toolkit-web/` |
+| ID token optional claim | `login_hint` (lets sign-out skip Microsoft's account picker) |
+| API permissions | delegated `ATK API / access_as_user` (pre-authorized, no consent prompt) |
+
+Both URIs include the Vite `base` (`/agentic-toolkit-web/`) because `pnpm dev` serves the app under it too. The bridge page is built as a second Vite entry (`build.rollupOptions.input` in `vite.config.ts`), so it is emitted into `dist/` and published to Pages with the app; it must be served from the app's own origin. A mismatch shows on Microsoft's page as `AADSTS50011` (`redirect_uri_mismatch`).
+
+Entra tokens are held by MSAL in `sessionStorage` (per tab, like the GitHub token) and refreshed silently; when a silent refresh needs interaction the app signs out with a "Your Emergent sign-in has expired" notice. Sign-out ends the Microsoft web session too (`logoutRedirect`) and lands back on the app root.
+
+### 2.5 GitHub sign-in cutoff
+
+The API setting `Atk__Auth__GitHubSunset` (monorepo Terraform `github_auth_sunset`) schedules the end of GitHub sign-in. Before the date, the SPA shows a banner under the header to GitHub sessions ("GitHub sign-in to ATK ends on YYYY-MM-DD…", with a **Switch now** button that starts the Emergent sign-in); after it, GitHub sign-ins are refused with `401 github_auth_retired` and the landing shows the API's message. Entra sessions never see either. Nothing in this repo changes for the cutoff; it is set in the monorepo once the CLI and web both offer the Emergent sign-in.
+
---
## 3. Normal deploy flow (`deploy-pages.yml`)
@@ -131,10 +151,14 @@ An API-side incident (the exchange, `/me`, or registry reads failing) is handled
| Symptom | Likely cause | Check |
|---|---|---|
+| Emergent sign-in stops on a Microsoft page saying `AADSTS50011` / redirect URI mismatch | The `ES ATK Web` registration lacks the bridge URI for this origin. | Both `auth-redirect.html` URIs in §2.4 must be registered exactly, including `/agentic-toolkit-web/`. |
+| Landing shows "Sign-in did not complete (…)" after returning from Microsoft | The user cancelled, or Entra returned an error (`access_denied`, network). | The notice carries Entra's own description; sign in again. |
+| Landing shows "Your Emergent sign-in has expired" | A silent token refresh needed interaction (session revoked, 24h SPA refresh-token limit). | Sign in again. |
+| App shows "Not authorized" for an Emergent account | `GET /me` returned `403 guest_not_allowed`: the account is a guest in the tenant. | ATK is for tenant members; use an Emergent account or sign in with GitHub. |
| Sign-in fails with a CORS error in the browser console | The API's `cors_allowed_origins` does not include the SPA origin. | Must contain `https://emergentsoftware.github.io` (prod) / `http://localhost:5173` (dev) — origin only, no path, no trailing slash. |
| Sign-in fails with `Auth exchange failed (HTTP 400): bad_verification_code` | The `code` was already used or expired, or the SPA's client id does not match the API's. | Local dev must use the **dev** OAuth App id (the dev API's id); Pages must use the **prod** id. Start the sign-in over. |
| Sign-in fails with `Auth exchange failed (HTTP 500)` | The API is missing its OAuth configuration. | Check `github_oauth_client_id` and the Key Vault secret for that environment. |
| App shows "Not authorized" for a known org member | `GET /me` returned `403 org_membership_unverifiable`. | SAML SSO not authorized for the token, or the OAuth App is not approved for the org (`https://github.com/orgs/EmergentSoftware/policies/applications`). The browser console logs the hint. |
-| App drops back to the signed-out landing on load | `GET /me` returned `401`; the stored token is dead. | Sign in again. |
+| App drops back to the signed-out landing on load | `GET /me` returned `401`; the stored token is dead. | Sign in again. If the landing shows "GitHub sign-in to ATK ended on …", the cutoff has passed: use the Emergent account. |
| `VITE_ATK_API_URL is not set` at startup | Repo variable or `.env.local` missing. | Variables must be named `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_ATK_API_URL`. |
| Downloads fail with "The ATK API is unavailable" | API 5xx (usually GitHub upstream). | Retry; check the API's App Insights in Azure. |
diff --git a/openapi/openapi.json b/openapi/openapi.json
index 6da73f7..d65bbaa 100644
--- a/openapi/openapi.json
+++ b/openapi/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "ATK API",
"version": "0.2.0",
- "description": "The Agentic Tool Kit API: registry reads, publish, checkout and web sign-in for the ATK CLI and web app. Authenticate with `Authorization: Bearer `; the caller must be an active member of the EmergentSoftware GitHub organisation."
+ "description": "The Agentic Tool Kit API: registry reads, publish, checkout and web sign-in for the ATK CLI and web app. Authenticate with `Authorization: Bearer ` using either a GitHub token (the caller must be an active member of the EmergentSoftware GitHub organisation) or a Microsoft Entra ID access token for the ATK API (the caller must be a member, not a guest, of the Emergent Software tenant). `GET /me` reports which scheme applied."
},
"servers": [
{
@@ -125,7 +125,7 @@
},
"/me": {
"get": {
- "summary": "The authenticated caller as resolved by the API",
+ "summary": "The authenticated caller as resolved by the API. `github` principals carry `githubAuthSunset` while a GitHub sign-in cutoff is scheduled.",
"tags": [
"Auth"
],
@@ -141,7 +141,7 @@
}
},
"401": {
- "description": "Missing or invalid token",
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -151,7 +151,17 @@
}
},
"403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -190,7 +200,7 @@
"description": "Not modified"
},
"401": {
- "description": "Missing or invalid token",
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -200,7 +210,17 @@
}
},
"403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -281,8 +301,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -291,8 +311,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -301,8 +321,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -383,8 +413,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -393,8 +423,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -403,8 +433,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -485,8 +525,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -495,8 +535,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -505,8 +545,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -597,8 +647,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -607,8 +657,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -617,8 +667,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -713,8 +773,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -723,8 +783,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -733,8 +793,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -798,8 +868,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -808,8 +878,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -818,8 +888,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -883,8 +963,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -893,8 +973,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -903,8 +983,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -924,7 +1014,7 @@
},
"/bundles/{name}/{version}/download": {
"get": {
- "summary": "Zip of the bundle: `bundle.json` at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and nests skill members as `{member}.skill`.",
+ "summary": "Zip of the bundle: `bundle.json` (plus the bundle's `README.md` when it has one) at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and `README.md` and nests skill members as `{member}.skill`.",
"tags": [
"Bundles"
],
@@ -982,8 +1072,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -992,8 +1082,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -1002,8 +1092,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -1094,8 +1194,8 @@
}
}
},
- "404": {
- "description": "Unknown asset, version, or file",
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
"content": {
"application/json": {
"schema": {
@@ -1104,8 +1204,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
"content": {
"application/json": {
"schema": {
@@ -1114,8 +1214,18 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "404": {
+ "description": "Unknown asset, version, or file",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -1170,6 +1280,26 @@
}
}
},
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
"409": {
"description": "`version_not_bumped`, `version_exists`, or `branch_exists`",
"content": {
@@ -1180,8 +1310,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "500": {
+ "description": "Entra caller but the API's GitHub publish token is missing or rejected (`server_misconfigured`)",
"content": {
"application/json": {
"schema": {
@@ -1190,8 +1320,8 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "502": {
+ "description": "GitHub unreachable (`github_unavailable`, `registry_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -1211,7 +1341,7 @@
},
"/publish": {
"post": {
- "summary": "Validate, then create the branch, commit and pull request on the registry with the caller's own token so the PR is authored by them. Global targets get the default reviewers; org targets use `reviewers`.",
+ "summary": "Validate, then create the branch, commit and pull request on the registry. GitHub callers write with their own token so the PR is authored by them; Entra callers are written by the API's publish identity with the commit authored as them, and every PR body names the publisher. Global targets get the default reviewers; org targets use `reviewers`.",
"tags": [
"Publish"
],
@@ -1246,6 +1376,26 @@
}
}
},
+ "401": {
+ "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ },
"409": {
"description": "`version_not_bumped`, `version_exists`, or `branch_exists`",
"content": {
@@ -1256,8 +1406,8 @@
}
}
},
- "401": {
- "description": "Missing or invalid token",
+ "500": {
+ "description": "Entra caller but the API's GitHub publish token is missing or rejected (`server_misconfigured`)",
"content": {
"application/json": {
"schema": {
@@ -1266,8 +1416,8 @@
}
}
},
- "403": {
- "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)",
+ "502": {
+ "description": "GitHub unreachable (`github_unavailable`, `registry_unavailable`)",
"content": {
"application/json": {
"schema": {
@@ -1291,7 +1441,7 @@
"githubToken": {
"type": "http",
"scheme": "bearer",
- "description": "A GitHub token (OAuth, `gh auth token`, or PAT) with `read:org`, and `repo` for publishing."
+ "description": "Either a GitHub token (OAuth, `gh auth token`, or PAT) with `read:org` (and `repo` for publishing), or a Microsoft Entra ID v2.0 access token for the ATK API carrying the `access_as_user` scope (`az account get-access-token --scope api:///access_as_user`). While a GitHub sign-in cutoff is scheduled, responses to GitHub-authenticated requests carry `Sunset` and `Deprecation: true` headers; after it GitHub tokens get `401 github_auth_retired`."
}
},
"schemas": {
@@ -1558,11 +1708,15 @@
"type": "object",
"properties": {
"scheme": {
- "description": "Auth scheme that validated the token (`github`)",
+ "description": "Auth scheme that validated the token: `github` or `entra`",
+ "type": "string"
+ },
+ "id": {
+ "description": "Stable user id: the GitHub numeric user id, or the Entra object id (`oid`)",
"type": "string"
},
"login": {
- "description": "GitHub login",
+ "description": "GitHub login, or the Entra user principal name (e.g. `jasonp@emergentsoftware.net`)",
"type": "string"
},
"name": {
@@ -1572,16 +1726,33 @@
"null"
]
},
+ "email": {
+ "description": "Email: the GitHub public profile email (often absent), or the Entra `email` claim falling back to the UPN",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"avatarUrl": {
- "description": "Avatar URL",
+ "description": "Avatar URL (GitHub only)",
"type": [
"string",
"null"
]
+ },
+ "githubAuthSunset": {
+ "description": "Only on `github` principals while a GitHub sign-in cutoff is scheduled: when GitHub sign-in to ATK ends (RFC 3339, UTC). Absent otherwise. The same responses carry `Sunset` and `Deprecation` headers.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time",
+ "default": null
}
},
"required": [
"scheme",
+ "id",
"login"
]
},
diff --git a/package.json b/package.json
index 089ada0..bce6702 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"generate-api": "openapi-ts"
},
"dependencies": {
+ "@azure/msal-browser": "5.21.0",
"@base-ui-components/react": "^1.0.0-beta.0",
"@tanstack/react-form": "^1.29.0",
"@tanstack/react-query": "^5.59.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c5f948f..69fe543 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@azure/msal-browser':
+ specifier: 5.21.0
+ version: 5.21.0
'@base-ui-components/react':
specifier: ^1.0.0-beta.0
version: 1.0.0-rc.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -157,6 +160,14 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+ '@azure/msal-browser@5.21.0':
+ resolution: {integrity: sha512-80OcuXDErmcEDAIH9pBtSqBsed2sPT/IWmbG3xHLoPMl5zc8TINd6SlJAbVSmN5huGa3xGAg5qR7VnpaIEK0Zw==}
+ engines: {node: '>=0.8.0'}
+
+ '@azure/msal-common@16.14.0':
+ resolution: {integrity: sha512-A4rb55hI86Q9tBl/+jBj7TMz7iX2RFgQs/nExFzcAtoI/BFRVdaH5SL/MivrYD7qvweMpN8AgVvVMHV8UBYxew==}
+ engines: {node: '>=0.8.0'}
+
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -3108,6 +3119,12 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
+ '@azure/msal-browser@5.21.0':
+ dependencies:
+ '@azure/msal-common': 16.14.0
+
+ '@azure/msal-common@16.14.0': {}
+
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
diff --git a/src/App.tsx b/src/App.tsx
index d4ada82..f4a1a7b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,12 +1,13 @@
import { Toast } from '@base-ui-components/react/toast';
import { QueryClientProvider } from '@tanstack/react-query';
-import { type ReactNode } from 'react';
+import { type ReactNode, useState } from 'react';
import { Route, Routes } from 'react-router';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { AppLayout } from '@/components/layout/AppLayout';
import { RequireAuth } from '@/components/RequireAuth';
import { ThemeProvider } from '@/components/theme/ThemeProvider';
+import { createEntraClient, type EntraClient } from '@/lib/entra';
import { queryClient } from '@/lib/query-client';
import { SessionProvider } from '@/providers/SessionProvider';
import { AssetDetailRoute } from '@/routes/AssetDetail';
@@ -22,11 +23,17 @@ import { NotAuthorizedRoute } from '@/routes/NotAuthorized';
import { NotFoundRoute } from '@/routes/NotFound';
import { SignInRoute } from '@/routes/SignIn';
-export function App() {
+interface AppProps {
+ /** Override the Entra (MSAL) client; tests pass a fake. */
+ entraClient?: EntraClient;
+}
+
+export function App({ entraClient }: AppProps = {}) {
+ const [resolvedEntraClient] = useState(() => entraClient ?? createEntraClient());
return (
-
+
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
index ccb0c52..59475ef 100644
--- a/src/__tests__/App.test.tsx
+++ b/src/__tests__/App.test.tsx
@@ -1,10 +1,22 @@
import { render, screen } from '@testing-library/react';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { App } from '@/App';
+import { createFakeEntraClient } from '@/lib/entra';
import { SESSION_STORAGE_KEYS } from '@/lib/session';
+function renderApp(path: string) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
describe('App', () => {
beforeEach(() => {
window.sessionStorage.clear();
@@ -14,36 +26,25 @@ describe('App', () => {
window.sessionStorage.clear();
});
- it('renders the signed-out landing at `/` when no session exists', () => {
- render(
-
-
- ,
- );
+ it('renders the signed-out landing at `/` when no session exists', async () => {
+ renderApp('/');
- expect(screen.getByTestId('signed-out-landing')).toBeInTheDocument();
+ expect(await screen.findByTestId('signed-out-landing')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1, name: /agentic toolkit/i })).toBeInTheDocument();
- expect(screen.getByTestId('landing-sign-in')).toBeInTheDocument();
+ expect(screen.getByTestId('landing-sign-in')).toHaveTextContent(/emergent account/i);
+ expect(screen.getByTestId('landing-sign-in-github')).toHaveTextContent(/github/i);
});
it('renders the NotFound catch-all for unknown paths', () => {
- render(
-
-
- ,
- );
+ renderApp('/totally/unknown/path');
expect(screen.getByRole('heading', { level: 1, name: /page not found/i })).toBeInTheDocument();
});
- it('redirects protected routes to `/` when signed out', () => {
+ it('redirects protected routes to `/` when signed out', async () => {
window.sessionStorage.removeItem(SESSION_STORAGE_KEYS.token);
- render(
-
-
- ,
- );
+ renderApp('/bundles');
- expect(screen.getByTestId('signed-out-landing')).toBeInTheDocument();
+ expect(await screen.findByTestId('signed-out-landing')).toBeInTheDocument();
});
});
diff --git a/src/__tests__/components/Header.test.tsx b/src/__tests__/components/Header.test.tsx
new file mode 100644
index 0000000..0c30a38
--- /dev/null
+++ b/src/__tests__/components/Header.test.tsx
@@ -0,0 +1,61 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+
+import { Header } from '@/components/layout/Header';
+import { ThemeProvider } from '@/components/theme/ThemeProvider';
+import { type SessionContextValue } from '@/providers/SessionProvider';
+
+import { makeSessionValue, SessionHarness } from '../utils/session-harness';
+
+function renderHeader(session: SessionContextValue) {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('Header', () => {
+ it('starts an Entra sign-in from the Sign in button', () => {
+ const signIn = vi.fn();
+ renderHeader(makeSessionValue({ signIn }));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
+ expect(signIn).toHaveBeenCalledWith('entra', undefined);
+ });
+
+ it('shows the Entra UPN and a Sign out button for an Entra session', () => {
+ const signOut = vi.fn();
+ renderHeader(
+ makeSessionValue({
+ scheme: 'entra',
+ signOut,
+ status: 'member',
+ user: { id: 'oid', login: 'jasonp@emergentsoftware.net', name: 'Jason Paff', scheme: 'entra' },
+ }),
+ );
+
+ expect(screen.getByTestId('user-login')).toHaveTextContent('jasonp@emergentsoftware.net');
+ expect(screen.queryByRole('img', { name: /avatar/i })).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
+ expect(signOut).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows the GitHub login for a GitHub session', () => {
+ renderHeader(
+ makeSessionValue({
+ scheme: 'github',
+ status: 'member',
+ user: { id: '1', login: 'octo', name: null, scheme: 'github' },
+ }),
+ );
+
+ expect(screen.getByTestId('user-login')).toHaveTextContent('octo');
+ expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/components/RequireAuth.test.tsx b/src/__tests__/components/RequireAuth.test.tsx
index e4b95ca..1a9cb1a 100644
--- a/src/__tests__/components/RequireAuth.test.tsx
+++ b/src/__tests__/components/RequireAuth.test.tsx
@@ -45,12 +45,12 @@ describe('RequireAuth', () => {
afterEach(() => window.sessionStorage.clear());
it('renders children when the session status is "member"', () => {
- renderAt('/protected', makeSessionValue({ status: 'member', token: 'tok' }));
+ renderAt('/protected', makeSessionValue({ status: 'member' }));
expect(screen.getByTestId('protected-child')).toBeInTheDocument();
});
it('shows a loading screen while verifying', () => {
- renderAt('/protected', makeSessionValue({ status: 'verifying', token: 'tok' }));
+ renderAt('/protected', makeSessionValue({ status: 'verifying' }));
expect(screen.getByTestId('require-auth-loading')).toBeInTheDocument();
expect(screen.queryByTestId('protected-child')).not.toBeInTheDocument();
});
@@ -67,7 +67,7 @@ describe('RequireAuth', () => {
});
it('redirects non-members to /not-authorized', () => {
- renderAt('/protected', makeSessionValue({ status: 'non-member', token: 'tok' }));
+ renderAt('/protected', makeSessionValue({ status: 'non-member' }));
expect(screen.getByTestId('not-authorized')).toBeInTheDocument();
expect(screen.queryByTestId('protected-child')).not.toBeInTheDocument();
});
diff --git a/src/__tests__/components/SignedOutLanding.test.tsx b/src/__tests__/components/SignedOutLanding.test.tsx
new file mode 100644
index 0000000..fcb9ad2
--- /dev/null
+++ b/src/__tests__/components/SignedOutLanding.test.tsx
@@ -0,0 +1,78 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SignedOutLanding } from '@/components/SignedOutLanding';
+import { SESSION_STORAGE_KEYS } from '@/lib/session';
+
+import { makeSessionValue, SessionHarness } from '../utils/session-harness';
+
+describe('SignedOutLanding', () => {
+ beforeEach(() => window.sessionStorage.clear());
+ afterEach(() => window.sessionStorage.clear());
+
+ it('offers the Emergent account as the primary sign-in and GitHub as the secondary', () => {
+ const signIn = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('landing-sign-in')).toHaveTextContent('Sign in with your Emergent account');
+ expect(screen.getByTestId('landing-sign-in-github')).toHaveTextContent('Sign in with GitHub');
+ expect(screen.getByText(/Emergent Software staff and members of the/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId('landing-sign-in'));
+ expect(signIn).toHaveBeenLastCalledWith('entra', undefined);
+
+ fireEvent.click(screen.getByTestId('landing-sign-in-github'));
+ expect(signIn).toHaveBeenLastCalledWith('github', undefined);
+ });
+
+ it('passes the stashed return path to whichever provider is clicked', () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.pendingReturn, '/bundles');
+ const signIn = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId('landing-sign-in-github'));
+ expect(signIn).toHaveBeenCalledWith('github', '/bundles');
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.pendingReturn)).toBeNull();
+ });
+
+ it('shows the session notice above the buttons and lets the user dismiss it', () => {
+ const dismissNotice = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const notice = screen.getByTestId('session-notice');
+ expect(notice).toHaveTextContent('GitHub sign-in to ATK ended on 2026-09-14.');
+ expect(notice).toHaveAttribute('data-kind', 'github_auth_retired');
+ expect(
+ notice.compareDocumentPosition(screen.getByTestId('landing-sign-in')) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+
+ fireEvent.click(screen.getByRole('button', { name: /dismiss notice/i }));
+ expect(dismissNotice).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders no notice when there is none', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByTestId('session-notice')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/components/SunsetBanner.test.tsx b/src/__tests__/components/SunsetBanner.test.tsx
new file mode 100644
index 0000000..7700df5
--- /dev/null
+++ b/src/__tests__/components/SunsetBanner.test.tsx
@@ -0,0 +1,78 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SunsetBanner } from '@/components/layout/SunsetBanner';
+import { SESSION_STORAGE_KEYS } from '@/lib/session';
+import { type SessionContextValue } from '@/providers/SessionProvider';
+
+import { makeSessionValue, SessionHarness } from '../utils/session-harness';
+
+const SUNSET = '2027-01-31T00:00:00+00:00';
+
+function githubSession(overrides: Partial = {}) {
+ return makeSessionValue({
+ scheme: 'github',
+ status: 'member',
+ user: { githubAuthSunset: SUNSET, id: '1', login: 'octo', scheme: 'github' },
+ ...overrides,
+ });
+}
+
+function renderBanner(session: SessionContextValue) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('SunsetBanner', () => {
+ beforeEach(() => window.sessionStorage.clear());
+ afterEach(() => window.sessionStorage.clear());
+
+ it('shows the cutoff date and a Switch now button for a GitHub session with githubAuthSunset', () => {
+ const signIn = vi.fn();
+ renderBanner(githubSession({ signIn }));
+
+ const banner = screen.getByTestId('sunset-banner');
+ expect(banner).toHaveTextContent(
+ 'GitHub sign-in to ATK ends on 2027-01-31. Switch to your Emergent account before then.',
+ );
+
+ fireEvent.click(screen.getByTestId('sunset-switch'));
+ expect(signIn).toHaveBeenCalledWith('entra');
+ });
+
+ it('is absent for a GitHub session without a sunset', () => {
+ renderBanner(githubSession({ user: { id: '1', login: 'octo', scheme: 'github' } }));
+ expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument();
+ });
+
+ it('is never shown for an Entra session', () => {
+ renderBanner(
+ makeSessionValue({
+ scheme: 'entra',
+ status: 'member',
+ user: { githubAuthSunset: SUNSET, id: 'oid', login: 'jasonp@emergentsoftware.net', scheme: 'entra' },
+ }),
+ );
+ expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument();
+ });
+
+ it('is absent when signed out', () => {
+ renderBanner(makeSessionValue());
+ expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument();
+ });
+
+ it('dismiss hides it and persists for the tab', () => {
+ const { unmount } = renderBanner(githubSession());
+ fireEvent.click(screen.getByTestId('sunset-dismiss'));
+
+ expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument();
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.sunsetDismissed)).toBe('1');
+
+ unmount();
+ renderBanner(githubSession());
+ expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/lib/api-client.test.ts b/src/__tests__/lib/api-client.test.ts
index 63a45c9..d0dfd7c 100644
--- a/src/__tests__/lib/api-client.test.ts
+++ b/src/__tests__/lib/api-client.test.ts
@@ -1,7 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry, me } from '@/lib/api';
-import { ApiRequestError, createApiClient, getApiUrl, isApiError, unwrap } from '@/lib/api-client';
+import {
+ ApiRequestError,
+ createApiClient,
+ getApiUrl,
+ isApiError,
+ NOT_MEMBER_CODES,
+ SESSION_EXPIRED_CODE,
+ sessionExpiredError,
+ unwrap,
+} from '@/lib/api-client';
import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch, textResponse } from '../utils/api-stub';
@@ -35,7 +44,7 @@ describe('createApiClient', () => {
expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_abc');
});
- it('omits the Authorization header when built without a token', async () => {
+ it('omits the Authorization header when built without a credential', async () => {
const { calls } = stubFetch(() => jsonResponse({}));
await getRegistry({ client: createApiClient(null, { retry: { maxRetries: 0 } }) });
@@ -43,6 +52,38 @@ describe('createApiClient', () => {
expect(calls[0]!.headers.get('authorization')).toBeNull();
});
+ it('asks an Entra credential for a token on every request', async () => {
+ const { calls } = stubFetch(() => jsonResponse({ login: 'jasonp@emergentsoftware.net', scheme: 'entra' }));
+ const getToken = vi.fn(async () => `eyJ.token.${getToken.mock.calls.length}`);
+ const client = createApiClient({ getToken, scheme: 'entra' }, { retry: { maxRetries: 0 } });
+
+ await me({ client });
+ await me({ client });
+
+ expect(getToken).toHaveBeenCalledTimes(2);
+ expect(calls[0]!.headers.get('authorization')).toBe('Bearer eyJ.token.1');
+ expect(calls[1]!.headers.get('authorization')).toBe('Bearer eyJ.token.2');
+ });
+
+ it('surfaces a throwing Entra token getter as the 401 it carries, without sending the request', async () => {
+ const { calls } = stubFetch(() => jsonResponse({}));
+ const client = createApiClient({ getToken: async () => Promise.reject(sessionExpiredError()), scheme: 'entra' });
+
+ const result = await me({ client });
+
+ expect(calls).toHaveLength(0);
+ expect(result.response).toBeUndefined();
+ const err = catchError(() => unwrap(result, 'your account'));
+ expect(isApiError(err, SESSION_EXPIRED_CODE, 401)).toBe(true);
+ expect(err.message).toBe('Your Emergent sign-in has expired. Sign in again.');
+ });
+
+ it('counts guest_not_allowed as a not-member code', () => {
+ expect(NOT_MEMBER_CODES.has('guest_not_allowed')).toBe(true);
+ expect(NOT_MEMBER_CODES.has('not_org_member')).toBe(true);
+ expect(NOT_MEMBER_CODES.has('org_membership_unverifiable')).toBe(true);
+ });
+
it('retries transient failures through fetchWithRetry', async () => {
const fixture = { assets: [], version: 'x' };
const { calls } = stubFetch((_req, index) => (index === 0 ? textResponse('', 503) : jsonResponse(fixture)));
@@ -102,9 +143,12 @@ describe('unwrap', () => {
const at = (status: number, error?: unknown) =>
catchError(() => unwrap({ error: error ?? {}, response: new Response(null, { status }) }, 'the registry index'));
- expect(at(401).message).toMatch(/sign in again/i);
+ expect(at(401).message).toBe('Your session is no longer valid. Sign in again.');
+ expect(at(401, { error: 'invalid_token', message: 'Bad credentials' }).message).not.toMatch(/GitHub/);
expect(at(403, { error: 'not_org_member', message: 'nope' }).message).toMatch(/EmergentSoftware/);
expect(at(403, { error: 'not_org_member', message: 'nope' }).code).toBe('not_org_member');
+ expect(at(403, { error: 'guest_not_allowed', message: 'nope' }).message).toMatch(/guests/i);
+ expect(at(403, { error: 'guest_not_allowed', message: 'nope' }).code).toBe('guest_not_allowed');
expect(at(404).message).toMatch(/was not found/i);
expect(at(429).message).toMatch(/rate limiting/i);
expect(at(503, { error: 'upstream_unavailable', message: 'GitHub timed out' }).message).toMatch(
@@ -112,6 +156,19 @@ describe('unwrap', () => {
);
});
+ it('keeps the API message verbatim for 401 github_auth_retired', () => {
+ const message =
+ 'GitHub sign-in to ATK ended on 2026-09-14. Run `atk login`, or sign in with your Emergent account.';
+ const err = catchError(() =>
+ unwrap(
+ { error: { error: 'github_auth_retired', message }, response: new Response(null, { status: 401 }) },
+ 'your account',
+ ),
+ );
+ expect(isApiError(err, 'github_auth_retired', 401)).toBe(true);
+ expect(err.message).toBe(message);
+ });
+
it('falls back to http_error with the raw body when there is no envelope', () => {
const err = catchError(() =>
unwrap({ error: 'Bad Gateway', response: new Response(null, { status: 502 }) }, 'the registry index'),
diff --git a/src/__tests__/lib/entra.test.ts b/src/__tests__/lib/entra.test.ts
new file mode 100644
index 0000000..81c7510
--- /dev/null
+++ b/src/__tests__/lib/entra.test.ts
@@ -0,0 +1,128 @@
+import {
+ BrowserAuthError,
+ BrowserAuthErrorCodes,
+ ClientAuthError,
+ ClientAuthErrorCodes,
+ InteractionRequiredAuthError,
+ ServerError,
+} from '@azure/msal-browser';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import {
+ appRootUrl,
+ createFakeEntraClient,
+ describeEntraError,
+ ENTRA_AUTHORITY,
+ ENTRA_CLIENT_ID,
+ ENTRA_SCOPES,
+ ENTRA_TENANT_ID,
+ isSessionExpiredError,
+ redirectBridgeUrl,
+ scrubRedirectState,
+} from '@/lib/entra';
+
+describe('entra constants', () => {
+ it('targets the Emergent Software tenant, the ES ATK Web SPA client and the ATK API scope', () => {
+ expect(ENTRA_TENANT_ID).toBe('25ee13ae-a8a5-4bc2-bb23-aea90536fb0c');
+ expect(ENTRA_AUTHORITY).toBe(`https://login.microsoftonline.com/${ENTRA_TENANT_ID}`);
+ expect(ENTRA_CLIENT_ID).toBe('07a23158-19c4-4180-a2a9-41cb80882a65');
+ expect(ENTRA_SCOPES).toEqual(['api://8da5aa72-0565-4ae8-bf5a-db89d8bd3186/access_as_user']);
+ });
+
+ it('derives the redirect URIs from the page origin and the Vite base', () => {
+ // vitest serves BASE_URL as '/'; the build uses '/agentic-toolkit-web/'.
+ expect(appRootUrl()).toBe(`${window.location.origin}/`);
+ expect(redirectBridgeUrl()).toBe(`${window.location.origin}/auth-redirect.html`);
+ });
+});
+
+describe('scrubRedirectState', () => {
+ afterEach(() => window.history.replaceState({}, '', '/'));
+
+ it('removes the state MSAL appends to the post-logout URL and keeps the rest', () => {
+ window.history.replaceState({}, '', '/agentic-toolkit-web/?state=abc&keep=1#/bundles');
+ scrubRedirectState();
+ expect(window.location.pathname).toBe('/agentic-toolkit-web/');
+ expect(window.location.search).toBe('?keep=1');
+ expect(window.location.hash).toBe('#/bundles');
+ });
+
+ it('leaves a URL without state untouched', () => {
+ window.history.replaceState({}, '', '/agentic-toolkit-web/#/');
+ scrubRedirectState();
+ expect(window.location.href).toBe(`${window.location.origin}/agentic-toolkit-web/#/`);
+ });
+});
+
+describe('isSessionExpiredError', () => {
+ it('treats interaction-required, invalid_grant and no-account failures as an expired session', () => {
+ expect(isSessionExpiredError(new InteractionRequiredAuthError('login_required', 'c'))).toBe(true);
+ expect(isSessionExpiredError(new InteractionRequiredAuthError('refresh_token_expired', 'c'))).toBe(true);
+ expect(isSessionExpiredError(new ServerError('invalid_grant', 'c'))).toBe(true);
+ expect(isSessionExpiredError(new BrowserAuthError(BrowserAuthErrorCodes.noAccountError, 'c'))).toBe(true);
+ expect(isSessionExpiredError(new ClientAuthError(ClientAuthErrorCodes.noAccountFound, 'c'))).toBe(true);
+ });
+
+ it('treats transient failures as not expired', () => {
+ expect(isSessionExpiredError(new BrowserAuthError(BrowserAuthErrorCodes.noNetworkConnectivity, 'c'))).toBe(false);
+ expect(isSessionExpiredError(new BrowserAuthError(BrowserAuthErrorCodes.interactionInProgress, 'c'))).toBe(false);
+ expect(isSessionExpiredError(new ClientAuthError(ClientAuthErrorCodes.networkError, 'c'))).toBe(false);
+ expect(isSessionExpiredError(new ServerError('server_error', 'c'))).toBe(false);
+ expect(isSessionExpiredError(new Error('boom'))).toBe(false);
+ expect(isSessionExpiredError(undefined)).toBe(false);
+ });
+});
+
+describe('describeEntraError', () => {
+ it("keeps Entra's own description for server errors", () => {
+ const error = new ServerError('access_denied', 'c', 'AADSTS65004: User declined to consent.');
+ expect(describeEntraError(error)).toBe(
+ 'Sign-in did not complete (access_denied): AADSTS65004: User declined to consent.',
+ );
+ });
+
+ it('falls back to the code when MSAL only carries a hashed message pointer', () => {
+ const error = new BrowserAuthError(BrowserAuthErrorCodes.userCancelled, 'c');
+ expect(describeEntraError(error)).toBe('Sign-in did not complete (user_cancelled).');
+ });
+
+ it('describes plain errors and unknown values', () => {
+ expect(describeEntraError(new Error('offline'))).toBe('Sign-in did not complete: offline');
+ expect(describeEntraError('?')).toBe('Sign-in did not complete.');
+ });
+});
+
+describe('createFakeEntraClient', () => {
+ const account = { homeAccountId: 'home-1', name: 'Jason Paff', username: 'jasonp@emergentsoftware.net' };
+
+ it('installs the redirect result as the account and records calls', async () => {
+ const client = createFakeEntraClient({ redirectResult: account });
+ await client.initialize();
+ expect(client.getAccount()).toBeNull();
+ await expect(client.handleRedirect()).resolves.toEqual(account);
+ expect(client.getAccount()).toEqual(account);
+ await expect(client.acquireToken()).resolves.toBe('entra-access-token');
+ await client.login('/bundles');
+ expect(client.calls).toEqual([
+ { method: 'initialize' },
+ { method: 'handleRedirect' },
+ { method: 'acquireToken' },
+ { method: 'login', returnPath: '/bundles' },
+ ]);
+ });
+
+ it('rejects from the scripted errors and forgets the account on logout and clearAccount', async () => {
+ const client = createFakeEntraClient({
+ account,
+ redirectError: new Error('cancelled'),
+ tokenError: new Error('x'),
+ });
+ await expect(client.handleRedirect()).rejects.toThrow('cancelled');
+ await expect(client.acquireToken()).rejects.toThrow('x');
+ await client.clearAccount();
+ expect(client.getAccount()).toBeNull();
+ client.state.account = account;
+ await client.logout();
+ expect(client.getAccount()).toBeNull();
+ });
+});
diff --git a/src/__tests__/lib/session.test.ts b/src/__tests__/lib/session.test.ts
new file mode 100644
index 0000000..eb499a2
--- /dev/null
+++ b/src/__tests__/lib/session.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+
+import { defaultAuthorFor, formatSunsetDate } from '@/lib/session';
+
+describe('defaultAuthorFor', () => {
+ it('uses the GitHub login for a GitHub session', () => {
+ expect(defaultAuthorFor('github', { id: '1', login: 'octo', name: 'Octo Cat', scheme: 'github' })).toBe('octo');
+ });
+
+ it('uses the display name for an Entra session, falling back to the UPN', () => {
+ const upn = 'jasonp@emergentsoftware.net';
+ expect(defaultAuthorFor('entra', { id: 'oid', login: upn, name: 'Jason Paff', scheme: 'entra' })).toBe(
+ 'Jason Paff',
+ );
+ expect(defaultAuthorFor('entra', { id: 'oid', login: upn, name: null, scheme: 'entra' })).toBe(upn);
+ expect(defaultAuthorFor('entra', { id: 'oid', login: upn, name: '', scheme: 'entra' })).toBe(upn);
+ });
+
+ it('is empty when signed out', () => {
+ expect(defaultAuthorFor(null, null)).toBe('');
+ });
+});
+
+describe('formatSunsetDate', () => {
+ it('formats an RFC 3339 timestamp as the UTC calendar date', () => {
+ expect(formatSunsetDate('2027-01-31T00:00:00+00:00')).toBe('2027-01-31');
+ expect(formatSunsetDate('2027-01-31T23:30:00-05:00')).toBe('2027-02-01');
+ });
+
+ it('passes an unparseable value through', () => {
+ expect(formatSunsetDate('soon')).toBe('soon');
+ });
+});
diff --git a/src/__tests__/providers/SessionProvider.test.tsx b/src/__tests__/providers/SessionProvider.test.tsx
index f8e590b..2d955d6 100644
--- a/src/__tests__/providers/SessionProvider.test.tsx
+++ b/src/__tests__/providers/SessionProvider.test.tsx
@@ -1,42 +1,67 @@
import type { ReactNode } from 'react';
+import { InteractionRequiredAuthError, ServerError } from '@azure/msal-browser';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSession } from '@/hooks/useSession';
+import { createFakeEntraClient, type EntraClient, type FakeEntraClient } from '@/lib/entra';
import { SESSION_STORAGE_KEYS } from '@/lib/session';
import { SessionProvider } from '@/providers/SessionProvider';
import { apiErrorResponse, jsonResponse, stubFetch } from '../utils/api-stub';
-const PRINCIPAL = { avatarUrl: null, login: 'tester', name: null, scheme: 'github' };
+const GITHUB_PRINCIPAL = { avatarUrl: null, id: '1', login: 'tester', name: null, scheme: 'github' };
+const ENTRA_PRINCIPAL = {
+ email: 'jasonp@emergentsoftware.net',
+ id: 'oid-1',
+ login: 'jasonp@emergentsoftware.net',
+ name: 'Jason Paff',
+ scheme: 'entra',
+};
+const ENTRA_ACCOUNT = { homeAccountId: 'home-1', name: 'Jason Paff', username: 'jasonp@emergentsoftware.net' };
function Probe() {
const session = useSession();
return (
);
}
-function wrap(children: ReactNode) {
+function renderProbe(entraClient: FakeEntraClient = createFakeEntraClient()) {
+ render(wrap(, entraClient));
+ return entraClient;
+}
+
+function wrap(children: ReactNode, entraClient: EntraClient) {
const client = new QueryClient({
defaultOptions: { queries: { gcTime: 0, retry: false, staleTime: Infinity } },
});
return (
- {children}
+ {children}
);
}
+const status = () => screen.getByTestId('status').textContent;
+
describe('SessionProvider', () => {
beforeEach(() => {
window.sessionStorage.clear();
+ window.location.hash = '';
});
afterEach(() => {
@@ -45,89 +70,223 @@ describe('SessionProvider', () => {
vi.unstubAllGlobals();
});
- it('is signed-out with no API client when no token is in sessionStorage', () => {
- const { calls } = stubFetch(() => jsonResponse(PRINCIPAL));
- render(wrap());
- expect(screen.getByTestId('status')).toHaveTextContent('signed-out');
- expect(screen.getByTestId('has-api')).toHaveTextContent('no');
- expect(calls).toHaveLength(0);
- });
+ describe('startup', () => {
+ it('is verifying until MSAL has initialized, then signed-out with no API client and no requests', async () => {
+ const { calls } = stubFetch(() => jsonResponse(GITHUB_PRINCIPAL));
+ const entra = renderProbe();
- it('rehydrates the token from sessionStorage on mount and verifies membership via GET /me', async () => {
- window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_rehydrated');
- const { calls } = stubFetch(() => jsonResponse(PRINCIPAL));
+ expect(status()).toBe('verifying');
+ await waitFor(() => expect(status()).toBe('signed-out'));
+ expect(screen.getByTestId('has-api')).toHaveTextContent('no');
+ expect(screen.getByTestId('scheme')).toHaveTextContent('');
+ expect(entra.calls.map((c) => c.method)).toEqual(['initialize', 'handleRedirect']);
+ expect(calls).toHaveLength(0);
+ });
- render(wrap());
+ it('completes an Entra redirect return: the account becomes the session and /me runs with its token', async () => {
+ const { calls } = stubFetch(() => jsonResponse(ENTRA_PRINCIPAL));
+ renderProbe(createFakeEntraClient({ redirectResult: ENTRA_ACCOUNT, token: 'eyJ.entra.token' }));
- // Starts in verifying while the query runs.
- expect(screen.getByTestId('status')).toHaveTextContent('verifying');
+ await waitFor(() => expect(status()).toBe('member'));
+ expect(screen.getByTestId('scheme')).toHaveTextContent('entra');
+ expect(screen.getByTestId('user')).toHaveTextContent('jasonp@emergentsoftware.net');
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.url).toBe('http://localhost:7071/me');
+ expect(calls[0]!.headers.get('authorization')).toBe('Bearer eyJ.entra.token');
+ });
- // Resolves to member when /me succeeds (the API enforces org membership).
- await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('member'));
- expect(screen.getByTestId('user')).toHaveTextContent('tester');
- expect(screen.getByTestId('has-api')).toHaveTextContent('yes');
+ it('resumes an Entra session already cached in this tab (reload) and drops a leftover GitHub token', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_leftover');
+ const { calls } = stubFetch(() => jsonResponse(ENTRA_PRINCIPAL));
+ renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT }));
- expect(calls).toHaveLength(1);
- expect(calls[0]!.url).toBe('http://localhost:7071/me');
- expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_rehydrated');
- });
+ await waitFor(() => expect(status()).toBe('member'));
+ expect(screen.getByTestId('scheme')).toHaveTextContent('entra');
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull();
+ expect(calls[0]!.headers.get('authorization')).toBe('Bearer entra-access-token');
+ });
- it('transitions to non-member on 403 not_org_member', async () => {
- window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_nonmember');
- stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not an active member'));
+ it('shows a sign_in_failed notice and stays signed out when the redirect return fails', async () => {
+ stubFetch(() => jsonResponse(ENTRA_PRINCIPAL));
+ renderProbe(
+ createFakeEntraClient({
+ redirectError: new ServerError('access_denied', 'c', 'AADSTS65004: User declined to consent.'),
+ }),
+ );
- render(wrap());
+ await waitFor(() => expect(status()).toBe('signed-out'));
+ expect(screen.getByTestId('notice-kind')).toHaveTextContent('sign_in_failed');
+ expect(screen.getByTestId('notice-message')).toHaveTextContent(/access_denied.*declined to consent/);
- await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('non-member'));
- expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBe('gho_nonmember');
+ screen.getByTestId('dismiss').click();
+ await waitFor(() => expect(screen.getByTestId('notice-kind')).toHaveTextContent(''));
+ });
});
- it('transitions to non-member on 403 org_membership_unverifiable and logs the SAML / OAuth-App hints', async () => {
- window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_unverifiable');
- stubFetch(() => apiErrorResponse(403, 'org_membership_unverifiable', 'Could not verify membership'));
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ describe('Entra session', () => {
+ it('transitions to non-member on 403 guest_not_allowed', async () => {
+ stubFetch(() => apiErrorResponse(403, 'guest_not_allowed', 'Guests are not allowed'));
+ renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT }));
- render(wrap());
+ await waitFor(() => expect(status()).toBe('non-member'));
+ expect(screen.getByTestId('scheme')).toHaveTextContent('entra');
+ });
- await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('non-member'));
- await waitFor(() => expect(warn).toHaveBeenCalled());
- const payload = JSON.stringify(warn.mock.calls[0]);
- expect(payload).toMatch(/policies\/applications/);
- expect(payload).toMatch(/SAML/);
- });
+ it('ends the session with an entra_expired notice when the silent token request needs interaction', async () => {
+ stubFetch(() => jsonResponse(ENTRA_PRINCIPAL));
+ const entra = renderProbe(
+ createFakeEntraClient({
+ account: ENTRA_ACCOUNT,
+ tokenError: new InteractionRequiredAuthError('login_required', 'c'),
+ }),
+ );
- it('returns to signed-out and clears the stored token on 401', async () => {
- window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_expired');
- stubFetch(() => apiErrorResponse(401, 'unauthorized', 'Bad credentials'));
+ await waitFor(() => expect(status()).toBe('signed-out'));
+ expect(screen.getByTestId('notice-kind')).toHaveTextContent('entra_expired');
+ expect(screen.getByTestId('notice-message')).toHaveTextContent(
+ 'Your Emergent sign-in has expired. Sign in again.',
+ );
+ expect(entra.calls.map((c) => c.method)).toContain('clearAccount');
+ expect(screen.getByTestId('has-api')).toHaveTextContent('no');
+ });
- render(wrap());
+ it('signOut() hands off to the Entra logout redirect', async () => {
+ stubFetch(() => jsonResponse(ENTRA_PRINCIPAL));
+ const entra = renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT }));
+ await waitFor(() => expect(status()).toBe('member'));
- await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('signed-out'));
- expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull();
- expect(screen.getByTestId('has-api')).toHaveTextContent('no');
+ act(() => screen.getByTestId('sign-out').click());
+
+ await waitFor(() => expect(status()).toBe('signed-out'));
+ expect(entra.calls.map((c) => c.method)).toContain('logout');
+ });
+
+ it('signIn("entra") clears a GitHub session first and starts the redirect with the return path', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_current');
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.oauthState, '{"state":"s","returnPath":"/"}');
+ stubFetch(() => jsonResponse(GITHUB_PRINCIPAL));
+ const entra = renderProbe();
+ await waitFor(() => expect(status()).toBe('member'));
+
+ act(() => screen.getByTestId('sign-in-entra').click());
+
+ await waitFor(() => expect(status()).toBe('authenticating'));
+ expect(entra.calls).toContainEqual({ method: 'login', returnPath: '/bundles' });
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull();
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.oauthState)).toBeNull();
+ });
+
+ it('signIn("github") clears an Entra session before redirecting to GitHub', async () => {
+ stubFetch(() => jsonResponse(ENTRA_PRINCIPAL));
+ const assign = vi.fn();
+ vi.stubGlobal('location', { ...window.location, assign, hash: '#/contribute' });
+ const entra = renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT }));
+ await waitFor(() => expect(status()).toBe('member'));
+
+ act(() => screen.getByTestId('sign-in-github').click());
+
+ await waitFor(() => expect(status()).toBe('authenticating'));
+ expect(entra.calls.map((c) => c.method)).toContain('clearAccount');
+ expect(assign).toHaveBeenCalledWith(expect.stringMatching(/^https:\/\/github\.com\/login\/oauth\/authorize\?/));
+ const stored = JSON.parse(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.oauthState) ?? '{}') as {
+ returnPath?: string;
+ };
+ expect(stored.returnPath).toBe('/contribute');
+ });
});
- it('signOut() clears the token and returns to signed-out', async () => {
- window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_bye');
- stubFetch(() => jsonResponse(PRINCIPAL));
+ describe('GitHub session (unchanged)', () => {
+ it('rehydrates the token from sessionStorage on mount and verifies membership via GET /me', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_rehydrated');
+ const { calls } = stubFetch(() => jsonResponse(GITHUB_PRINCIPAL));
- function SignOutButton() {
- const { signOut, status } = useSession();
- return (
-
- out
-
- );
- }
+ renderProbe();
+
+ expect(status()).toBe('verifying');
+ await waitFor(() => expect(status()).toBe('member'));
+ expect(screen.getByTestId('scheme')).toHaveTextContent('github');
+ expect(screen.getByTestId('user')).toHaveTextContent('tester');
+ expect(screen.getByTestId('has-api')).toHaveTextContent('yes');
+
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.url).toBe('http://localhost:7071/me');
+ expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_rehydrated');
+ });
+
+ it('transitions to non-member on 403 not_org_member', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_nonmember');
+ stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not an active member'));
+
+ renderProbe();
+
+ await waitFor(() => expect(status()).toBe('non-member'));
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBe('gho_nonmember');
+ });
+
+ it('transitions to non-member on 403 org_membership_unverifiable and logs the SAML / OAuth-App hints', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_unverifiable');
+ stubFetch(() => apiErrorResponse(403, 'org_membership_unverifiable', 'Could not verify membership'));
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ renderProbe();
+
+ await waitFor(() => expect(status()).toBe('non-member'));
+ await waitFor(() => expect(warn).toHaveBeenCalled());
+ const payload = JSON.stringify(warn.mock.calls[0]);
+ expect(payload).toMatch(/policies\/applications/);
+ expect(payload).toMatch(/SAML/);
+ });
+
+ it('returns to signed-out and clears the stored token on 401, with no notice', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_expired');
+ stubFetch(() => apiErrorResponse(401, 'invalid_token', 'Bad credentials'));
+
+ renderProbe();
+
+ await waitFor(() => expect(status()).toBe('signed-out'));
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull();
+ expect(screen.getByTestId('has-api')).toHaveTextContent('no');
+ expect(screen.getByTestId('notice-kind')).toHaveTextContent('');
+ });
+
+ it('keeps the API message verbatim as the github_auth_retired notice on 401 github_auth_retired', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_retired');
+ const message =
+ 'GitHub sign-in to ATK ended on 2026-09-14. Update the CLI (`npm install -g @detergent-software/atk@latest`) and run `atk login`, or sign in to the web app with your Emergent account.';
+ stubFetch(() => apiErrorResponse(401, 'github_auth_retired', message));
+
+ renderProbe();
+
+ await waitFor(() => expect(status()).toBe('signed-out'));
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull();
+ expect(screen.getByTestId('notice-kind')).toHaveTextContent('github_auth_retired');
+ expect(screen.getByTestId('notice-message')).toHaveTextContent(message);
+ });
+
+ it('signOut() clears the token and returns to signed-out without touching Entra', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_bye');
+ stubFetch(() => jsonResponse(GITHUB_PRINCIPAL));
+ const entra = renderProbe();
+ await waitFor(() => expect(status()).toBe('member'));
+
+ act(() => screen.getByTestId('sign-out').click());
- render(wrap());
+ await waitFor(() => expect(status()).toBe('signed-out'));
+ expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull();
+ expect(entra.calls.map((c) => c.method)).not.toContain('logout');
+ });
- // Wait for verify query to settle before signing out.
- await waitFor(() => expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('member'));
+ it('exposes githubAuthSunset from the principal on the user', async () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_sunset');
+ stubFetch(() => jsonResponse({ ...GITHUB_PRINCIPAL, githubAuthSunset: '2027-01-31T00:00:00+00:00' }));
- screen.getByTestId('sign-out').click();
+ function SunsetProbe() {
+ const { user } = useSession();
+ return {user?.githubAuthSunset ?? ''};
+ }
+ render(wrap(, createFakeEntraClient()));
- await waitFor(() => expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('signed-out'));
- expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull();
+ await waitFor(() => expect(screen.getByTestId('sunset')).toHaveTextContent('2027-01-31T00:00:00+00:00'));
+ });
});
});
diff --git a/src/__tests__/routes/Contribute.test.tsx b/src/__tests__/routes/Contribute.test.tsx
index d5f8edf..80fa6e1 100644
--- a/src/__tests__/routes/Contribute.test.tsx
+++ b/src/__tests__/routes/Contribute.test.tsx
@@ -1,10 +1,11 @@
import { Toast } from '@base-ui-components/react/toast';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import JSZip from 'jszip';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiClient } from '@/lib/api-client';
+import type { SessionContextValue } from '@/providers/SessionProvider';
import { Toaster } from '@/components/Toaster';
import * as publishServiceModule from '@/lib/publish-service';
@@ -111,11 +112,16 @@ async function makeSkillFile(name: string, entries: Record } = {},
+) {
const session = makeSessionValue({
api: options.api ?? null,
+ scheme: 'github',
status: 'member',
- user: { login, name: null },
+ user: { id: '1', login, name: null, scheme: 'github' },
+ ...options.session,
});
return render(
@@ -296,6 +302,23 @@ describe('Contribute — wizard UI', () => {
expect(persisted).toContain('"author":"octo-login"');
});
+ it('prefills the author field from the display name for an Entra session, falling back to the UPN', () => {
+ renderContribute('jasonp@emergentsoftware.net', {
+ session: {
+ scheme: 'entra',
+ user: { id: 'oid', login: 'jasonp@emergentsoftware.net', name: 'Jason Paff', scheme: 'entra' },
+ },
+ });
+ expect(window.sessionStorage.getItem(DRAFT_STORAGE_KEY)).toContain('"author":"Jason Paff"');
+ cleanup();
+ window.sessionStorage.clear();
+
+ renderContribute('jasonp@emergentsoftware.net', {
+ session: { scheme: 'entra', user: { id: 'oid', login: 'jasonp@emergentsoftware.net', scheme: 'entra' } },
+ });
+ expect(window.sessionStorage.getItem(DRAFT_STORAGE_KEY)).toContain('"author":"jasonp@emergentsoftware.net"');
+ });
+
it('persists draft changes to sessionStorage and hydrates on mount', () => {
renderContribute();
fireEvent.click(screen.getByTestId('asset-type-rule'));
diff --git a/src/__tests__/routes/CreateBundle.test.tsx b/src/__tests__/routes/CreateBundle.test.tsx
index c0057c8..5c5c721 100644
--- a/src/__tests__/routes/CreateBundle.test.tsx
+++ b/src/__tests__/routes/CreateBundle.test.tsx
@@ -34,7 +34,7 @@ function renderCreateBundle(api: ApiClient | null = makeTestApiClient()) {
const session = makeSessionValue({
api,
status: 'member',
- user: { login: 'test-user', name: null },
+ user: { id: '1', login: 'test-user', name: null, scheme: 'github' },
});
return render(
@@ -225,7 +225,7 @@ describe('CreateBundle — wizard flow', () => {
const session = makeSessionValue({
api: makeTestApiClient(),
status: 'member',
- user: { login: 'test-user', name: null },
+ user: { id: '1', login: 'test-user', name: null, scheme: 'github' },
});
render(
+
+ ,
+ );
+}
+
+describe('NotAuthorizedRoute', () => {
+ it('keeps the GitHub org copy for a GitHub session', () => {
+ const signOut = vi.fn();
+ renderRoute(
+ makeSessionValue({
+ scheme: 'github',
+ signOut,
+ status: 'non-member',
+ user: { id: '1', login: 'octo', scheme: 'github' },
+ }),
+ );
+
+ expect(
+ screen.getByText(/Your GitHub account is not a member of the Emergent Software organization/),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('not-authorized-content')).toHaveTextContent('signed in as octo');
+ expect(screen.getByTestId('not-authorized-content')).toHaveTextContent(/EmergentSoftware GitHub organization/);
+ fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
+ expect(signOut).toHaveBeenCalledTimes(1);
+ });
+
+ it('explains the guest refusal for an Entra session', () => {
+ const signOut = vi.fn();
+ renderRoute(
+ makeSessionValue({
+ scheme: 'entra',
+ signOut,
+ status: 'non-member',
+ user: { id: 'oid', login: 'guest_outlook.com#EXT#@emergentsoftware.onmicrosoft.com', scheme: 'entra' },
+ }),
+ );
+
+ expect(screen.getByText('Your account is a guest in the Emergent Software tenant.')).toBeInTheDocument();
+ expect(screen.getByTestId('not-authorized-content')).toHaveTextContent(
+ 'ATK is available to Emergent Software staff; sign out and use your Emergent account, or sign in with GitHub.',
+ );
+ expect(screen.queryByText(/read:org/)).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
+ expect(signOut).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/routes/SignIn.test.tsx b/src/__tests__/routes/SignIn.test.tsx
new file mode 100644
index 0000000..06c0f79
--- /dev/null
+++ b/src/__tests__/routes/SignIn.test.tsx
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SESSION_STORAGE_KEYS } from '@/lib/session';
+import { type SessionContextValue } from '@/providers/SessionProvider';
+import { SignInRoute } from '@/routes/SignIn';
+
+import { makeSessionValue, SessionHarness } from '../utils/session-harness';
+
+/** The router carries the query (as HashRouter does inside the fragment); `window.location.search` stays empty. */
+function renderSignIn(path: string, session: SessionContextValue) {
+ return render(
+
+
+
+ } path='/sign-in' />
+ home} path='/' />
+ na} path='/not-authorized' />
+
+
+ ,
+ );
+}
+
+describe('SignInRoute', () => {
+ beforeEach(() => window.sessionStorage.clear());
+ afterEach(() => window.sessionStorage.clear());
+
+ it('starts an Entra sign-in by default, restoring the stashed return path', () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.pendingReturn, '/bundles');
+ const signIn = vi.fn();
+ renderSignIn('/sign-in', makeSessionValue({ signIn }));
+
+ expect(signIn).toHaveBeenCalledWith('entra', '/bundles');
+ expect(screen.getByText(/Redirecting to Microsoft/)).toBeInTheDocument();
+ });
+
+ it('starts a GitHub sign-in with ?provider=github', () => {
+ const signIn = vi.fn();
+ renderSignIn('/sign-in?provider=github', makeSessionValue({ signIn }));
+
+ expect(signIn).toHaveBeenCalledWith('github', undefined);
+ expect(screen.getByText(/Redirecting to GitHub/)).toBeInTheDocument();
+ });
+
+ it('forwards members home and non-members to /not-authorized', () => {
+ const signIn = vi.fn();
+ const { unmount } = renderSignIn('/sign-in', makeSessionValue({ scheme: 'entra', signIn, status: 'member' }));
+ expect(screen.getByTestId('home')).toBeInTheDocument();
+ unmount();
+
+ renderSignIn('/sign-in', makeSessionValue({ scheme: 'github', signIn, status: 'non-member' }));
+ expect(screen.getByTestId('not-authorized')).toBeInTheDocument();
+ expect(signIn).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/routes/navigation.test.tsx b/src/__tests__/routes/navigation.test.tsx
index 2bcb499..f6146d8 100644
--- a/src/__tests__/routes/navigation.test.tsx
+++ b/src/__tests__/routes/navigation.test.tsx
@@ -1,8 +1,10 @@
import { render, screen } from '@testing-library/react';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { App } from '@/App';
+import { createFakeEntraClient } from '@/lib/entra';
/**
* Routing smoke tests. Auth-protected routes redirect unauthenticated users
@@ -31,24 +33,28 @@ describe('route navigation (signed-out)', () => {
beforeEach(() => window.sessionStorage.clear());
afterEach(() => window.sessionStorage.clear());
- it.each(SIGNED_OUT_CASES)('renders $path with its PageHeader title', ({ heading, path }) => {
+ it.each(SIGNED_OUT_CASES)('renders $path with its PageHeader title', async ({ heading, path }) => {
render(
-
+
+
+ ,
);
- expect(screen.getByRole('heading', { level: 1, name: heading })).toBeInTheDocument();
+ expect(await screen.findByRole('heading', { level: 1, name: heading })).toBeInTheDocument();
expect(screen.getByTestId('app-layout')).toBeInTheDocument();
});
- it.each(REDIRECTED_CASES)('redirects $path to the landing when signed out', ({ heading, path }) => {
+ it.each(REDIRECTED_CASES)('redirects $path to the landing when signed out', async ({ heading, path }) => {
render(
-
+
+
+ ,
);
- expect(screen.getByRole('heading', { level: 1, name: heading })).toBeInTheDocument();
+ expect(await screen.findByRole('heading', { level: 1, name: heading })).toBeInTheDocument();
});
});
diff --git a/src/__tests__/utils/api-stub.ts b/src/__tests__/utils/api-stub.ts
index 3722de3..f672352 100644
--- a/src/__tests__/utils/api-stub.ts
+++ b/src/__tests__/utils/api-stub.ts
@@ -41,7 +41,7 @@ export function jsonResponse(body: unknown, status = 200): Response {
/** Build a real API client (bearer auth + retries) pointed at the test base URL. */
export function makeTestApiClient(token: null | string = 'test-token', retry = fastRetry): ApiClient {
- return createApiClient(token, { retry });
+ return createApiClient(token === null ? null : { scheme: 'github', token }, { retry });
}
/**
diff --git a/src/__tests__/utils/session-harness.tsx b/src/__tests__/utils/session-harness.tsx
index 872b28e..de60934 100644
--- a/src/__tests__/utils/session-harness.tsx
+++ b/src/__tests__/utils/session-harness.tsx
@@ -15,10 +15,12 @@ export function makeSessionValue(overrides: Partial = {}):
return {
api: null,
completeSignIn: () => {},
+ dismissNotice: () => {},
+ notice: null,
+ scheme: null,
signIn: () => {},
signOut: () => {},
status: 'signed-out',
- token: null,
user: null,
...overrides,
};
diff --git a/src/auth-redirect.ts b/src/auth-redirect.ts
new file mode 100644
index 0000000..ca9cc5f
--- /dev/null
+++ b/src/auth-redirect.ts
@@ -0,0 +1,11 @@
+/**
+ * MSAL v5 redirect bridge. Microsoft Entra returns to this page after sign-in
+ * and sign-out; it hands the response to MSAL, which navigates back to the
+ * route that started the flow. It is a separate Vite entry (see vite.config.ts)
+ * and must be served from the app's own origin.
+ */
+import { broadcastResponseToMainFrame } from '@azure/msal-browser/redirect-bridge';
+
+broadcastResponseToMainFrame().catch((error: unknown) => {
+ console.error('[auth-redirect] Could not hand the sign-in response to the app:', error);
+});
diff --git a/src/components/RequireAuth.tsx b/src/components/RequireAuth.tsx
index d7567f1..3c2c446 100644
--- a/src/components/RequireAuth.tsx
+++ b/src/components/RequireAuth.tsx
@@ -11,7 +11,8 @@ interface RequireAuthProps {
/**
* Route guard that allows the active session to reach `children` only when the
- * user is a verified member of the Emergent Software org. Unauthenticated users
+ * API has verified the user (an EmergentSoftware org member, or an Emergent
+ * Software tenant member). Unauthenticated users
* are redirected to `/` (after stashing their intended path for restoration on
* sign-in) and non-members are redirected to `/not-authorized`.
*/
@@ -35,7 +36,7 @@ export function RequireAuth({ children }: RequireAuthProps) {
className='flex min-h-[60vh] items-center justify-center'
data-testid='require-auth-loading'
>
-
+
);
}
diff --git a/src/components/SignedOutLanding.tsx b/src/components/SignedOutLanding.tsx
index 742c34a..02ad838 100644
--- a/src/components/SignedOutLanding.tsx
+++ b/src/components/SignedOutLanding.tsx
@@ -1,10 +1,12 @@
+import { X } from 'lucide-react';
+
import { Button } from '@/components/ui/button';
import { useSession } from '@/hooks/useSession';
import { consumePendingReturnPath } from '@/lib/session';
/** Friendly intro shown at `/` when the viewer is signed out. */
export function SignedOutLanding() {
- const { signIn } = useSession();
+ const { dismissNotice, notice, signIn } = useSession();
return (
Agentic Toolkit
- A curated registry of skills, agents, rules, hooks, and bundles for AI coding tools.
- Sign in with your GitHub account to browse and download assets.
+ A curated registry of skills, agents, rules, hooks, and bundles for AI coding tools. Sign in to browse and
+ download assets.
- Access is limited to members of the EmergentSoftware organization.
+ Access is limited to Emergent Software staff and members of the{' '}
+ EmergentSoftware GitHub organization.
- signIn(consumePendingReturnPath())}
- size='lg'
- >
- Sign in with GitHub
-
+ {notice ? (
+
+
{notice.message}
+
+
+
+
+ ) : null}
+
+ signIn('entra', consumePendingReturnPath())} size='lg'>
+ Sign in with your Emergent account
+
+ signIn('github', consumePendingReturnPath())}
+ size='sm'
+ variant='outline'
+ >
+ Sign in with GitHub
+
+
);
}
diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx
index c0f3798..523a9a8 100644
--- a/src/components/layout/AppLayout.tsx
+++ b/src/components/layout/AppLayout.tsx
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { Header } from './Header';
import { LayoutWidthProvider, useLayoutWidth } from './LayoutWidthContext';
+import { SunsetBanner } from './SunsetBanner';
export function AppLayout() {
return (
@@ -19,6 +20,7 @@ function AppLayoutInner() {
return (
+ signIn(consumePendingReturnPath())}
+ onClick={() => signIn('entra', consumePendingReturnPath())}
size='sm'
variant='outline'
>
diff --git a/src/components/layout/SunsetBanner.tsx b/src/components/layout/SunsetBanner.tsx
new file mode 100644
index 0000000..3ab28a0
--- /dev/null
+++ b/src/components/layout/SunsetBanner.tsx
@@ -0,0 +1,44 @@
+import { X } from 'lucide-react';
+import { useState } from 'react';
+
+import { Button } from '@/components/ui/button';
+import { useSession } from '@/hooks/useSession';
+import { formatSunsetDate, SESSION_STORAGE_KEYS } from '@/lib/session';
+
+/**
+ * Slim banner under the header while a GitHub session runs and the API has
+ * scheduled the end of GitHub sign-in (`githubAuthSunset` on the principal).
+ * Never shown for Entra sessions. Dismissal lasts for the tab.
+ */
+export function SunsetBanner() {
+ const { scheme, signIn, user } = useSession();
+ const [dismissed, setDismissed] = useState(() => readDismissed());
+
+ const sunset = scheme === 'github' ? user?.githubAuthSunset : undefined;
+ if (!sunset || dismissed) return null;
+
+ const dismiss = () => {
+ window.sessionStorage.setItem(SESSION_STORAGE_KEYS.sunsetDismissed, '1');
+ setDismissed(true);
+ };
+
+ return (
+
+
+
+ GitHub sign-in to ATK ends on {formatSunsetDate(sunset)}. Switch to your Emergent account before then.
+
- You are signed in as {user.login}, but this account does not
- have access to the Emergent Software registry.
+ You are signed in as {user.login}, but this account does not have access to the Emergent
+ Software registry.
) : null}
- The Agentic Toolkit registry is gated on membership of the{' '}
- EmergentSoftware GitHub organization. If you believe you should
- have access:
+ The Agentic Toolkit registry is gated on membership of the EmergentSoftware GitHub
+ organization. If you believe you should have access:
Ask a repo admin to add your GitHub account to the org.
- Make sure your org membership is set to public, or grant the{' '}
- read:org scope when signing in.
+ Make sure your org membership is set to public, or grant the read:org scope when
+ signing in.
Sign out and retry with a different account.
diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx
index eccdc73..02e8c86 100644
--- a/src/routes/SignIn.tsx
+++ b/src/routes/SignIn.tsx
@@ -1,42 +1,43 @@
import { useEffect } from 'react';
-import { Navigate } from 'react-router';
+import { Navigate, useSearchParams } from 'react-router';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import { PageHeader } from '@/components/PageHeader';
import { Button } from '@/components/ui/button';
import { useSession } from '@/hooks/useSession';
-import { consumePendingReturnPath } from '@/lib/session';
+import { consumePendingReturnPath, type SessionScheme } from '@/lib/session';
/**
- * Alternate direct-entry sign-in route. Kicks off the OAuth redirect immediately;
- * if the user is already signed in and a member, forwards them home.
+ * Alternate direct-entry sign-in route. Kicks off the redirect immediately:
+ * Entra by default, GitHub with `?provider=github`. If the user is already
+ * signed in and a member, forwards them home.
+ *
+ * The query is read through react-router (hash-aware): under `HashRouter` the
+ * params live inside the fragment (`#/sign-in?provider=github`), where nuqs's
+ * react-router adapter, which reads `window.location.search`, cannot see them.
*/
export function SignInRoute() {
const { signIn, status } = useSession();
+ const [searchParams] = useSearchParams();
+ const provider: SessionScheme = searchParams.get('provider') === 'github' ? 'github' : 'entra';
+ const providerLabel = provider === 'github' ? 'GitHub' : 'Microsoft';
useEffect(() => {
if (status === 'signed-out') {
- signIn(consumePendingReturnPath());
+ signIn(provider, consumePendingReturnPath());
}
- }, [signIn, status]);
+ }, [provider, signIn, status]);
if (status === 'member') return ;
if (status === 'non-member') return ;
return (
<>
-
+
-
+