Skip to content

Commit 1d0c032

Browse files
andyatmiamiclaude
andcommitted
feat: allow auth overrides in debug menu
Backend auth will soon be enabled by default. To support local development, contributors need a way to configure the kubeflow-userid and kubeflow-groups request headers from the frontend UI at runtime, without restarting the dev server. This adds an Auth Headers section to the Debug page with User and Groups text fields that persist to localStorage. A dev-only Axios request interceptor reads the saved values and injects the corresponding headers into all API requests when running in development mode (APP_ENV=development). Changes: - Add devAuth.ts utility with localStorage helpers and interceptor - Add DebugAuthSection component with form, validation, and save - Replace Debug page placeholder with the new auth section - Register interceptor on all API instances in notebookApisImpl - Add unit tests for all devAuth utility functions (12 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Andy Stoneberg <astonebe@redhat.com>
1 parent d4ef1ca commit 1d0c032

5 files changed

Lines changed: 292 additions & 32 deletions

File tree

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,17 @@
11
import React from 'react';
2-
import { CubesIcon } from '@patternfly/react-icons/dist/esm/icons/cubes-icon';
3-
import { Button } from '@patternfly/react-core/dist/esm/components/Button';
4-
import {
5-
EmptyState,
6-
EmptyStateBody,
7-
EmptyStateVariant,
8-
EmptyStateFooter,
9-
} from '@patternfly/react-core/dist/esm/components/EmptyState';
102
import { PageSection } from '@patternfly/react-core/dist/esm/components/Page';
3+
import { Title } from '@patternfly/react-core/dist/esm/components/Title';
4+
import { DebugAuthSection } from './DebugAuthSection';
115

126
const Debug: React.FunctionComponent = () => (
13-
<PageSection>
14-
<EmptyState
15-
variant={EmptyStateVariant.full}
16-
titleText="Debug page (for development only)"
17-
icon={CubesIcon}
18-
>
19-
<EmptyStateBody>
20-
This represents an the empty state pattern in Patternfly 6. Hopefully it&apos;s simple
21-
enough to use but flexible enough to meet a variety of needs.
22-
</EmptyStateBody>
23-
<EmptyStateFooter>
24-
<Button variant="primary">Primary Action</Button>
25-
</EmptyStateFooter>
26-
</EmptyState>
27-
</PageSection>
7+
<>
8+
<PageSection data-testid="debug-page">
9+
<Title headingLevel="h1">Debug Settings</Title>
10+
</PageSection>
11+
<PageSection>
12+
<DebugAuthSection />
13+
</PageSection>
14+
</>
2815
);
2916

3017
export { Debug };
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React, { useState } from 'react';
2+
import { useNotification } from 'mod-arch-core';
3+
import { Alert, AlertVariant } from '@patternfly/react-core/dist/esm/components/Alert';
4+
import { Button } from '@patternfly/react-core/dist/esm/components/Button';
5+
import { Card, CardBody, CardTitle } from '@patternfly/react-core/dist/esm/components/Card';
6+
import { Content } from '@patternfly/react-core/dist/esm/components/Content';
7+
import { Form, ActionGroup } from '@patternfly/react-core/dist/esm/components/Form';
8+
import { HelperText } from '@patternfly/react-core/dist/esm/components/HelperText';
9+
import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput';
10+
import ThemeAwareFormGroupWrapper from '~/shared/components/ThemeAwareFormGroupWrapper';
11+
import { getDevAuthUser, getDevAuthGroups, setDevAuth } from '~/shared/utilities/devAuth';
12+
13+
export const DebugAuthSection: React.FC = () => {
14+
const notification = useNotification();
15+
16+
const [user, setUser] = useState(() => getDevAuthUser());
17+
const [groups, setGroups] = useState(() => getDevAuthGroups());
18+
const [error, setError] = useState<string | null>(null);
19+
20+
const handleSave = () => {
21+
const trimmedUser = user.trim();
22+
const trimmedGroups = groups.trim();
23+
24+
if (!trimmedUser && !trimmedGroups) {
25+
setError('At least one of User or Groups must be provided.');
26+
return;
27+
}
28+
29+
setError(null);
30+
setDevAuth(trimmedUser, trimmedGroups);
31+
notification.success('Auth headers saved.');
32+
};
33+
34+
return (
35+
<Card data-testid="debug-auth-section">
36+
<CardTitle>Auth Headers</CardTitle>
37+
<CardBody>
38+
<Content component="p">
39+
Configure the <code>kubeflow-userid</code> and <code>kubeflow-groups</code> headers sent
40+
with API requests during local development.
41+
</Content>
42+
{error && (
43+
<Alert
44+
variant={AlertVariant.warning}
45+
isInline
46+
title={error}
47+
data-testid="debug-auth-error"
48+
/>
49+
)}
50+
<Form>
51+
<ThemeAwareFormGroupWrapper label="User" fieldId="debug-auth-user">
52+
<TextInput
53+
id="debug-auth-user"
54+
data-testid="debug-auth-user-input"
55+
value={user}
56+
onChange={(_event, value) => {
57+
setUser(value);
58+
setError(null);
59+
}}
60+
aria-label="User ID"
61+
/>
62+
</ThemeAwareFormGroupWrapper>
63+
<ThemeAwareFormGroupWrapper
64+
label="Groups"
65+
fieldId="debug-auth-groups"
66+
helperTextNode={<HelperText>Comma-separated list of group IDs.</HelperText>}
67+
>
68+
<TextInput
69+
id="debug-auth-groups"
70+
data-testid="debug-auth-groups-input"
71+
value={groups}
72+
onChange={(_event, value) => {
73+
setGroups(value);
74+
setError(null);
75+
}}
76+
aria-label="Groups"
77+
/>
78+
</ThemeAwareFormGroupWrapper>
79+
<ActionGroup>
80+
<Button variant="primary" onClick={handleSave} data-testid="debug-auth-save-button">
81+
Save
82+
</Button>
83+
</ActionGroup>
84+
</Form>
85+
</CardBody>
86+
</Card>
87+
);
88+
};

workspaces/frontend/src/shared/api/notebookApi.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Storageclasses } from '~/generated/Storageclasses';
66
import { Workspacekinds } from '~/generated/Workspacekinds';
77
import { Workspaces } from '~/generated/Workspaces';
88
import { ApiInstance } from '~/shared/api/types';
9+
import { DEV_MODE } from '~/shared/utilities/const';
10+
import { registerDevAuthInterceptor } from '~/shared/utilities/devAuth';
911

