diff --git a/web/packages/design/src/utils/testing.tsx b/web/packages/design/src/utils/testing.tsx
index 7312f4eb1a5af..b68eecff79cfe 100644
--- a/web/packages/design/src/utils/testing.tsx
+++ b/web/packages/design/src/utils/testing.tsx
@@ -25,6 +25,7 @@ import {
render as testingRender,
waitFor,
waitForElementToBeRemoved,
+ within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReactNode } from 'react';
@@ -78,8 +79,8 @@ screen.debug = () => {
};
type RenderOptions = {
- wrapper: React.FC;
- container: HTMLElement;
+ wrapper?: React.FC;
+ container?: HTMLElement;
};
export {
@@ -95,4 +96,5 @@ export {
Router,
userEvent,
waitForElementToBeRemoved,
+ within,
};
diff --git a/web/packages/shared/utils/collectKeys.test.ts b/web/packages/shared/utils/collectKeys.test.ts
new file mode 100644
index 0000000000000..9b7b736af7842
--- /dev/null
+++ b/web/packages/shared/utils/collectKeys.test.ts
@@ -0,0 +1,97 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { collectKeys } from './collectKeys';
+
+describe('collectKeys', () => {
+ it.each`
+ value
+ ${undefined}
+ ${null}
+ ${1}
+ ${true}
+ ${() => {}}
+ `('supports non object values ($value)', ({ value }) => {
+ const actual = collectKeys(value);
+ expect(actual).toBeNull();
+ });
+
+ it('supports empty object values', () => {
+ const actual = collectKeys({});
+ expect(actual).toEqual([]);
+ });
+
+ it('supports empty array values', () => {
+ const actual = collectKeys([]);
+ expect(actual).toEqual([]);
+ });
+
+ it('supports simple object values', () => {
+ const actual = collectKeys({
+ number: 1,
+ boolean: true,
+ string: 'string',
+ function: () => {},
+ null: null,
+ undefined: undefined,
+ });
+ expect(actual).toEqual([
+ '.number',
+ '.boolean',
+ '.string',
+ '.function',
+ '.null',
+ '.undefined',
+ ]);
+ });
+
+ it('supports simple array values', () => {
+ const actual = collectKeys([
+ { alpha: true },
+ { alpha: true },
+ { beta: true },
+ ]);
+ expect(actual).toEqual(['.alpha', '.alpha', '.beta']);
+ });
+
+ it('supports nested object values', () => {
+ const actual = collectKeys([
+ {
+ inner: {
+ foo: 'bar',
+ },
+ },
+ ]);
+ expect(actual).toEqual(['.inner.foo']);
+ });
+
+ it('supports nested array values', () => {
+ const actual = collectKeys([[{ foo: 'bar' }], { bar: 'foo' }]);
+ expect(actual).toEqual(['.foo', '.bar']);
+ });
+
+ it('allows a custom key prefix', () => {
+ const actual = collectKeys(
+ {
+ foo: 1,
+ },
+ 'root'
+ );
+ expect(actual).toEqual(['root.foo']);
+ });
+});
diff --git a/web/packages/shared/utils/collectKeys.ts b/web/packages/shared/utils/collectKeys.ts
new file mode 100644
index 0000000000000..cb96b4358dba7
--- /dev/null
+++ b/web/packages/shared/utils/collectKeys.ts
@@ -0,0 +1,42 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * `collectKeys` gathers object keys recursively and returns them. Arrays are
+ * traversed, but are transparent. Returns null if the value is not an object
+ * or array of objects, and an empty array of no keys are present.
+ *
+ * @param value Value from which keys will be collected
+ * @param prefix An optional value to be prepended to all keys returned
+ * @returns An array of the keys collected (if any) or null
+ */
+export const collectKeys = (value: unknown, prefix: string = '') => {
+ if (typeof value !== 'object' || value === null) {
+ return prefix ? [prefix] : null;
+ }
+
+ if (Array.isArray(value)) {
+ return value.flatMap(val => {
+ return collectKeys(val, prefix);
+ });
+ }
+
+ return Object.entries(value).flatMap(([k, v]) => {
+ return collectKeys(v, `${prefix}.${k}`);
+ });
+};
diff --git a/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx
index 5c9b7a4f5a806..dc6604e8db544 100644
--- a/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx
+++ b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx
@@ -31,9 +31,11 @@ import {
export const JoinTokenIAMForm = ({
tokenState,
onUpdateState,
+ readonly,
}: {
tokenState: NewJoinTokenState;
onUpdateState: (newToken: NewJoinTokenState) => void;
+ readonly: boolean;
}) => {
const rules = tokenState.iam;
@@ -104,6 +106,7 @@ export const JoinTokenIAMForm = ({
onChange={e =>
setTokenRulesField(index, 'aws_account', e.target.value)
}
+ readonly={readonly}
/>
setTokenRulesField(index, 'aws_arn', e.target.value)}
+ readonly={readonly}
/>
))}
@@ -125,9 +129,11 @@ export const JoinTokenIAMForm = ({
export const JoinTokenGCPForm = ({
tokenState,
onUpdateState,
+ readonly,
}: {
tokenState: NewJoinTokenState;
onUpdateState: (newToken: NewJoinTokenState) => void;
+ readonly: boolean;
}) => {
const rules = tokenState.gcp;
function removeRule(index: number) {
@@ -198,6 +204,7 @@ export const JoinTokenGCPForm = ({
value={rule.project_ids}
label="Add Project ID(s)"
rule={requiredField('At least 1 Project ID required')}
+ isDisabled={readonly}
/>
))}
@@ -235,9 +244,11 @@ export const JoinTokenGCPForm = ({
export const JoinTokenOracleForm = ({
tokenState,
onUpdateState,
+ readonly,
}: {
tokenState: NewJoinTokenState;
onUpdateState: (newToken: NewJoinTokenState) => void;
+ readonly: boolean;
}) => {
const rules = tokenState.oracle;
function removeRule(index: number) {
@@ -301,6 +312,7 @@ export const JoinTokenOracleForm = ({
placeholder="ocid1.tenancy.oc1.."
value={rule.tenancy}
onChange={e => updateRuleField(index, 'tenancy', e.target.value)}
+ readonly={readonly}
/>
))}
diff --git a/web/packages/teleport/src/JoinTokens/JoinTokenGithubForm.test.tsx b/web/packages/teleport/src/JoinTokens/JoinTokenGithubForm.test.tsx
new file mode 100644
index 0000000000000..b9c9e817a35a3
--- /dev/null
+++ b/web/packages/teleport/src/JoinTokens/JoinTokenGithubForm.test.tsx
@@ -0,0 +1,390 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import userEvent from '@testing-library/user-event';
+import { PropsWithChildren } from 'react';
+
+import { fireEvent, render, screen, within } from 'design/utils/testing';
+import Validation, { useValidation } from 'shared/components/Validation';
+
+import { ThemeProvider } from 'teleport/ThemeProvider';
+
+import { JoinTokenGithubForm } from './JoinTokenGithubForm';
+import { NewJoinTokenState } from './UpsertJoinTokenDialog';
+
+const populateRuleFieldTest =
+ (
+ field: keyof NewJoinTokenState['github']['rules'][number],
+ placeholer: string,
+ value: string
+ ) =>
+ async () => {
+ const state = baseState();
+ const onUpdate = jest.fn();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ fireEvent.change(screen.getByPlaceholderText(placeholer), {
+ target: { value },
+ });
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate).toHaveBeenLastCalledWith(
+ baseState({
+ rules: [
+ {
+ ...state.github.rules[0],
+ [field]: value,
+ },
+ ],
+ })
+ );
+ };
+
+const populateFieldTest =
+ ({
+ field,
+ placeholer,
+ value,
+ }: {
+ field: keyof NewJoinTokenState['github'];
+ placeholer: string;
+ value: string;
+ }) =>
+ async () => {
+ const state = baseState();
+ const onUpdate = jest.fn();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ fireEvent.change(screen.getByPlaceholderText(placeholer), {
+ target: { value },
+ });
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate).toHaveBeenLastCalledWith(
+ baseState({
+ [field]: value,
+ })
+ );
+ };
+
+describe('GithubJoinTokenForm', () => {
+ it('a rule can be added', async () => {
+ const state = baseState();
+ const onUpdate = jest.fn();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Add another GitHub rule/i })
+ );
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate).toHaveBeenLastCalledWith(
+ baseState({
+ rules: [
+ ...state.github.rules,
+ {
+ ref_type: 'any',
+ },
+ ],
+ })
+ );
+ });
+
+ it('delete button is hidden when only one rule exists', async () => {
+ const state = baseState();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.queryByTestId('delete_rule')).not.toBeInTheDocument();
+ });
+
+ it('delete button is visible when more than one rule exists', async () => {
+ const state = baseState({
+ rules: [{}, {}],
+ });
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.queryAllByTestId('delete_rule').length).toBe(2);
+ });
+
+ it('a rule can be deleted', async () => {
+ const state = baseState({
+ rules: [{}, {}],
+ });
+ const onUpdate = jest.fn();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ const rule0 = screen.getByTestId('rule_0');
+ const deleteButton0 = within(rule0).getByTestId('delete_rule');
+
+ await userEvent.click(deleteButton0);
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate).toHaveBeenLastCalledWith(
+ baseState({
+ rules: [state.github.rules[0]],
+ })
+ );
+ });
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'repository field can be populated',
+ populateRuleFieldTest(
+ 'repository',
+ 'gravitational/teleport',
+ 'gravitational/teleport'
+ )
+ );
+
+ it('repository field shows a validation message', async () => {
+ const state = baseState();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ await userEvent.click(screen.getByTestId('submit'));
+
+ expect(
+ screen.getByText('Either repository name or owner is required')
+ ).toBeInTheDocument();
+ });
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'repository owner field can be populated',
+ populateRuleFieldTest('repository_owner', 'gravitational', 'gravitational')
+ );
+
+ it('repository owner field shows a validation message', async () => {
+ const state = baseState();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ await userEvent.click(screen.getByTestId('submit'));
+
+ expect(
+ screen.getByText('Either repository owner or name is required')
+ ).toBeInTheDocument();
+ });
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'workflow field can be populated',
+ populateRuleFieldTest('workflow', 'my-workflow', 'my-workflow')
+ );
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'environment field can be populated',
+ populateRuleFieldTest('environment', 'production', 'production')
+ );
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'ref field can be populated',
+ populateRuleFieldTest('ref', 'ref/heads/main', 'ref/heads/main')
+ );
+
+ it('ref type is disabled when ref is not populated', async () => {
+ const state = baseState();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ expect(screen.getByLabelText('Ref type')).toBeDisabled();
+ });
+
+ it('ref type can be selected', async () => {
+ const state = baseState({
+ rules: [
+ {
+ ref: 'ref/heads/main',
+ ref_type: 'any',
+ },
+ ],
+ });
+ const onUpdate = jest.fn();
+
+ render(
+ ,
+ { wrapper: Wrapper }
+ );
+
+ const selectElement = screen.getByLabelText('Ref type');
+ expect(selectElement).toBeEnabled();
+
+ // Seems to be the only way to interact with react-select component
+ fireEvent.keyDown(selectElement, { key: 'ArrowDown' });
+ const existingItem = await screen.findByText('Branch');
+ fireEvent.click(existingItem);
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate).toHaveBeenLastCalledWith(
+ baseState({
+ rules: [
+ {
+ ...state.github.rules[0],
+ ref_type: 'branch',
+ },
+ ],
+ })
+ );
+ });
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'server host field can be populated',
+ populateFieldTest({
+ field: 'server_host',
+ placeholer: 'github.example.com',
+ value: 'github.example.com',
+ })
+ );
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'slug field can be populated',
+ populateFieldTest({
+ field: 'enterprise_slug',
+ placeholer: 'octo-enterprise',
+ value: 'octo-enterprise',
+ })
+ );
+
+ // eslint-disable-next-line jest/expect-expect
+ it(
+ 'jwks field can be populated',
+ populateFieldTest({
+ field: 'static_jwks',
+ placeholer: '{"keys":[--snip--]}',
+ value: '{"keys":[]}',
+ })
+ );
+});
+
+const Wrapper = ({ children }: PropsWithChildren) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+const SubmitWrapper = ({ children }: PropsWithChildren) => {
+ const validation = useValidation();
+
+ return (
+ <>
+ {children}
+