diff --git a/backend/main.go b/backend/main.go
index 8c935c0..f122c74 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "strings"
"sync"
"github.com/ory/viper"
@@ -163,7 +164,7 @@ func handleFuncCreate(w http.ResponseWriter, r *http.Request) {
return
}
- if err := generateCIWorkflow(root, cfg.Branch); err != nil {
+ if err := generateCIWorkflow(root, cfg.Branch, cfg.Registry); err != nil {
http.Error(w, "failed to generate CI workflow: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -214,10 +215,14 @@ func handleFuncCreate(w http.ResponseWriter, r *http.Request) {
}
}
-func generateCIWorkflow(root, branch string) error {
+const ocpInternalRegistry = "image-registry.openshift-image-registry.svc:5000/"
+
+func generateCIWorkflow(root, branch, registry string) error {
ciMu.Lock()
defer ciMu.Unlock()
+ useRegistryLogin := !strings.HasPrefix(registry, ocpInternalRegistry)
+
viper.Set(ci.PlatformFlag, ci.DefaultPlatform)
viper.Set(ci.PathFlag, root)
viper.Set(ci.BranchFlag, branch)
@@ -227,7 +232,7 @@ func generateCIWorkflow(root, branch string) error {
viper.Set(ci.RegistryUserVariableNameFlag, ci.DefaultRegistryUserVariableName)
viper.Set(ci.RegistryPassSecretNameFlag, ci.DefaultRegistryPassSecretName)
viper.Set(ci.RegistryUrlVariableNameFlag, ci.DefaultRegistryUrlVariableName)
- viper.Set(ci.UseRegistryLoginFlag, ci.DefaultUseRegistryLogin)
+ viper.Set(ci.UseRegistryLoginFlag, useRegistryLogin)
viper.Set(ci.WorkflowDispatchFlag, ci.DefaultWorkflowDispatch)
viper.Set(ci.UseRemoteBuildFlag, ci.DefaultUseRemoteBuild)
viper.Set(ci.UseSelfHostedRunnerFlag, ci.DefaultUseSelfHostedRunner)
diff --git a/docs/claude-progress.txt b/docs/claude-progress.txt
index 0103661..26b1ee7 100644
--- a/docs/claude-progress.txt
+++ b/docs/claude-progress.txt
@@ -1,6 +1,26 @@
# Claude Progress Log
# Newest entries first. Agents: append your entry at the top after the header.
+---
+## 2026-04-13 | Session: Session-Wide GitHub PAT
+Worked on: Replace build-time compiled GitHub PAT with runtime session-wide PAT (plan 004)
+Completed:
+- Design spec: brainstormed approaches, chose React hook + sessionStorage + PatModal, wrote spec to docs/design/
+- Implementation plan: 9-task bottom-up plan in docs/plans/active/004, executed via subagent-driven development
+- `usePat` hook (`src/hooks/usePat.ts`) backed by sessionStorage key `func-console-gh-pat`
+- `PatModal` component (`src/components/PatModal.tsx`) — non-dismissable PF6 modal, validates PAT via Octokit `GET /user`
+- `useSourceControl` refactored to accept `pat` parameter with `useMemo` (no more module-level singleton)
+- `FunctionsListPage` integrated: `usePat()` + `PatModal` gate, `useFunctionListPage(pat)` skips API when empty
+- `FunctionCreatePage` integrated: `usePat()` + `PatModal` gate, passes session PAT to `pushFiles()`
+- `CreateFunctionForm` PAT field removed (interface, state, validation, JSX)
+- `__GITHUB_PAT__` removed from webpack DefinePlugin and `src/globals.d.ts` deleted
+- i18n strings added, webpack build verified
+- 9 commits, 46 tests across 11 suites, all passing
+- Feature marked as passing in features.json, plan moved to completed/
+- feat: do not use login step for OCP internal registry in the generated function GH WF
+Left off: Feature complete. Ready for manual testing and PR.
+Blockers: None
+
---
## 2026-04-02 | Session: Function Create Page
Worked on: Full-stack Function Create Page feature (plan 003)
diff --git a/docs/design/2026-04-13-session-wide-gh-pat-design.md b/docs/design/2026-04-13-session-wide-gh-pat-design.md
new file mode 100644
index 0000000..f1eb8fd
--- /dev/null
+++ b/docs/design/2026-04-13-session-wide-gh-pat-design.md
@@ -0,0 +1,122 @@
+# Session-Wide GitHub Personal Access Token
+
+**Date:** 2026-04-13
+**Status:** Draft
+**Feature:** Session wide GitHub Personal Access Token (features.json)
+
+## Problem
+
+The GitHub PAT is currently injected at build time via webpack `DefinePlugin` from the `GITHUB_PAT` environment variable. This compiles the PAT as a string literal into the distributed JavaScript bundle — anyone with access to the bundle can extract it. Additionally, the Function Create form has a separate PAT text input, creating two inconsistent PAT entry points.
+
+## Solution
+
+Replace the build-time PAT with a session-wide runtime PAT. On first visit to any plugin page, a modal prompts the user to enter their PAT. The PAT is validated against the GitHub API, stored in `sessionStorage` (survives refresh, cleared on tab close), and used by all services that need GitHub access.
+
+## Architecture
+
+### PAT Storage Hook — `usePat`
+
+Location: `src/hooks/usePat.ts`
+
+A custom hook backed by `sessionStorage`. Returns `{ pat, setPat, clearPat }`.
+
+- Reads initial value from `sessionStorage` key `func-console-gh-pat` on mount.
+- `setPat(value)` writes to `sessionStorage` and updates React state.
+- `clearPat()` removes from `sessionStorage` and resets state to empty string.
+
+No React Context is needed. OCP dynamic plugins load each page as an independent module with no shared parent component. Since `sessionStorage` is the actual cross-page shared state, each page calls `usePat()` independently and gets the current value.
+
+### PAT Modal — `PatModal`
+
+Location: `src/components/PatModal.tsx`
+
+A PatternFly `Modal` component displayed when no PAT is set.
+
+**Props:**
+- `isOpen: boolean` — controls visibility
+- `onSave: (pat: string) => void` — called with validated PAT
+
+**Behavior:**
+- Non-dismissable: no close button, no backdrop click dismiss. The user must provide a valid PAT to use the plugin.
+- Contains a password `TextInput` for the PAT.
+- Save button triggers validation: calls GitHub API `GET /user` via Octokit with the entered PAT.
+- On success: calls `onSave` with the PAT.
+- On failure: shows an inline PatternFly `Alert` with the error message.
+- Save button shows a loading spinner (`isLoading`) while validating.
+
+### Service Layer Changes
+
+**`useSourceControl(pat: string): SourceControlService`**
+
+The hook accepts a `pat` parameter. Returns a `GithubService` instance memoized on `pat` via `useMemo`. No more module-level singleton. The `GithubService` class is unchanged — constructor still takes PAT, creates Octokit once per instance.
+
+**`OctokitGitHubService.pushFiles`** — unchanged. Already accepts PAT as a parameter.
+
+**`useGitHubService`** — unchanged. The `pushFiles` method already takes PAT as an argument.
+
+### Webpack Cleanup
+
+- Remove `DefinePlugin` entry for `__GITHUB_PAT__` from `webpack.config.ts`.
+- Remove `declare const __GITHUB_PAT__: string` from `src/globals.d.ts`.
+
+### Page Integration
+
+Each page that requires a PAT renders the modal conditionally:
+
+```tsx
+function SomePage() {
+ const { pat, setPat } = usePat();
+
+ return (
+ <>
+
+ {/* page content */}
+ >
+ );
+}
+```
+
+**FunctionsListPage:**
+- Add `usePat()` in the page component, pass `pat` to `useFunctionListPage(pat)`.
+- `useFunctionListPage(pat)` passes `pat` to `useSourceControl(pat)`.
+- The `useEffect` that fetches repos skips the API call when `pat` is empty (avoids a failed request while the modal is visible).
+- Render `PatModal` when `pat` is empty.
+
+**FunctionCreatePage:**
+- Add `usePat()`, pass `pat` to `gitHubService.pushFiles()` instead of `data.pat`.
+- Render `PatModal` when `pat` is empty.
+
+**CreateFunctionForm:**
+- Remove the PAT `TextInput` field.
+- Remove `pat` from `CreateFunctionFormData` interface.
+- Remove `pat` from form validation and local state.
+
+**FunctionEditPage:** Not modified — PAT integration deferred to when that page is built out.
+
+## Files Changed
+
+| File | Change |
+|------|--------|
+| `src/hooks/usePat.ts` | New — sessionStorage-backed PAT hook |
+| `src/components/PatModal.tsx` | New — modal with PAT validation |
+| `webpack.config.ts` | Remove `__GITHUB_PAT__` DefinePlugin entry |
+| `src/globals.d.ts` | Remove `__GITHUB_PAT__` declaration |
+| `src/services/source-control/useSourceControl.ts` | Accept `pat` param, `useMemo` instance |
+| `src/components/CreateFunctionForm.tsx` | Remove PAT field, update interface |
+| `src/views/FunctionCreatePage.tsx` | Use `usePat()`, render `PatModal` |
+| `src/views/FunctionsListPage.tsx` | Use `usePat()`, pass to `useSourceControl`, render `PatModal` |
+
+## Test Strategy
+
+**New test suites:**
+
+- `usePat.test.ts` — returns empty string when sessionStorage is empty; `setPat` writes to sessionStorage and updates state; `clearPat` removes from sessionStorage and resets state.
+- `PatModal.test.tsx` — renders when `isOpen` is true; does not render when false; shows error on invalid PAT (mock Octokit rejects); calls `onSave` with valid PAT (mock Octokit resolves); Save button shows spinner while validating.
+
+**Modified test suites:**
+
+- `CreateFunctionForm.test.tsx` — remove PAT field assertions, update form data expectations.
+- `FunctionCreatePage.test.tsx` — remove PAT from form submit data, mock `usePat` to return a token.
+- `FunctionsListPage.test.tsx` — mock `usePat`, verify `PatModal` renders when PAT is empty.
+
+**Mocking:** sessionStorage is available in jsdom. Octokit validation call mocked via jest module mock, consistent with existing patterns.
diff --git a/docs/features.json b/docs/features.json
index 7b99c50..80cb538 100644
--- a/docs/features.json
+++ b/docs/features.json
@@ -31,6 +31,16 @@
],
"passes": true
},
+ {
+ "category": "functional",
+ "description": "Session wide GitHub Personal Access Token",
+ "steps": [
+ "If a user visits the /functions plugin and the GH PAT is not set then there will be prompt to enter it and store it into the session",
+ "The GH PAT it then used where needed (e.g. Function Creation page)",
+ "The GH PAT text box shall be removed from the Function Creation page as it should be stored in the session"
+ ],
+ "passes": true
+ },
{
"category": "functional",
"description": "Function List Page renders a table with function entries",
diff --git a/docs/plans/completed/004-feat-session-wide-gh-pat.md b/docs/plans/completed/004-feat-session-wide-gh-pat.md
new file mode 100644
index 0000000..6b7f898
--- /dev/null
+++ b/docs/plans/completed/004-feat-session-wide-gh-pat.md
@@ -0,0 +1,1107 @@
+# Session-Wide GitHub PAT Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace build-time compiled GitHub PAT with a runtime session-wide PAT entered via modal dialog, validated against GitHub API, and stored in `sessionStorage`.
+
+**Architecture:** Bottom-up — hook first (`usePat`), then modal component (`PatModal`), then service layer update (`useSourceControl`), then page integration (list page, create page), then webpack cleanup. TDD throughout.
+
+**Tech Stack:** React 17, TypeScript, PatternFly 6 (`Modal`, `TextInput`, `Alert`, `Button`), Octokit (`@octokit/rest`), Jest + React Testing Library.
+
+**Docs to read before starting:**
+
+- `docs/ARCHITECTURE.md` — layered architecture, dependency rules, page/component/hook rules
+- `docs/STYLEGUIDE.md` — code style, naming, no `any`, no `console.log`
+- `docs/TESTING.md` — TDD flow, mock patterns, forbidden patterns
+- `docs/design/2026-04-13-session-wide-gh-pat-design.md` — full spec for this feature
+
+---
+
+## File Structure
+
+| Action | File | Responsibility |
+|--------|------|----------------|
+| Create | `src/hooks/usePat.ts` | sessionStorage-backed PAT hook |
+| Create | `src/hooks/usePat.test.ts` | Unit tests for usePat |
+| Create | `src/components/PatModal.tsx` | Modal for PAT entry with GitHub API validation |
+| Create | `src/components/PatModal.test.tsx` | Component tests for PatModal |
+| Modify | `src/services/source-control/useSourceControl.ts` | Accept `pat` param, memoize instance |
+| Modify | `src/services/source-control/useSourceControl.ts` test references | Existing tests mock this — update mock signatures |
+| Modify | `src/views/FunctionsListPage.tsx` | Use `usePat()`, pass pat to hook, render PatModal |
+| Modify | `src/views/FunctionsListPage.test.tsx` | Update mocks, add PAT modal tests |
+| Modify | `src/components/CreateFunctionForm.tsx` | Remove PAT field |
+| Modify | `src/components/CreateFunctionForm.test.tsx` | Remove PAT assertions |
+| Modify | `src/views/FunctionCreatePage.tsx` | Use `usePat()`, render PatModal |
+| Modify | `src/views/FunctionCreatePage.test.tsx` | Mock usePat, remove PAT from form flow |
+| Modify | `webpack.config.ts` | Remove `__GITHUB_PAT__` DefinePlugin entry |
+| Modify | `src/globals.d.ts` | Remove `__GITHUB_PAT__` declaration |
+| Modify | `locales/en/plugin__console-functions-plugin.json` | Add new i18n strings |
+
+---
+
+### Task 1: Create `usePat` hook
+
+**Files:**
+
+- Create: `src/hooks/usePat.ts`
+- Create: `src/hooks/usePat.test.ts`
+
+- [ ] **Step 1: Write failing test — returns empty string when sessionStorage is empty**
+
+Create `src/hooks/usePat.test.ts`:
+
+```typescript
+import { render, screen, act } from '@testing-library/react';
+import { usePat } from './usePat';
+
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
+function TestComponent() {
+ const { pat } = usePat();
+ return {pat};
+}
+
+describe('usePat', () => {
+ it('returns empty string when sessionStorage is empty', () => {
+ render();
+
+ expect(screen.getByTestId('pat').textContent).toBe('');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `yarn test src/hooks/usePat.test.ts`
+Expected: FAIL — module `./usePat` not found.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/hooks/usePat.ts`:
+
+```typescript
+import { useState } from 'react';
+
+const SESSION_KEY = 'func-console-gh-pat';
+
+export function usePat(): { pat: string; setPat: (value: string) => void; clearPat: () => void } {
+ const [pat, setPatState] = useState(() => sessionStorage.getItem(SESSION_KEY) ?? '');
+
+ const setPat = (value: string) => {
+ sessionStorage.setItem(SESSION_KEY, value);
+ setPatState(value);
+ };
+
+ const clearPat = () => {
+ sessionStorage.removeItem(SESSION_KEY);
+ setPatState('');
+ };
+
+ return { pat, setPat, clearPat };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `yarn test src/hooks/usePat.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Write failing test — reads initial value from sessionStorage**
+
+Add to `src/hooks/usePat.test.ts`:
+
+```typescript
+ it('reads initial value from sessionStorage', () => {
+ sessionStorage.setItem('func-console-gh-pat', 'ghp_stored');
+
+ render();
+
+ expect(screen.getByTestId('pat').textContent).toBe('ghp_stored');
+ });
+```
+
+- [ ] **Step 6: Run test to verify it passes** (implementation already handles this)
+
+Run: `yarn test src/hooks/usePat.test.ts`
+Expected: PASS
+
+- [ ] **Step 7: Write failing test — setPat writes to sessionStorage and updates state**
+
+Add `SetTestComponent` and test to `src/hooks/usePat.test.ts`:
+
+```typescript
+function SetTestComponent() {
+ const { pat, setPat } = usePat();
+ return (
+ <>
+ {pat}
+
+ >
+ );
+}
+```
+
+```typescript
+ it('setPat writes to sessionStorage and updates state', () => {
+ render();
+
+ act(() => {
+ screen.getByRole('button', { name: 'set' }).click();
+ });
+
+ expect(screen.getByTestId('pat').textContent).toBe('ghp_new');
+ expect(sessionStorage.getItem('func-console-gh-pat')).toBe('ghp_new');
+ });
+```
+
+- [ ] **Step 8: Run test to verify it passes**
+
+Run: `yarn test src/hooks/usePat.test.ts`
+Expected: PASS
+
+- [ ] **Step 9: Write failing test — clearPat removes from sessionStorage and resets state**
+
+Add `ClearTestComponent` and test to `src/hooks/usePat.test.ts`:
+
+```typescript
+function ClearTestComponent() {
+ const { pat, setPat, clearPat } = usePat();
+ return (
+ <>
+ {pat}
+
+
+ >
+ );
+}
+```
+
+```typescript
+ it('clearPat removes from sessionStorage and resets state', () => {
+ render();
+
+ act(() => {
+ screen.getByRole('button', { name: 'set' }).click();
+ });
+ expect(screen.getByTestId('pat').textContent).toBe('ghp_temp');
+
+ act(() => {
+ screen.getByRole('button', { name: 'clear' }).click();
+ });
+ expect(screen.getByTestId('pat').textContent).toBe('');
+ expect(sessionStorage.getItem('func-console-gh-pat')).toBeNull();
+ });
+```
+
+- [ ] **Step 10: Run test to verify it passes**
+
+Run: `yarn test src/hooks/usePat.test.ts`
+Expected: PASS
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/hooks/usePat.ts src/hooks/usePat.test.ts
+git commit -m "feat: add usePat hook backed by sessionStorage"
+```
+
+---
+
+### Task 2: Create `PatModal` component
+
+**Files:**
+
+- Create: `src/components/PatModal.tsx`
+- Create: `src/components/PatModal.test.tsx`
+
+- [ ] **Step 1: Write failing test — does not render when isOpen is false**
+
+Create `src/components/PatModal.test.tsx`:
+
+```typescript
+import { render, screen } from '@testing-library/react';
+import { PatModal } from './PatModal';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+const mockGetAuthenticated = jest.fn();
+
+jest.mock('@octokit/rest', () => ({
+ Octokit: jest.fn().mockImplementation(() => ({
+ users: { getAuthenticated: mockGetAuthenticated },
+ })),
+}));
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('PatModal', () => {
+ it('does not render when isOpen is false', () => {
+ render();
+
+ expect(screen.queryByText('GitHub Personal Access Token')).not.toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `yarn test src/components/PatModal.test.tsx`
+Expected: FAIL — module `./PatModal` not found.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/components/PatModal.tsx`:
+
+```typescript
+import { useState } from 'react';
+import {
+ Alert,
+ Button,
+ Form,
+ FormGroup,
+ Modal,
+ ModalBody,
+ ModalFooter,
+ ModalHeader,
+ TextInput,
+} from '@patternfly/react-core';
+import { Octokit } from '@octokit/rest';
+import { useTranslation } from 'react-i18next';
+
+interface PatModalProps {
+ isOpen: boolean;
+ onSave: (pat: string) => void;
+}
+
+export function PatModal({ isOpen, onSave }: PatModalProps) {
+ const { t } = useTranslation('plugin__console-functions-plugin');
+ const [token, setToken] = useState('');
+ const [isValidating, setIsValidating] = useState(false);
+ const [error, setError] = useState('');
+
+ if (!isOpen) return null;
+
+ const handleSave = async () => {
+ setIsValidating(true);
+ setError('');
+
+ try {
+ const octokit = new Octokit({ auth: token });
+ await octokit.users.getAuthenticated();
+ onSave(token);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setIsValidating(false);
+ }
+ };
+
+ return (
+ {}}
+ >
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `yarn test src/components/PatModal.test.tsx`
+Expected: PASS
+
+- [ ] **Step 5: Write failing test — renders modal when isOpen is true**
+
+Add to `src/components/PatModal.test.tsx`:
+
+```typescript
+ it('renders modal with token input when isOpen is true', () => {
+ render();
+
+ expect(screen.getByText('GitHub Personal Access Token')).toBeInTheDocument();
+ expect(screen.getByLabelText(/Personal Access Token/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Save/ })).toBeInTheDocument();
+ });
+```
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run: `yarn test src/components/PatModal.test.tsx`
+Expected: PASS
+
+- [ ] **Step 7: Write failing test — Save is disabled when input is empty**
+
+Add to `src/components/PatModal.test.tsx`:
+
+```typescript
+ it('disables Save button when input is empty', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled();
+ });
+```
+
+- [ ] **Step 8: Run test to verify it passes**
+
+Run: `yarn test src/components/PatModal.test.tsx`
+Expected: PASS
+
+- [ ] **Step 9: Write failing test — calls onSave with valid PAT**
+
+Add to `src/components/PatModal.test.tsx`:
+
+```typescript
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+```
+
+(Update the import at the top of the file to include `waitFor`, and add `userEvent`.)
+
+```typescript
+ it('calls onSave when PAT is valid', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn();
+ mockGetAuthenticated.mockResolvedValue({ data: { login: 'testuser' } });
+
+ render();
+
+ await user.type(screen.getByLabelText(/Personal Access Token/), 'ghp_valid');
+ await user.click(screen.getByRole('button', { name: /Save/ }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith('ghp_valid');
+ });
+ });
+```
+
+- [ ] **Step 10: Run test to verify it passes**
+
+Run: `yarn test src/components/PatModal.test.tsx`
+Expected: PASS
+
+- [ ] **Step 11: Write failing test — shows error on invalid PAT**
+
+Add to `src/components/PatModal.test.tsx`:
+
+```typescript
+ it('shows error when PAT is invalid', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn();
+ mockGetAuthenticated.mockRejectedValue(new Error('Bad credentials'));
+
+ render();
+
+ await user.type(screen.getByLabelText(/Personal Access Token/), 'ghp_invalid');
+ await user.click(screen.getByRole('button', { name: /Save/ }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Bad credentials')).toBeInTheDocument();
+ });
+ expect(onSave).not.toHaveBeenCalled();
+ });
+```
+
+- [ ] **Step 12: Run test to verify it passes**
+
+Run: `yarn test src/components/PatModal.test.tsx`
+Expected: PASS
+
+- [ ] **Step 13: Commit**
+
+```bash
+git add src/components/PatModal.tsx src/components/PatModal.test.tsx
+git commit -m "feat: add PatModal component with GitHub PAT validation"
+```
+
+---
+
+### Task 3: Update `useSourceControl` to accept PAT parameter
+
+**Files:**
+
+- Modify: `src/services/source-control/useSourceControl.ts`
+
+- [ ] **Step 1: Update `useSourceControl` to accept `pat` parameter**
+
+Replace the full content of `src/services/source-control/useSourceControl.ts`:
+
+```typescript
+import { useMemo } from 'react';
+import { GithubService } from './GithubService';
+import { SourceControlService } from './SourceControlService';
+
+export function useSourceControl(pat: string): SourceControlService {
+ return useMemo(() => new GithubService(pat), [pat]);
+}
+```
+
+- [ ] **Step 2: Run all tests to verify nothing is broken**
+
+Run: `yarn test`
+Expected: Tests that mock `useSourceControl` still pass because they mock the entire module. The mock call sites return mock objects regardless of the argument.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/services/source-control/useSourceControl.ts
+git commit -m "refactor: useSourceControl accepts pat parameter instead of build-time global"
+```
+
+---
+
+### Task 4: Integrate `usePat` and `PatModal` into `FunctionsListPage`
+
+**Files:**
+
+- Modify: `src/views/FunctionsListPage.tsx`
+- Modify: `src/views/FunctionsListPage.test.tsx`
+
+- [ ] **Step 1: Write failing test — renders PatModal when PAT is empty**
+
+Add mock for `usePat` and new test to `src/views/FunctionsListPage.test.tsx`.
+
+Add these mock declarations near the top with the other mocks:
+
+```typescript
+const mockUsePat = jest.fn();
+jest.mock('../hooks/usePat', () => ({
+ usePat: () => mockUsePat(),
+}));
+
+jest.mock('../components/PatModal', () => ({
+ PatModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ? 'PatModal-open' : null,
+}));
+```
+
+Add test:
+
+```typescript
+ it('renders PatModal when PAT is empty', () => {
+ mockUsePat.mockReturnValue({ pat: '', setPat: jest.fn(), clearPat: jest.fn() });
+ mockUseSourceControl.mockReturnValue({
+ listFunctionRepos: jest.fn().mockResolvedValue([]),
+ fetchFileContent: jest.fn(),
+ });
+ mockUseClusterService.mockReturnValue({ deployments: [], loaded: true, error: null });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('PatModal-open')).toBeInTheDocument();
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `yarn test src/views/FunctionsListPage.test.tsx`
+Expected: FAIL — module `../hooks/usePat` not found or PatModal not rendered.
+
+- [ ] **Step 3: Update FunctionsListPage to use `usePat` and render `PatModal`**
+
+In `src/views/FunctionsListPage.tsx`, add imports:
+
+```typescript
+import { usePat } from '../hooks/usePat';
+import { PatModal } from '../components/PatModal';
+```
+
+Update the `FunctionsListPage` default export to use `usePat` and pass `pat` to `useFunctionListPage`:
+
+```typescript
+export default function FunctionsListPage() {
+ const { t } = useTranslation('plugin__console-functions-plugin');
+ const { pat, setPat } = usePat();
+ const { functions, loaded, onEdit } = useFunctionListPage(pat);
+
+ return (
+ <>
+
+ {t('Functions')}
+
+
+ {!loaded && (
+
+ )}
+ {loaded && functions.length === 0 && }
+ {loaded && functions.length > 0 && (
+ <>
+
+ {t(
+ 'Serverless functions in your repository and deployed to your cluster. Manage lifecycle, monitor status, and scale on demand.',
+ )}
+
+
+
+
+
+ >
+ )}
+
+ >
+ );
+}
+```
+
+Update `useFunctionListPage` to accept `pat` and pass it to `useSourceControl`:
+
+```typescript
+function useFunctionListPage(pat: string): {
+ functions: FunctionTableItem[];
+ loaded: boolean;
+ onEdit: (name: string) => void;
+} {
+ const sourceControl = useSourceControl(pat);
+ const { deployments, loaded: clusterLoaded } = useClusterService();
+ const navigate = useNavigate();
+
+ const [functionItems, setFunctionItems] = useState([]);
+ const [reposLoaded, setReposLoaded] = useState(false);
+
+ useEffect(() => {
+ if (!pat) {
+ setReposLoaded(true);
+ return;
+ }
+
+ let ignore = false;
+
+ async function loadFunctionTableItems() {
+ const repos = await sourceControl.listFunctionRepos();
+ const items = await Promise.all(
+ repos.map(async (repo) => {
+ const funcYaml = await sourceControl.fetchFileContent(repo, 'func.yaml');
+ const { namespace, runtime } = parseNamespaceAndRuntime(funcYaml, repo.name);
+ return newItem(repo.name, namespace, runtime);
+ }),
+ );
+ if (!ignore) {
+ setFunctionItems(items);
+ setReposLoaded(true);
+ }
+ }
+
+ loadFunctionTableItems().catch(() => {
+ if (!ignore) {
+ setReposLoaded(true);
+ }
+ });
+ return () => {
+ ignore = true;
+ };
+ }, [sourceControl, pat]);
+
+ const functions = useMemo(
+ () =>
+ functionItems.map((item) => {
+ const deployment = deployments.find(
+ (d) => d.metadata?.labels?.['function.knative.dev/name'] === item.name,
+ );
+ return deployment ? enrichItem(item, deployment) : item;
+ }),
+ [functionItems, deployments],
+ );
+
+ const loaded = reposLoaded && clusterLoaded;
+
+ const onEdit = (name: string) => navigate(`/functions/edit/${name}`);
+ return { functions, loaded, onEdit };
+}
+```
+
+- [ ] **Step 4: Update existing tests to mock `usePat` with a valid token**
+
+In `src/views/FunctionsListPage.test.tsx`, add to each existing test's setup (inside each `it` block, before `render`):
+
+```typescript
+ mockUsePat.mockReturnValue({ pat: 'ghp_test', setPat: jest.fn(), clearPat: jest.fn() });
+```
+
+Also update the `useSourceControl` mock to accept a parameter (the mock already ignores it since it returns `mockUseSourceControl()`, so this requires no functional change to the mock definition).
+
+- [ ] **Step 5: Run all tests to verify they pass**
+
+Run: `yarn test src/views/FunctionsListPage.test.tsx`
+Expected: PASS — all existing tests pass plus the new PatModal test.
+
+- [ ] **Step 6: Write failing test — does not render PatModal when PAT is set**
+
+Add to `src/views/FunctionsListPage.test.tsx`:
+
+```typescript
+ it('does not render PatModal when PAT is set', () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_valid', setPat: jest.fn(), clearPat: jest.fn() });
+ mockUseSourceControl.mockReturnValue({
+ listFunctionRepos: jest.fn().mockResolvedValue([]),
+ fetchFileContent: jest.fn(),
+ });
+ mockUseClusterService.mockReturnValue({ deployments: [], loaded: true, error: null });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByText('PatModal-open')).not.toBeInTheDocument();
+ });
+```
+
+- [ ] **Step 7: Run test to verify it passes**
+
+Run: `yarn test src/views/FunctionsListPage.test.tsx`
+Expected: PASS
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/views/FunctionsListPage.tsx src/views/FunctionsListPage.test.tsx
+git commit -m "feat: integrate session PAT into FunctionsListPage with PatModal gate"
+```
+
+---
+
+### Task 5: Remove PAT field from `CreateFunctionForm`
+
+**Files:**
+
+- Modify: `src/components/CreateFunctionForm.tsx`
+- Modify: `src/components/CreateFunctionForm.test.tsx`
+
+- [ ] **Step 1: Update test — remove PAT field assertion from 'renders all form fields'**
+
+In `src/components/CreateFunctionForm.test.tsx`, remove this line from the 'renders all form fields' test:
+
+```typescript
+ expect(screen.getByLabelText(/Personal Access Token/)).toBeInTheDocument();
+```
+
+- [ ] **Step 2: Update test — remove PAT from 'calls onSubmit with form data'**
+
+In `src/components/CreateFunctionForm.test.tsx`, in the 'calls onSubmit with form data when form is filled and Create is clicked' test:
+
+Remove:
+
+```typescript
+ await user.type(screen.getByLabelText(/Personal Access Token/), 'ghp_token');
+```
+
+Update the `toHaveBeenCalledWith` expectation — remove `pat: 'ghp_token'`:
+
+```typescript
+ expect(onSubmit).toHaveBeenCalledWith({
+ owner: 'testuser',
+ repo: 'my-repo',
+ branch: 'main',
+ name: 'my-func',
+ runtime: 'node',
+ registry: 'quay.io/test',
+ namespace: 'default',
+ });
+```
+
+- [ ] **Step 3: Run tests to verify they fail** (PAT field still exists in component)
+
+Run: `yarn test src/components/CreateFunctionForm.test.tsx`
+Expected: FAIL — the onSubmit assertion fails because the component still sends `pat`.
+
+- [ ] **Step 4: Remove PAT from `CreateFunctionForm` component**
+
+In `src/components/CreateFunctionForm.tsx`:
+
+Remove `pat` from the `CreateFunctionFormData` interface:
+
+```typescript
+export interface CreateFunctionFormData {
+ owner: string;
+ repo: string;
+ branch: string;
+ name: string;
+ runtime: FunctionRuntime;
+ registry: string;
+ namespace: string;
+}
+```
+
+Remove the PAT state line:
+
+```typescript
+ const [pat, setPat] = useState('');
+```
+
+Update validation — remove `pat` from the condition:
+
+```typescript
+ const isValid = owner && repo && branch && name && registry && namespace;
+```
+
+Update handleSubmit — remove `pat` from the data object:
+
+```typescript
+ const handleSubmit = () => {
+ onSubmit({ owner, repo, branch, name, runtime, registry, namespace });
+ };
+```
+
+Remove the PAT FormGroup JSX block (lines 84-92 in the original file):
+
+```tsx
+
+ setPat(val)}
+ />
+
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `yarn test src/components/CreateFunctionForm.test.tsx`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/CreateFunctionForm.tsx src/components/CreateFunctionForm.test.tsx
+git commit -m "refactor: remove PAT field from CreateFunctionForm"
+```
+
+---
+
+### Task 6: Integrate `usePat` and `PatModal` into `FunctionCreatePage`
+
+**Files:**
+
+- Modify: `src/views/FunctionCreatePage.tsx`
+- Modify: `src/views/FunctionCreatePage.test.tsx`
+
+- [ ] **Step 1: Update test — add usePat mock and remove PAT from form fill**
+
+In `src/views/FunctionCreatePage.test.tsx`, add these mocks near the top with the other mocks:
+
+```typescript
+const mockUsePat = jest.fn();
+jest.mock('../hooks/usePat', () => ({
+ usePat: () => mockUsePat(),
+}));
+
+jest.mock('../components/PatModal', () => ({
+ PatModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ? 'PatModal-open' : null,
+}));
+```
+
+Update the `fillForm` helper — remove the PAT line:
+
+```typescript
+const fillForm = async (user: ReturnType) => {
+ await user.type(screen.getByRole('textbox', { name: /Owner/ }), 'testuser');
+ await user.type(screen.getByRole('textbox', { name: /Repository/ }), 'my-repo');
+ await user.type(screen.getByRole('textbox', { name: /Branch/ }), 'main');
+ await user.type(screen.getByRole('textbox', { name: /^Name$/ }), 'my-func');
+ await user.type(screen.getByRole('textbox', { name: /Registry/ }), 'quay.io/test');
+ await user.type(screen.getByRole('textbox', { name: /Namespace/ }), 'default');
+};
+```
+
+Add `mockUsePat` setup to each existing test (before `render`):
+
+```typescript
+ mockUsePat.mockReturnValue({ pat: 'ghp_session', setPat: jest.fn(), clearPat: jest.fn() });
+```
+
+Update the `pushFiles` assertion — the PAT should now be `'ghp_session'` (from usePat), not `'ghp_token'` (from form):
+
+```typescript
+ await waitFor(() => {
+ expect(mockPushFiles).toHaveBeenCalledWith(
+ { owner: 'testuser', repo: 'my-repo', branch: 'main' },
+ 'ghp_session',
+ files,
+ 'Initialize Knative function project',
+ );
+ });
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `yarn test src/views/FunctionCreatePage.test.tsx`
+Expected: FAIL — `FunctionCreatePage` doesn't use `usePat` yet, so pushFiles still receives the old PAT.
+
+- [ ] **Step 3: Update FunctionCreatePage to use `usePat` and `PatModal`**
+
+Replace `src/views/FunctionCreatePage.tsx`:
+
+```typescript
+import { useState } from 'react';
+import { DocumentTitle, ListPageHeader } from '@openshift-console/dynamic-plugin-sdk';
+import { Alert, PageSection } from '@patternfly/react-core';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom-v5-compat';
+import { CreateFunctionForm, CreateFunctionFormData } from '../components/CreateFunctionForm';
+import { useFunctionService } from '../services/function/useFunctionService';
+import { useGitHubService } from '../services/github/useGitHubService';
+import { usePat } from '../hooks/usePat';
+import { PatModal } from '../components/PatModal';
+
+export default function FunctionCreatePage() {
+ const { t } = useTranslation('plugin__console-functions-plugin');
+ const navigate = useNavigate();
+ const functionService = useFunctionService();
+ const gitHubService = useGitHubService();
+ const { pat, setPat } = usePat();
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (data: CreateFunctionFormData) => {
+ setIsSubmitting(true);
+ setError(null);
+
+ try {
+ const files = await functionService.generateFunction({
+ name: data.name,
+ runtime: data.runtime,
+ registry: data.registry,
+ namespace: data.namespace,
+ branch: data.branch,
+ });
+
+ await gitHubService.pushFiles(
+ { owner: data.owner, repo: data.repo, branch: data.branch },
+ pat,
+ files,
+ 'Initialize Knative function project',
+ );
+
+ navigate('/functions');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleCancel = () => {
+ navigate('/functions');
+ };
+
+ return (
+ <>
+
+ {t('Create function')}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ >
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `yarn test src/views/FunctionCreatePage.test.tsx`
+Expected: PASS
+
+- [ ] **Step 5: Write failing test — renders PatModal when PAT is empty**
+
+Add to `src/views/FunctionCreatePage.test.tsx`:
+
+```typescript
+ it('renders PatModal when PAT is empty', () => {
+ mockUsePat.mockReturnValue({ pat: '', setPat: jest.fn(), clearPat: jest.fn() });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('PatModal-open')).toBeInTheDocument();
+ });
+```
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run: `yarn test src/views/FunctionCreatePage.test.tsx`
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/views/FunctionCreatePage.tsx src/views/FunctionCreatePage.test.tsx
+git commit -m "feat: integrate session PAT into FunctionCreatePage with PatModal gate"
+```
+
+---
+
+### Task 7: Remove `__GITHUB_PAT__` from webpack and globals
+
+**Files:**
+
+- Modify: `webpack.config.ts`
+- Modify: `src/globals.d.ts`
+
+- [ ] **Step 1: Remove DefinePlugin entry from `webpack.config.ts`**
+
+In `webpack.config.ts`, change the plugins array. Remove the `__GITHUB_PAT__` entry from `DefinePlugin`. If `DefinePlugin` has no other entries, remove the entire `DefinePlugin` usage and its import.
+
+Current code:
+
+```typescript
+ plugins: [
+ new DefinePlugin({
+ __GITHUB_PAT__: JSON.stringify(process.env.GITHUB_PAT || ''),
+ }),
+ new ConsoleRemotePlugin(),
+```
+
+Replace with:
+
+```typescript
+ plugins: [
+ new ConsoleRemotePlugin(),
+```
+
+Also remove `DefinePlugin` from the webpack import. Change:
+
+```typescript
+import { Configuration as WebpackConfiguration, DefinePlugin } from 'webpack';
+```
+
+To:
+
+```typescript
+import { Configuration as WebpackConfiguration } from 'webpack';
+```
+
+- [ ] **Step 2: Remove `__GITHUB_PAT__` declaration from `src/globals.d.ts`**
+
+Delete the file `src/globals.d.ts` — it only contains the `__GITHUB_PAT__` declaration and nothing else.
+
+- [ ] **Step 3: Run all tests to verify nothing is broken**
+
+Run: `yarn test`
+Expected: PASS — all tests pass. No code references `__GITHUB_PAT__` anymore.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add webpack.config.ts src/globals.d.ts
+git commit -m "chore: remove build-time __GITHUB_PAT__ from webpack DefinePlugin and globals"
+```
+
+---
+
+### Task 8: Add i18n strings and run final verification
+
+**Files:**
+
+- Modify: `locales/en/plugin__console-functions-plugin.json`
+
+- [ ] **Step 1: Add new i18n keys**
+
+Add these keys to `locales/en/plugin__console-functions-plugin.json` (in alphabetical order):
+
+```
+"GitHub Personal Access Token": "GitHub Personal Access Token",
+"Invalid token": "Invalid token",
+"Save": "Save",
+```
+
+Note: `"Personal Access Token"` already exists in the locale file.
+
+- [ ] **Step 2: Run full test suite**
+
+Run: `yarn test`
+Expected: PASS — all tests across all suites pass.
+
+- [ ] **Step 3: Verify webpack build succeeds**
+
+Run: `yarn build-dev`
+Expected: Build completes without errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add locales/en/plugin__console-functions-plugin.json
+git commit -m "chore: add i18n strings for PatModal"
+```
+
+---
+
+### Task 9: Update `features.json`
+
+**Files:**
+
+- Modify: `docs/features.json`
+
+- [ ] **Step 1: Mark the feature as passing**
+
+In `docs/features.json`, find the "Session wide GitHub Personal Access Token" entry and set `"passes": true`.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/features.json
+git commit -m "chore: mark session-wide GH PAT feature as passing"
+```
diff --git a/locales/en/plugin__console-functions-plugin.json b/locales/en/plugin__console-functions-plugin.json
index a116e80..728197f 100644
--- a/locales/en/plugin__console-functions-plugin.json
+++ b/locales/en/plugin__console-functions-plugin.json
@@ -11,7 +11,9 @@
"Error creating function": "Error creating function",
"Function Settings": "Function Settings",
"Functions": "Functions",
+ "GitHub Personal Access Token": "GitHub Personal Access Token",
"GitHub Settings": "GitHub Settings",
+ "Invalid token": "Invalid token",
"Language": "Language",
"Loading": "Loading",
"Name": "Name",
@@ -24,6 +26,7 @@
"Repository": "Repository",
"Runtime": "Runtime",
"Serverless functions in your repository and deployed to your cluster. Manage lifecycle, monitor status, and scale on demand.": "Serverless functions in your repository and deployed to your cluster. Manage lifecycle, monitor status, and scale on demand.",
+ "Save": "Save",
"Status": "Status",
"URL": "URL",
"Undeploy": "Undeploy"
diff --git a/src/components/CreateFunctionForm.test.tsx b/src/components/CreateFunctionForm.test.tsx
index 19ff14d..b45b85c 100644
--- a/src/components/CreateFunctionForm.test.tsx
+++ b/src/components/CreateFunctionForm.test.tsx
@@ -27,7 +27,6 @@ describe('CreateFunctionForm', () => {
expect(screen.getByRole('textbox', { name: /Owner/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Repository/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Branch/ })).toBeInTheDocument();
- expect(screen.getByLabelText(/Personal Access Token/)).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /^Name$/ })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /Language/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Registry/ })).toBeInTheDocument();
@@ -80,7 +79,6 @@ describe('CreateFunctionForm', () => {
await user.type(screen.getByRole('textbox', { name: /Owner/ }), 'testuser');
await user.type(screen.getByRole('textbox', { name: /Repository/ }), 'my-repo');
await user.type(screen.getByRole('textbox', { name: /Branch/ }), 'main');
- await user.type(screen.getByLabelText(/Personal Access Token/), 'ghp_token');
await user.type(screen.getByRole('textbox', { name: /^Name$/ }), 'my-func');
await user.type(screen.getByRole('textbox', { name: /Registry/ }), 'quay.io/test');
await user.type(screen.getByRole('textbox', { name: /Namespace/ }), 'default');
@@ -91,7 +89,6 @@ describe('CreateFunctionForm', () => {
owner: 'testuser',
repo: 'my-repo',
branch: 'main',
- pat: 'ghp_token',
name: 'my-func',
runtime: 'node',
registry: 'quay.io/test',
diff --git a/src/components/CreateFunctionForm.tsx b/src/components/CreateFunctionForm.tsx
index 5a143b1..3074748 100644
--- a/src/components/CreateFunctionForm.tsx
+++ b/src/components/CreateFunctionForm.tsx
@@ -16,7 +16,6 @@ export interface CreateFunctionFormData {
owner: string;
repo: string;
branch: string;
- pat: string;
name: string;
runtime: FunctionRuntime;
registry: string;
@@ -42,16 +41,15 @@ export function CreateFunctionForm({ onSubmit, onCancel, isSubmitting }: CreateF
const [owner, setOwner] = useState('');
const [repo, setRepo] = useState('');
const [branch, setBranch] = useState('');
- const [pat, setPat] = useState('');
const [name, setName] = useState('');
const [runtime, setRuntime] = useState('node');
const [registry, setRegistry] = useState('');
const [namespace, setNamespace] = useState('');
- const isValid = owner && repo && branch && pat && name && registry && namespace;
+ const isValid = owner && repo && branch && name && registry && namespace;
const handleSubmit = () => {
- onSubmit({ owner, repo, branch, pat, name, runtime, registry, namespace });
+ onSubmit({ owner, repo, branch, name, runtime, registry, namespace });
};
return (
@@ -81,15 +79,6 @@ export function CreateFunctionForm({ onSubmit, onCancel, isSubmitting }: CreateF
onChange={(_e, val) => setBranch(val)}
/>
-
- setPat(val)}
- />
-
diff --git a/src/components/PatModal.test.tsx b/src/components/PatModal.test.tsx
new file mode 100644
index 0000000..4785ac4
--- /dev/null
+++ b/src/components/PatModal.test.tsx
@@ -0,0 +1,74 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { PatModal } from './PatModal';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+const mockGetAuthenticated = jest.fn();
+
+jest.mock('@octokit/rest', () => ({
+ Octokit: jest.fn().mockImplementation(() => ({
+ users: { getAuthenticated: mockGetAuthenticated },
+ })),
+}));
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('PatModal', () => {
+ it('does not render when isOpen is false', () => {
+ render();
+
+ expect(screen.queryByText('GitHub Personal Access Token')).not.toBeInTheDocument();
+ });
+
+ it('renders modal with token input when isOpen is true', () => {
+ render();
+
+ expect(screen.getByText('GitHub Personal Access Token')).toBeInTheDocument();
+ expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Save/ })).toBeInTheDocument();
+ });
+
+ it('disables Save button when input is empty', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled();
+ });
+
+ it('calls onSave when PAT is valid', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn();
+ mockGetAuthenticated.mockResolvedValue({ data: { login: 'testuser' } });
+
+ render();
+
+ const input = document.getElementById('pat-input') as HTMLInputElement;
+ await user.type(input, 'ghp_valid');
+ await user.click(screen.getByRole('button', { name: /Save/ }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith('ghp_valid');
+ });
+ });
+
+ it('shows error when PAT is invalid', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn();
+ mockGetAuthenticated.mockRejectedValue(new Error('Bad credentials'));
+
+ render();
+
+ const input = document.getElementById('pat-input') as HTMLInputElement;
+ await user.type(input, 'ghp_invalid');
+ await user.click(screen.getByRole('button', { name: /Save/ }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Bad credentials')).toBeInTheDocument();
+ });
+ expect(onSave).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/PatModal.tsx b/src/components/PatModal.tsx
new file mode 100644
index 0000000..0734cac
--- /dev/null
+++ b/src/components/PatModal.tsx
@@ -0,0 +1,81 @@
+import { useState } from 'react';
+import {
+ Alert,
+ Button,
+ Form,
+ FormGroup,
+ Modal,
+ ModalBody,
+ ModalFooter,
+ ModalHeader,
+ TextInput,
+} from '@patternfly/react-core';
+import { Octokit } from '@octokit/rest';
+import { useTranslation } from 'react-i18next';
+
+interface PatModalProps {
+ isOpen: boolean;
+ onSave: (pat: string) => void;
+}
+
+export function PatModal({ isOpen, onSave }: PatModalProps) {
+ const { t } = useTranslation('plugin__console-functions-plugin');
+ const [token, setToken] = useState('');
+ const [isValidating, setIsValidating] = useState(false);
+ const [error, setError] = useState('');
+
+ if (!isOpen) return null;
+
+ const handleSave = async () => {
+ setIsValidating(true);
+ setError('');
+
+ try {
+ const octokit = new Octokit({ auth: token });
+ await octokit.users.getAuthenticated();
+ onSave(token);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setIsValidating(false);
+ }
+ };
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/globals.d.ts b/src/globals.d.ts
deleted file mode 100644
index aac78e0..0000000
--- a/src/globals.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare const __GITHUB_PAT__: string;
diff --git a/src/hooks/usePat.test.tsx b/src/hooks/usePat.test.tsx
new file mode 100644
index 0000000..48d0221
--- /dev/null
+++ b/src/hooks/usePat.test.tsx
@@ -0,0 +1,74 @@
+import { render, screen, act } from '@testing-library/react';
+import { usePat } from './usePat';
+
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
+function TestComponent() {
+ const { pat } = usePat();
+ return {pat};
+}
+
+function SetTestComponent() {
+ const { pat, setPat } = usePat();
+ return (
+ <>
+ {pat}
+
+ >
+ );
+}
+
+function ClearTestComponent() {
+ const { pat, setPat, clearPat } = usePat();
+ return (
+ <>
+ {pat}
+
+
+ >
+ );
+}
+
+describe('usePat', () => {
+ it('returns empty string when sessionStorage is empty', () => {
+ render();
+
+ expect(screen.getByTestId('pat').textContent).toBe('');
+ });
+
+ it('setPat writes to sessionStorage and updates state', () => {
+ render();
+
+ act(() => {
+ screen.getByRole('button', { name: 'set' }).click();
+ });
+
+ expect(screen.getByTestId('pat').textContent).toBe('ghp_new');
+ expect(sessionStorage.getItem('func-console-gh-pat')).toBe('ghp_new');
+ });
+
+ it('reads initial value from sessionStorage', () => {
+ sessionStorage.setItem('func-console-gh-pat', 'ghp_stored');
+
+ render();
+
+ expect(screen.getByTestId('pat').textContent).toBe('ghp_stored');
+ });
+
+ it('clearPat removes from sessionStorage and resets state', () => {
+ render();
+
+ act(() => {
+ screen.getByRole('button', { name: 'set' }).click();
+ });
+ expect(screen.getByTestId('pat').textContent).toBe('ghp_temp');
+
+ act(() => {
+ screen.getByRole('button', { name: 'clear' }).click();
+ });
+ expect(screen.getByTestId('pat').textContent).toBe('');
+ expect(sessionStorage.getItem('func-console-gh-pat')).toBeNull();
+ });
+});
diff --git a/src/hooks/usePat.ts b/src/hooks/usePat.ts
new file mode 100644
index 0000000..b075cff
--- /dev/null
+++ b/src/hooks/usePat.ts
@@ -0,0 +1,19 @@
+import { useState } from 'react';
+
+const SESSION_KEY = 'func-console-gh-pat';
+
+export function usePat(): { pat: string; setPat: (value: string) => void; clearPat: () => void } {
+ const [pat, setPatState] = useState(() => sessionStorage.getItem(SESSION_KEY) ?? '');
+
+ const setPat = (value: string) => {
+ sessionStorage.setItem(SESSION_KEY, value);
+ setPatState(value);
+ };
+
+ const clearPat = () => {
+ sessionStorage.removeItem(SESSION_KEY);
+ setPatState('');
+ };
+
+ return { pat, setPat, clearPat };
+}
diff --git a/src/services/source-control/useSourceControl.ts b/src/services/source-control/useSourceControl.ts
index 7f204ff..4eaa4f7 100644
--- a/src/services/source-control/useSourceControl.ts
+++ b/src/services/source-control/useSourceControl.ts
@@ -1,12 +1,7 @@
+import { useMemo } from 'react';
import { GithubService } from './GithubService';
import { SourceControlService } from './SourceControlService';
-// PAT injected via webpack DefinePlugin from GITHUB_PAT env variable.
-// For dev/testing: export GITHUB_PAT=ghp_... before running yarn start.
-// DO NOT hardcode a real PAT here — this file is committed.
-const pat = typeof __GITHUB_PAT__ !== 'undefined' ? __GITHUB_PAT__ : '';
-const instance = new GithubService(pat);
-
-export function useSourceControl(): SourceControlService {
- return instance;
+export function useSourceControl(pat: string): SourceControlService {
+ return useMemo(() => new GithubService(pat), [pat]);
}
diff --git a/src/views/FunctionCreatePage.test.tsx b/src/views/FunctionCreatePage.test.tsx
index 2d0e32d..0c8dd23 100644
--- a/src/views/FunctionCreatePage.test.tsx
+++ b/src/views/FunctionCreatePage.test.tsx
@@ -6,6 +6,7 @@ import FunctionCreatePage from './FunctionCreatePage';
const mockGenerateFunction = jest.fn();
const mockPushFiles = jest.fn();
const mockNavigate = jest.fn();
+const mockUsePat = jest.fn();
jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
@@ -28,6 +29,15 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigate: () => mockNavigate,
}));
+jest.mock('../hooks/usePat', () => ({
+ usePat: () => mockUsePat(),
+}));
+
+jest.mock('../components/PatModal', () => ({
+ PatModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ? PatModal-open
: null,
+}));
+
afterEach(() => {
jest.clearAllMocks();
});
@@ -36,7 +46,6 @@ const fillForm = async (user: ReturnType) => {
await user.type(screen.getByRole('textbox', { name: /Owner/ }), 'testuser');
await user.type(screen.getByRole('textbox', { name: /Repository/ }), 'my-repo');
await user.type(screen.getByRole('textbox', { name: /Branch/ }), 'main');
- await user.type(screen.getByLabelText(/Personal Access Token/), 'ghp_token');
await user.type(screen.getByRole('textbox', { name: /^Name$/ }), 'my-func');
await user.type(screen.getByRole('textbox', { name: /Registry/ }), 'quay.io/test');
await user.type(screen.getByRole('textbox', { name: /Namespace/ }), 'default');
@@ -44,6 +53,8 @@ const fillForm = async (user: ReturnType) => {
describe('FunctionCreatePage', () => {
it('renders CreateFunctionForm', () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_session', setPat: jest.fn(), clearPat: jest.fn() });
+
render(
@@ -59,6 +70,7 @@ describe('FunctionCreatePage', () => {
const files = [{ path: 'func.yaml', mode: '100644', content: 'name: f', type: 'blob' }];
mockGenerateFunction.mockResolvedValue(files);
mockPushFiles.mockResolvedValue(undefined);
+ mockUsePat.mockReturnValue({ pat: 'ghp_session', setPat: jest.fn(), clearPat: jest.fn() });
render(
@@ -82,7 +94,7 @@ describe('FunctionCreatePage', () => {
await waitFor(() => {
expect(mockPushFiles).toHaveBeenCalledWith(
{ owner: 'testuser', repo: 'my-repo', branch: 'main' },
- 'ghp_token',
+ 'ghp_session',
files,
'Initialize Knative function project',
);
@@ -96,6 +108,7 @@ describe('FunctionCreatePage', () => {
it('shows an alert on error', async () => {
const user = userEvent.setup();
mockGenerateFunction.mockRejectedValue(new Error('Backend error'));
+ mockUsePat.mockReturnValue({ pat: 'ghp_session', setPat: jest.fn(), clearPat: jest.fn() });
render(
@@ -110,4 +123,16 @@ describe('FunctionCreatePage', () => {
expect(screen.getByText('Backend error')).toBeInTheDocument();
});
});
+
+ it('renders PatModal when PAT is empty', () => {
+ mockUsePat.mockReturnValue({ pat: '', setPat: jest.fn(), clearPat: jest.fn() });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('PatModal-open')).toBeInTheDocument();
+ });
});
diff --git a/src/views/FunctionCreatePage.tsx b/src/views/FunctionCreatePage.tsx
index aa7348d..b5cf846 100644
--- a/src/views/FunctionCreatePage.tsx
+++ b/src/views/FunctionCreatePage.tsx
@@ -6,12 +6,15 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { CreateFunctionForm, CreateFunctionFormData } from '../components/CreateFunctionForm';
import { useFunctionService } from '../services/function/useFunctionService';
import { useGitHubService } from '../services/github/useGitHubService';
+import { usePat } from '../hooks/usePat';
+import { PatModal } from '../components/PatModal';
export default function FunctionCreatePage() {
const { t } = useTranslation('plugin__console-functions-plugin');
const navigate = useNavigate();
const functionService = useFunctionService();
const gitHubService = useGitHubService();
+ const { pat, setPat } = usePat();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
@@ -31,7 +34,7 @@ export default function FunctionCreatePage() {
await gitHubService.pushFiles(
{ owner: data.owner, repo: data.repo, branch: data.branch },
- data.pat,
+ pat,
files,
'Initialize Knative function project',
);
@@ -50,6 +53,7 @@ export default function FunctionCreatePage() {
return (
<>
+
{t('Create function')}
diff --git a/src/views/FunctionsListPage.test.tsx b/src/views/FunctionsListPage.test.tsx
index 4384da2..37e4264 100644
--- a/src/views/FunctionsListPage.test.tsx
+++ b/src/views/FunctionsListPage.test.tsx
@@ -18,7 +18,7 @@ jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({
const mockUseSourceControl = jest.fn();
jest.mock('../services/source-control/useSourceControl', () => ({
- useSourceControl: () => mockUseSourceControl(),
+ useSourceControl: (pat: string) => mockUseSourceControl(pat),
}));
const mockUseClusterService = jest.fn();
@@ -26,6 +26,16 @@ jest.mock('../services/cluster/useClusterService', () => ({
useClusterService: () => mockUseClusterService(),
}));
+const mockUsePat = jest.fn();
+jest.mock('../hooks/usePat', () => ({
+ usePat: () => mockUsePat(),
+}));
+
+jest.mock('../components/PatModal', () => ({
+ PatModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ? PatModal-open
: null,
+}));
+
jest.mock('../components/FunctionTable', () => ({
FunctionTable: ({ functions }: { functions: { name: string }[] }) =>
functions.map((f) => f.name).join(','),
@@ -37,6 +47,7 @@ afterEach(() => {
describe('FunctionsListPage', () => {
it('renders a spinner while loading', () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_test', setPat: jest.fn(), clearPat: jest.fn() });
mockUseSourceControl.mockReturnValue({
listFunctionRepos: jest.fn().mockResolvedValue([]),
fetchFileContent: jest.fn(),
@@ -53,6 +64,7 @@ describe('FunctionsListPage', () => {
});
it('renders the empty state when loaded with no functions', async () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_test', setPat: jest.fn(), clearPat: jest.fn() });
mockUseSourceControl.mockReturnValue({
listFunctionRepos: jest.fn().mockResolvedValue([]),
fetchFileContent: jest.fn(),
@@ -69,6 +81,7 @@ describe('FunctionsListPage', () => {
});
it('renders table when functions are loaded', async () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_test', setPat: jest.fn(), clearPat: jest.fn() });
mockUseSourceControl.mockReturnValue({
listFunctionRepos: jest.fn().mockResolvedValue([
{
@@ -110,6 +123,7 @@ describe('FunctionsListPage', () => {
});
it('shows NotDeployed status for repos without cluster deployment', async () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_test', setPat: jest.fn(), clearPat: jest.fn() });
mockUseSourceControl.mockReturnValue({
listFunctionRepos: jest.fn().mockResolvedValue([
{
@@ -135,6 +149,7 @@ describe('FunctionsListPage', () => {
});
it('renders empty state when GitHub API fails', async () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_test', setPat: jest.fn(), clearPat: jest.fn() });
mockUseSourceControl.mockReturnValue({
listFunctionRepos: jest.fn().mockRejectedValue(new Error('Requires authentication')),
fetchFileContent: jest.fn(),
@@ -149,4 +164,38 @@ describe('FunctionsListPage', () => {
expect(await screen.findByRole('heading', { name: 'No functions found' })).toBeInTheDocument();
});
+
+ it('renders PatModal when PAT is empty', () => {
+ mockUsePat.mockReturnValue({ pat: '', setPat: jest.fn(), clearPat: jest.fn() });
+ mockUseSourceControl.mockReturnValue({
+ listFunctionRepos: jest.fn().mockResolvedValue([]),
+ fetchFileContent: jest.fn(),
+ });
+ mockUseClusterService.mockReturnValue({ deployments: [], loaded: true, error: null });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('PatModal-open')).toBeInTheDocument();
+ });
+
+ it('does not render PatModal when PAT is set', () => {
+ mockUsePat.mockReturnValue({ pat: 'ghp_valid', setPat: jest.fn(), clearPat: jest.fn() });
+ mockUseSourceControl.mockReturnValue({
+ listFunctionRepos: jest.fn().mockResolvedValue([]),
+ fetchFileContent: jest.fn(),
+ });
+ mockUseClusterService.mockReturnValue({ deployments: [], loaded: true, error: null });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByText('PatModal-open')).not.toBeInTheDocument();
+ });
});
diff --git a/src/views/FunctionsListPage.tsx b/src/views/FunctionsListPage.tsx
index e043396..e88b96f 100644
--- a/src/views/FunctionsListPage.tsx
+++ b/src/views/FunctionsListPage.tsx
@@ -11,13 +11,17 @@ import { FunctionsEmptyState } from '../components/EmptyState';
import { FunctionStatus, FunctionTable, FunctionTableItem } from '../components/FunctionTable';
import { useSourceControl } from '../services/source-control/useSourceControl';
import { useClusterService } from '../services/cluster/useClusterService';
+import { usePat } from '../hooks/usePat';
+import { PatModal } from '../components/PatModal';
export default function FunctionsListPage() {
const { t } = useTranslation('plugin__console-functions-plugin');
- const { functions, loaded, onEdit } = useFunctionListPage();
+ const { pat, setPat } = usePat();
+ const { functions, loaded, onEdit } = useFunctionListPage(pat);
return (
<>
+
{t('Functions')}
@@ -48,12 +52,12 @@ export default function FunctionsListPage() {
);
}
-function useFunctionListPage(): {
+function useFunctionListPage(pat: string): {
functions: FunctionTableItem[];
loaded: boolean;
onEdit: (name: string) => void;
} {
- const sourceControl = useSourceControl();
+ const sourceControl = useSourceControl(pat);
const { deployments, loaded: clusterLoaded } = useClusterService();
const navigate = useNavigate();
@@ -61,6 +65,11 @@ function useFunctionListPage(): {
const [reposLoaded, setReposLoaded] = useState(false);
useEffect(() => {
+ if (!pat) {
+ setReposLoaded(true);
+ return;
+ }
+
let ignore = false;
async function loadFunctionTableItems() {
@@ -86,7 +95,7 @@ function useFunctionListPage(): {
return () => {
ignore = true;
};
- }, [sourceControl]);
+ }, [sourceControl, pat]);
const functions = useMemo(
() =>
diff --git a/webpack.config.ts b/webpack.config.ts
index 548da90..1e91fbe 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -1,7 +1,7 @@
/* eslint-env node */
import * as path from 'path';
-import { Configuration as WebpackConfiguration, DefinePlugin } from 'webpack';
+import { Configuration as WebpackConfiguration } from 'webpack';
import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
import { ConsoleRemotePlugin } from '@openshift-console/dynamic-plugin-sdk-webpack';
@@ -74,9 +74,6 @@ const config: Configuration = {
},
},
plugins: [
- new DefinePlugin({
- __GITHUB_PAT__: JSON.stringify(process.env.GITHUB_PAT || ''),
- }),
new ConsoleRemotePlugin(),
new CopyWebpackPlugin({
patterns: [{ from: path.resolve(__dirname, 'locales'), to: 'locales' }],