Skip to content

Commit 69d3e98

Browse files
feat(join-tokens): [44794] Add Join Token form for GitHub (#54308) (#54477)
* Fix/silence lint issues * Fix spacing on side panel * Return github-specific config from `GET /webapi/tokens` * Add github form for create/edit * Handle unsupported fields during edit * Add js docs for check functions * Add js docs for github check function * Fix casing in test mocks * Fix missing `readonly` prop * Remove unused import * Retain metadata (inc expiry) on token edit * Remove mutual-exclusivity on repo/owner fields * Improve supported fields readability * Hide GHES config * Ignore token not found errors when creating * Lock GHES fields when using OSS * Lint fix * Return github-specific config from `GET /webapi/tokens` * Support "token" in `/webapi/yaml/parse/:kind` * Use empty time.Time for token expiry (`POST /webapi/tokens`) * Revert createTokenForDiscoveryHandle changes * Fix acronym casing * Cover enterprise token types in tests * Cover github tokens in existing tests * Tweak handling of `tokenId` * Check expiry is not overwritten * Revert removing tokenId check * Refactor supported fields * Remove use of `X-Teleport-TokenName`
1 parent 005a89c commit 69d3e98

File tree

15 files changed

+1433
-167
lines changed

15 files changed

+1433
-167
lines changed

web/packages/design/src/utils/testing.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
render as testingRender,
2626
waitFor,
2727
waitForElementToBeRemoved,
28+
within,
2829
} from '@testing-library/react';
2930
import userEvent from '@testing-library/user-event';
3031
import { ReactNode } from 'react';
@@ -78,8 +79,8 @@ screen.debug = () => {
7879
};
7980

8081
type RenderOptions = {
81-
wrapper: React.FC;
82-
container: HTMLElement;
82+
wrapper?: React.FC;
83+
container?: HTMLElement;
8384
};
8485

