Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit 1e51bf1

Browse files
matejvasekclaude
andcommitted
feat: add runtime PAT entry and user avatar
Replace compile-time PAT injection (webpack DefinePlugin, __GITHUB_PAT__) with runtime entry via a modal dialog. PAT is stored in sessionStorage and read lazily per-request through a cached Octokit getter. UserAvatar component renders in ListPageHeader on every page. On the list page it is clickable (opens PatModal to connect or reconnect); on create and edit pages it is read-only. PatModal auto-opens on first visit when no PAT is stored. Cancel and modal close are disabled while validation is in flight. ForgeConnectionProvider context lets FunctionsListPage react when the user connects. EmptyState shows a PAT hint and disables the Create button when not connected. FunctionCreatePage hides the form entirely and shows a warning alert when accessed without a PAT. GithubService refactored: single Octokit instance with a cached getter that re-creates it only when the token changes. fetchUserInfo added to SourceControlService interface (forge-agnostic). GitHubUser renamed to ForgeUser. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
1 parent 066fce0 commit 1e51bf1

22 files changed

Lines changed: 709 additions & 63 deletions

.vscode/settings.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
},
1717
"json.schemas": [
1818
{
19-
"fileMatch": ["**/console-extensions.json"],
19+
"fileMatch": [
20+
"**/console-extensions.json"
21+
],
2022
"url": "./node_modules/@openshift-console/dynamic-plugin-sdk-webpack/schema/console-extensions.json"
2123
}
22-
]
24+
],
25+
"typescript.preferences.importModuleSpecifier": "relative"
2326
}

docs/claude-progress.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
# Claude Progress Log
22
# Newest entries first. Agents: append your entry at the top after the header.
33

4+
---
5+
## 2026-04-28 | Session: Runtime PAT entry and user avatar
6+
Worked on: Replace compile-time PAT injection with runtime modal entry, add user avatar to page headers
7+
Completed:
8+
- Runtime PAT entry via PatModal (PF6 Modal, password input, validation, error alert, small variant)
9+
- UserAvatar component in ListPageHeader on all pages (list: clickable, create/edit: disabled)
10+
- PatModal and useUserAvatar consolidated into UserAvatar as private internals, usePatModal hook extracted
11+
- ForgeConnectionProvider context for cross-component PAT state sharing
12+
- GithubService refactored: lazy auth via cached Octokit getter, fetchUserInfo on SourceControlService interface
13+
- ForgeUser type (forge-agnostic naming), PAT and user stored in sessionStorage
14+
- EmptyState simplified to single isCreateDisabled prop (PAT hint text internal, no Tooltip)
15+
- FunctionsListPage wired: autoOpen modal, disabled Create when unauthenticated, skip API call when no PAT
16+
- FunctionCreatePage protected against unauthenticated URL access (warning alert, form hidden)
17+
- Removed DefinePlugin for __GITHUB_PAT__ from webpack.config.ts, deleted src/globals.d.ts
18+
- Extracted errorMessage utility (src/utils/errorMessage.ts)
19+
- Cancel and modal close disabled while validating
20+
- 9 suites, 55 tests, all passing, zero lint errors
21+
Left off: Squash and merge PR #15.
22+
Blockers: None
23+
424
---
525
## 2026-04-20 | Session: CI/CD pipeline and ESLint fixes
626
Worked on: GitHub Actions CI/CD pipeline, ESLint config fixes, README restructure

docs/features.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,13 @@
103103
"PatModal opens automatically on first visit to Function List Page when no PAT is stored in session",
104104
"PatModal is dismissable — user can close it without entering a PAT",
105105
"PatModal validates the PAT against GitHub API and on success stores it in session and closes",
106-
"SourceControlService interface extended with init(pat: string) and isInitialized(): boolean methods; GithubService implementation updated accordingly",
107106
"UserAvatar component renders inside ListPageHeader at the top-right corner on every page (list, create, edit)",
108107
"UserAvatar has two states: not initialized shows 'Connect to GitHub' with a key/lock icon (clickable, opens PatModal); initialized shows GitHub username with avatar icon",
109108
"UserAvatar is clickable only on the Function List Page, clicking reopens PatModal to change the PAT and associated GitHub user",
110109
"On Create and Edit pages, UserAvatar displays the GitHub user but is not clickable (PAT cannot be changed from those pages)",
111110
"When PatModal is dismissed without a PAT, the Function List Page (both empty state and table view) shows a hint text directing the user to click 'Connect to GitHub' in the top-right corner to set a PAT",
112111
"PAT change logic is encapsulated in a useUserAvatar custom hook following layered architecture (Hook imports Service, Component imports Hook)",
113-
"If the GH PAT is not set in the session then the Create button is inactive/disabled. The tooltiop of the button should say that PAT is required.",
112+
"If the GH PAT is not set in the session then the Create button is inactive/disabled.",
114113
"The GH PAT must not be compiled/hardcode into code at compile time."
115114
],
116115
"passes": false