1012
export interface NotebookApis {
1113
healthCheck: ApiInstance<typeof Healthcheck>;
@@ -20,13 +22,19 @@ export interface NotebookApis {
2022
export const notebookApisImpl = (path: string): NotebookApis => {
2123
const commonConfig = { baseURL: path };
2224

23-
return {
24-
healthCheck: new Healthcheck(commonConfig),
25-
namespaces: new Namespaces(commonConfig),
26-
workspaces: new Workspaces(commonConfig),
27-
workspaceKinds: new Workspacekinds(commonConfig),
28-
secrets: new Secrets(commonConfig),
29-
pvc: new Persistentvolumeclaims(commonConfig),
30-
storageClasses: new Storageclasses(commonConfig),
31-
};
25+
const healthCheck = new Healthcheck(commonConfig);
26+
const namespaces = new Namespaces(commonConfig);
27+
const workspaces = new Workspaces(commonConfig);
28+
const workspaceKinds = new Workspacekinds(commonConfig);
29+
const secrets = new Secrets(commonConfig);
30+
const pvc = new Persistentvolumeclaims(commonConfig);
31+
const storageClasses = new Storageclasses(commonConfig);
32+
33+
if (DEV_MODE) {
34+
[healthCheck, namespaces, workspaces, workspaceKinds, secrets, pvc, storageClasses].forEach(
35+
(api) => registerDevAuthInterceptor(api.instance),
36+
);
37+
}
38+
39+
return { healthCheck, namespaces, workspaces, workspaceKinds, secrets, pvc, storageClasses };
3240
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import axios from 'axios';
2+
import {
3+
getDevAuthUser,
4+
getDevAuthGroups,
5+
setDevAuth,
6+
registerDevAuthInterceptor,
7+
} from '~/shared/utilities/devAuth';
8+
9+
describe('devAuth', () => {
10+
beforeEach(() => {
11+
localStorage.clear();
12+
});
13+
14+
describe('getDevAuthUser', () => {
15+
it('should return "admin" when localStorage is empty', () => {
16+
expect(getDevAuthUser()).toBe('admin');
17+
});
18+
19+
it('should return the stored value', () => {
20+
localStorage.setItem('kubeflow-dev-auth-user', 'test-user');
21+
expect(getDevAuthUser()).toBe('test-user');
22+
});
23+
24+
it('should return empty string when stored as empty', () => {
25+
localStorage.setItem('kubeflow-dev-auth-user', '');
26+
expect(getDevAuthUser()).toBe('');
27+
});
28+
});
29+
30+
describe('getDevAuthGroups', () => {
31+
it('should return empty string when localStorage is empty', () => {
32+
expect(getDevAuthGroups()).toBe('');
33+
});
34+
35+
it('should return the stored value', () => {
36+
localStorage.setItem('kubeflow-dev-auth-groups', 'group-a,group-b');
37+
expect(getDevAuthGroups()).toBe('group-a,group-b');
38+
});
39+
});
40+
41+
describe('setDevAuth', () => {
42+
it('should persist both values to localStorage', () => {
43+
setDevAuth('my-user', 'grp1,grp2');
44+
expect(localStorage.getItem('kubeflow-dev-auth-user')).toBe('my-user');
45+
expect(localStorage.getItem('kubeflow-dev-auth-groups')).toBe('grp1,grp2');
46+
});
47+
48+
it('should handle empty strings', () => {
49+
setDevAuth('', '');
50+
expect(localStorage.getItem('kubeflow-dev-auth-user')).toBe('');
51+
expect(localStorage.getItem('kubeflow-dev-auth-groups')).toBe('');
52+
});
53+
});
54+
55+
describe('registerDevAuthInterceptor', () => {
56+
// Access the interceptor handler directly via the internal handlers array.
57+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
58+
const getInterceptorFn = (instance: ReturnType<typeof axios.create>): ((config: any) => any) =>
59+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
60+
(instance.interceptors.request as any).handlers[0].fulfilled;
61+
62+
it('should set kubeflow-userid header when user is non-empty', () => {
63+
setDevAuth('dev-user', '');
64+
const instance = axios.create();
65+
registerDevAuthInterceptor(instance);
66+
67+
const config = getInterceptorFn(instance)({
68+
headers: new axios.AxiosHeaders(),
69+
});
70+
71+
expect(config.headers['kubeflow-userid']).toBe('dev-user');
72+
expect(config.headers['kubeflow-groups']).toBeUndefined();
73+
});
74+
75+
it('should set kubeflow-groups header when groups is non-empty', () => {
76+
setDevAuth('', 'editors,viewers');
77+
const instance = axios.create();
78+
registerDevAuthInterceptor(instance);
79+
80+
const config = getInterceptorFn(instance)({
81+
headers: new axios.AxiosHeaders(),
82+
});
83+
84+
expect(config.headers['kubeflow-userid']).toBeUndefined();
85+
expect(config.headers['kubeflow-groups']).toBe('editors,viewers');
86+
});
87+
88+
it('should set both headers when both are non-empty', () => {
89+
setDevAuth('dev-user', 'editors,viewers');
90+
const instance = axios.create();
91+
registerDevAuthInterceptor(instance);
92+
93+
const config = getInterceptorFn(instance)({
94+
headers: new axios.AxiosHeaders(),
95+
});
96+
97+
expect(config.headers['kubeflow-userid']).toBe('dev-user');
98+
expect(config.headers['kubeflow-groups']).toBe('editors,viewers');
99+
});
100+
101+
it('should not set headers when values are empty or whitespace', () => {
102+
setDevAuth(' ', ' ');
103+
const instance = axios.create();
104+
registerDevAuthInterceptor(instance);
105+
106+
const config = getInterceptorFn(instance)({
107+
headers: new axios.AxiosHeaders(),
108+
});
109+
110+
expect(config.headers['kubeflow-userid']).toBeUndefined();
111+
expect(config.headers['kubeflow-groups']).toBeUndefined();
112+
});
113+
114+
it('should trim whitespace from values', () => {
115+
setDevAuth(' padded-user ', ' grp1 , grp2 ');
116+
const instance = axios.create();
117+
registerDevAuthInterceptor(instance);
118+
119+
const config = getInterceptorFn(instance)({
120+
headers: new axios.AxiosHeaders(),
121+
});
122+
123+
expect(config.headers['kubeflow-userid']).toBe('padded-user');
124+
expect(config.headers['kubeflow-groups']).toBe('grp1 , grp2');
125+
});
126+
});
127+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
2+
3+
const DEV_AUTH_USER_KEY = 'kubeflow-dev-auth-user';
4+
const DEV_AUTH_GROUPS_KEY = 'kubeflow-dev-auth-groups';
5+
6+
const USERID_HEADER = 'kubeflow-userid';
7+
const GROUPS_HEADER = 'kubeflow-groups';
8+
9+
const DEFAULT_USER = 'admin';
10+
11+
export const getDevAuthUser = (): string => {
12+
try {
13+
return localStorage.getItem(DEV_AUTH_USER_KEY) ?? DEFAULT_USER;
14+
} catch {
15+
return DEFAULT_USER;
16+
}
17+
};
18+
19+
export const getDevAuthGroups = (): string => {
20+
try {
21+
return localStorage.getItem(DEV_AUTH_GROUPS_KEY) ?? '';
22+
} catch {
23+
return '';
24+
}
25+
};
26+
27+
export const setDevAuth = (user: string, groups: string): void => {
28+
try {
29+
localStorage.setItem(DEV_AUTH_USER_KEY, user);
30+
localStorage.setItem(DEV_AUTH_GROUPS_KEY, groups);
31+
} catch {
32+
// localStorage may be unavailable
33+
}
34+
};
35+
36+
export const registerDevAuthInterceptor = (axiosInstance: AxiosInstance): void => {
37+
axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
38+
const user = getDevAuthUser().trim();
39+
const groups = getDevAuthGroups().trim();
40+
41+
if (user) {
42+
config.headers.set(USERID_HEADER, user);
43+
}
44+
if (groups) {
45+
config.headers.set(GROUPS_HEADER, groups);
46+
}
47+
48+
return config;
49+
});
50+
};

0 commit comments

Comments
 (0)