8586
export {
@@ -95,4 +96,5 @@ export {
9596
Router,
9697
userEvent,
9798
waitForElementToBeRemoved,
99+
within,
98100
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import { collectKeys } from './collectKeys';
20+
21+
describe('collectKeys', () => {
22+
it.each`
23+
value
24+
${undefined}
25+
${null}
26+
${1}
27+
${true}
28+
${() => {}}
29+
`('supports non object values ($value)', ({ value }) => {
30+
const actual = collectKeys(value);
31+
expect(actual).toBeNull();
32+
});
33+
34+
it('supports empty object values', () => {
35+
const actual = collectKeys({});
36+
expect(actual).toEqual([]);
37+
});
38+
39+
it('supports empty array values', () => {
40+
const actual = collectKeys([]);
41+
expect(actual).toEqual([]);
42+
});
43+
44+
it('supports simple object values', () => {
45+
const actual = collectKeys({
46+
number: 1,
47+
boolean: true,
48+
string: 'string',
49+
function: () => {},
50+
null: null,
51+
undefined: undefined,
52+
});
53+
expect(actual).toEqual([
54+
'.number',
55+
'.boolean',
56+
'.string',
57+
'.function',
58+
'.null',
59+
'.undefined',
60+
]);
61+
});
62+
63+
it('supports simple array values', () => {
64+
const actual = collectKeys([
65+
{ alpha: true },
66+
{ alpha: true },
67+
{ beta: true },
68+
]);
69+
expect(actual).toEqual(['.alpha', '.alpha', '.beta']);
70+
});
71+
72+
it('supports nested object values', () => {
73+
const actual = collectKeys([
74+
{
75+
inner: {
76+
foo: 'bar',
77+
},
78+
},
79+
]);
80+
expect(actual).toEqual(['.inner.foo']);
81+
});
82+
83+
it('supports nested array values', () => {
84+
const actual = collectKeys([[{ foo: 'bar' }], { bar: 'foo' }]);
85+
expect(actual).toEqual(['.foo', '.bar']);
86+
});
87+
88+
it('allows a custom key prefix', () => {
89+
const actual = collectKeys(
90+
{
91+
foo: 1,
92+
},
93+
'root'
94+
);
95+
expect(actual).toEqual(['root.foo']);
96+
});
97+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
/**
20+
* `collectKeys` gathers object keys recursively and returns them. Arrays are
21+
* traversed, but are transparent. Returns null if the value is not an object
22+
* or array of objects, and an empty array of no keys are present.
23+
*
24+
* @param value Value from which keys will be collected
25+
* @param prefix An optional value to be prepended to all keys returned
26+
* @returns An array of the keys collected (if any) or null
27+
*/
28+
export const collectKeys = (value: unknown, prefix: string = '') => {
29+
if (typeof value !== 'object' || value === null) {
30+
return prefix ? [prefix] : null;
31+
}
32+
33+
if (Array.isArray(value)) {
34+
return value.flatMap(val => {
35+
return collectKeys(val, prefix);
36+
});
37+
}
38+
39+
return Object.entries(value).flatMap(([k, v]) => {
40+
return collectKeys(v, `${prefix}.${k}`);
41+
});
42+
};

web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ import {
3131
export const JoinTokenIAMForm = ({
3232
tokenState,
3333
onUpdateState,
34+
readonly,
3435
}: {
3536
tokenState: NewJoinTokenState;
3637
onUpdateState: (newToken: NewJoinTokenState) => void;
38+
readonly: boolean;
3739
}) => {
3840
const rules = tokenState.iam;
3941

@@ -104,13 +106,15 @@ export const JoinTokenIAMForm = ({
104106
onChange={e =>
105107
setTokenRulesField(index, 'aws_account', e.target.value)
106108
}
109+
readonly={readonly}
107110
/>
108111
<FieldInput
109112
label="ARN"
110113
toolTipContent={`The joining nodes must match this ARN. Supports wildcards "*" and "?"`}
111114
placeholder="arn:aws:iam::account-id:role/*"
112115
value={rule.aws_arn}
113116
onChange={e => setTokenRulesField(index, 'aws_arn', e.target.value)}
117+
readonly={readonly}
114118
/>
115119
</RuleBox>
116120
))}
@@ -125,9 +129,11 @@ export const JoinTokenIAMForm = ({
125129
export const JoinTokenGCPForm = ({
126130
tokenState,
127131
onUpdateState,
132+
readonly,
128133
}: {
129134
tokenState: NewJoinTokenState;
130135
onUpdateState: (newToken: NewJoinTokenState) => void;
136+
readonly: boolean;
131137
}) => {
132138
const rules = tokenState.gcp;
133139
function removeRule(index: number) {
@@ -198,6 +204,7 @@ export const JoinTokenGCPForm = ({
198204
value={rule.project_ids}
199205
label="Add Project ID(s)"
200206
rule={requiredField('At least 1 Project ID required')}
207+
isDisabled={readonly}
201208
/>
202209
<FieldSelectCreatable
203210
placeholder="us-west1, us-east1-a"
@@ -210,6 +217,7 @@ export const JoinTokenGCPForm = ({
210217
value={rule.locations}
211218
label="Add Locations"
212219
helperText="Allows regions and/or zones."
220+
isDisabled={readonly}
213221
/>
214222
<FieldSelectCreatable
215223
placeholder="[email protected]"
@@ -221,6 +229,7 @@ export const JoinTokenGCPForm = ({
221229
}
222230
value={rule.service_accounts}
223231
label="Add Service Account Emails"
232+
isDisabled={readonly}
224233
/>
225234
</RuleBox>
226235
))}
@@ -235,9 +244,11 @@ export const JoinTokenGCPForm = ({
235244
export const JoinTokenOracleForm = ({
236245
tokenState,
237246
onUpdateState,
247+
readonly,
238248
}: {
239249
tokenState: NewJoinTokenState;
240250
onUpdateState: (newToken: NewJoinTokenState) => void;
251+
readonly: boolean;
241252
}) => {
242253
const rules = tokenState.oracle;
243254
function removeRule(index: number) {
@@ -301,6 +312,7 @@ export const JoinTokenOracleForm = ({
301312
placeholder="ocid1.tenancy.oc1..<unique ID>"
302313
value={rule.tenancy}
303314
onChange={e => updateRuleField(index, 'tenancy', e.target.value)}
315+
readonly={readonly}
304316
/>
305317
<FieldSelectCreatable
306318
placeholder="ocid1.compartment.oc1..<unique ID>"
@@ -317,6 +329,7 @@ export const JoinTokenOracleForm = ({
317329
value={rule.parent_compartments}
318330
label="Add Compartments"
319331
helperText="Direct parent compartments only, no nested compartments."
332+
isDisabled={readonly}
320333
/>
321334
<FieldSelectCreatable
322335
placeholder="us-ashburn-1, phx"
@@ -328,6 +341,7 @@ export const JoinTokenOracleForm = ({
328341
}
329342
value={rule.regions}
330343
label="Add Regions"
344+
isDisabled={readonly}
331345
/>
332346
</RuleBox>
333347
))}

0 commit comments

Comments
 (0)