locales/en/plugin__console-functions-plugin.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
{
2+
"A GitHub Personal Access Token is required to create functions. Click 'Connect to GitHub' in the top-right corner to connect. Once connected, the create button will be enabled.": "A GitHub Personal Access Token is required to create functions. Click 'Connect to GitHub' in the top-right corner to connect. Once connected, the create button will be enabled.",
3+
"A GitHub Personal Access Token is required to create functions. Go to the Functions page and click 'Connect to GitHub' to connect.": "A GitHub Personal Access Token is required to create functions. Go to the Functions page and click 'Connect to GitHub' to connect.",
24
"Actions": "Actions",
35
"Branch": "Branch",
46
"Cancel": "Cancel",
7+
"Connect": "Connect",
8+
"Connect to GitHub": "Connect to GitHub",
59
"Create": "Create",
610
"Create a serverless function to get started.": "Create a serverless function to get started.",
711
"Create function": "Create function",
812
"Create new function": "Create new function",
913
"Delete": "Delete",
1014
"Edit": "Edit",
15+
"Enter your GitHub Personal Access Token to connect your repositories.": "Enter your GitHub Personal Access Token to connect your repositories.",
1116
"Error creating function": "Error creating function",
1217
"Function Settings": "Function Settings",
1318
"FaaS": "FaaS",

src/components/EmptyState.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,32 @@ describe('FunctionsEmptyState', () => {
3131
const link = screen.getByRole('link', { name: 'Create function' });
3232
expect(link).toHaveAttribute('href', '/faas/create');
3333
});
34+
35+
it('shows PAT hint and disabled button when isCreateDisabled is true', () => {
36+
render(
37+
<MemoryRouter>
38+
<FunctionsEmptyState isCreateDisabled />
39+
</MemoryRouter>,
40+
);
41+
42+
expect(
43+
screen.getByText(/A GitHub Personal Access Token is required to create functions/),
44+
).toBeInTheDocument();
45+
expect(
46+
screen.queryByText('Create a serverless function to get started.'),
47+
).not.toBeInTheDocument();
48+
expect(screen.getByRole('button', { name: 'Create function' })).toBeDisabled();
49+
});
50+
51+
it('shows standard body text when isCreateDisabled is false', () => {
52+
render(
53+
<MemoryRouter>
54+
<FunctionsEmptyState isCreateDisabled={false} />
55+
</MemoryRouter>,
56+
);
57+
58+
expect(screen.getByText('Create a serverless function to get started.')).toBeInTheDocument();
59+
const link = screen.getByRole('link', { name: 'Create function' });
60+
expect(link).toHaveAttribute('href', '/faas/create');
61+
});
3462
});

src/components/EmptyState.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,33 @@ import { CubesIcon } from '@patternfly/react-icons';
99
import { useTranslation } from 'react-i18next';
1010
import { Link } from 'react-router-dom-v5-compat';
1111

12-
export function FunctionsEmptyState() {
12+
interface FunctionsEmptyStateProps {
13+
isCreateDisabled?: boolean;
14+
}
15+
16+
export function FunctionsEmptyState({ isCreateDisabled }: FunctionsEmptyStateProps) {
1317
const { t } = useTranslation('plugin__console-functions-plugin');
1418

1519
return (
1620
<EmptyState headingLevel="h2" icon={CubesIcon} titleText={t('No functions found')}>
17-
<EmptyStateBody>{t('Create a serverless function to get started.')}</EmptyStateBody>
21+
<EmptyStateBody>
22+
{isCreateDisabled
23+
? t(
24+
"A GitHub Personal Access Token is required to create functions. Click 'Connect to GitHub' in the top-right corner to connect. Once connected, the create button will be enabled.",
25+
)
26+
: t('Create a serverless function to get started.')}
27+
</EmptyStateBody>
1828
<EmptyStateFooter>
1929
<EmptyStateActions>
20-
<Button variant="primary" component={(props) => <Link {...props} to="/faas/create" />}>
21-
{t('Create function')}
22-
</Button>
30+
{isCreateDisabled ? (
31+
<Button variant="primary" isDisabled>
32+
{t('Create function')}
33+
</Button>
34+
) : (
35+
<Button variant="primary" component={(props) => <Link {...props} to="/faas/create" />}>
36+
{t('Create function')}
37+
</Button>
38+
)}
2339
</EmptyStateActions>
2440
</EmptyStateFooter>
2541
</EmptyState>

src/components/UserAvatar.test.tsx

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { UserAvatar } from './UserAvatar';
4+
import { PAT_KEY, USER_KEY } from '../services/types';
5+
import { ForgeConnectionContext } from '../context/ForgeConnectionProvider';
6+
import { ReactNode } from 'react';
7+
8+
jest.mock('react-i18next', () => ({
9+
useTranslation: () => ({ t: (key: string) => key }),
10+
}));
11+
12+
const mockFetchUserInfo = jest.fn();
13+
jest.mock('../services/source-control/useSourceControlService', () => ({
14+
useSourceControlService: () => ({
15+
fetchUserInfo: mockFetchUserInfo,
16+
}),
17+
}));
18+
19+
const testUser = { name: 'twoGiants' };
20+
21+
function renderWithContext(
22+
ui: ReactNode,
23+
contextValue = { isActive: false, connectToForge: jest.fn() },
24+
) {
25+
return render(
26+
<ForgeConnectionContext.Provider value={contextValue}>{ui}</ForgeConnectionContext.Provider>,
27+
);
28+
}
29+
30+
describe('UserAvatar', () => {
31+
beforeEach(() => {
32+
sessionStorage.clear();
33+
});
34+
35+
afterEach(() => {
36+
jest.restoreAllMocks();
37+
});
38+
39+
afterAll(() => {
40+
sessionStorage.clear();
41+
});
42+
43+
describe('rendering', () => {
44+
it('renders "Connect to GitHub" when no user is stored', () => {
45+
renderWithContext(<UserAvatar enableReconnect={false} />);
46+
47+
expect(screen.getByText('Connect to GitHub')).toBeInTheDocument();
48+
});
49+
50+
it('renders username when user is stored in sessionStorage', () => {
51+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
52+
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
53+
54+
renderWithContext(<UserAvatar enableReconnect />);
55+
56+
expect(screen.getByText('twoGiants')).toBeInTheDocument();
57+
});
58+
59+
it('button is clickable when enableReconnect is true', async () => {
60+
const user = userEvent.setup();
61+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
62+
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
63+
64+
renderWithContext(<UserAvatar enableReconnect />);
65+
66+
const button = screen.getByRole('button', { name: 'twoGiants' });
67+
await user.click(button);
68+
69+
expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
70+
});
71+
72+
it('button is disabled when enableReconnect is false', async () => {
73+
const user = userEvent.setup();
74+
75+
renderWithContext(<UserAvatar enableReconnect={false} />);
76+
77+
const button = screen.getByRole('button', { name: 'Connect to GitHub' });
78+
expect(button).toBeDisabled();
79+
80+
await user.click(button);
81+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
82+
});
83+
});
84+
85+
describe('modal auto-open', () => {
86+
it('opens modal automatically when enableReconnect is true and no PAT stored', () => {
87+
renderWithContext(<UserAvatar enableReconnect />);
88+
89+
expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
90+
});
91+
92+
it('does not auto-open modal when PAT is already stored', () => {
93+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
94+
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
95+
96+
renderWithContext(<UserAvatar enableReconnect />);
97+
98+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
99+
});
100+
101+
it('does not auto-open modal when enableReconnect is false', () => {
102+
renderWithContext(<UserAvatar enableReconnect={false} />);
103+
104+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
105+
});
106+
});
107+
108+
describe('PAT modal', () => {
109+
it('Connect button disabled when input is empty', () => {
110+
renderWithContext(<UserAvatar enableReconnect />);
111+
112+
expect(screen.getByRole('button', { name: 'Connect' })).toBeDisabled();
113+
});
114+
115+
it('calls fetchUserInfo with PAT and updates UI on successful connect', async () => {
116+
const user = userEvent.setup();
117+
const connectToForge = jest.fn();
118+
mockFetchUserInfo.mockResolvedValue(testUser);
119+
120+
renderWithContext(<UserAvatar enableReconnect />, { isActive: false, connectToForge });
121+
122+
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_valid');
123+
await user.click(screen.getByRole('button', { name: 'Connect' }));
124+
125+
await waitFor(() => {
126+
expect(mockFetchUserInfo).toHaveBeenCalledWith('ghp_valid');
127+
});
128+
129+
expect(screen.getByText('twoGiants')).toBeInTheDocument();
130+
expect(sessionStorage.getItem(PAT_KEY)).toBe('ghp_valid');
131+
expect(JSON.parse(sessionStorage.getItem(USER_KEY)!)).toEqual(testUser);
132+
expect(connectToForge).toHaveBeenCalled();
133+
});
134+
135+
it('shows error alert when fetchUserInfo rejects', async () => {
136+
const user = userEvent.setup();
137+
mockFetchUserInfo.mockRejectedValue(new Error('Bad credentials'));
138+
139+
renderWithContext(<UserAvatar enableReconnect />);
140+
141+
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_bad');
142+
await user.click(screen.getByRole('button', { name: 'Connect' }));
143+
144+
expect(await screen.findByText('Bad credentials')).toBeInTheDocument();
145+
});
146+
147+
it('closes modal when Cancel is clicked', async () => {
148+
const user = userEvent.setup();
149+
150+
renderWithContext(<UserAvatar enableReconnect />);
151+
152+
expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
153+
154+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
155+
156+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
157+
});
158+
159+
it('disables Cancel button while validating', async () => {
160+
const user = userEvent.setup();
161+
let resolveConnect: () => void;
162+
mockFetchUserInfo.mockReturnValue(
163+
new Promise<void>((resolve) => {
164+
resolveConnect = resolve;
165+
}),
166+
);
167+
168+
renderWithContext(<UserAvatar enableReconnect />);
169+
170+
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_slow');
171+
await user.click(screen.getByRole('button', { name: 'Connect' }));
172+
173+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
174+
175+
resolveConnect!();
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)