Skip to content

Commit

Permalink
UI: Adding UI to read OIDC group value (PROJQUAY-6298)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sunandadadi committed Feb 2, 2024
1 parent 2ab7dc2 commit 03d3c36
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 35 deletions.
21 changes: 21 additions & 0 deletions data/users/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from data.users.externalldap import LDAPUsers
from data.users.federated import FederatedUsers
from data.users.keystone import get_keystone_users
from data.users.externaloidc import OIDCUsers
from util.config.superusermanager import ConfigUserManager
from util.security.aes import AESCipher
from util.security.secret import convert_secret_key
Expand All @@ -35,6 +36,9 @@ def get_federated_service_name(authentication_type):
if authentication_type == "Database":
return None

if authentication_type == "OIDC":
return "oidc"

raise Exception("Unknown auth type: %s" % authentication_type)


Expand Down Expand Up @@ -135,6 +139,23 @@ def get_users_handler(config, _, override_config_dir):

return AppTokenInternalAuth()

if authentication_type == "OIDC":
client_id = config.get("CLIENT_ID")
client_secret = config.get("CLIENT_SECRET")
oidc_server = config.get("OIDC_SERVER")
service_name = config.get("SERVICE_NAME")
login_scopes = config.get("LOGIN_SCOPES")
preferred_group_claim_name = config.get("PREFERRED_GROUP_CLAIM_NAME")

return OIDCUsers(
client_id,
client_secret,
oidc_server,
service_name,
login_scopes,
preferred_group_claim_name,
)

raise RuntimeError("Unknown authentication type: %s" % authentication_type)


Expand Down
9 changes: 8 additions & 1 deletion web/src/components/empty/Empty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PageSection,
EmptyStateHeader,
EmptyStateFooter,
EmptyStateActions,
} from '@patternfly/react-core';
import {SVGIconProps} from '@patternfly/react-icons/dist/js/createIcon';

Expand All @@ -21,7 +22,12 @@ export default function Empty(props: EmptyProps) {
<EmptyStateBody style={{paddingBottom: 20}}>
{props.body}
</EmptyStateBody>
<EmptyStateFooter>{props.button}</EmptyStateFooter>
<EmptyStateFooter>
<EmptyStateActions variant="primary">
{props.button}
</EmptyStateActions>
{props.secondaryActions?.map((ele) => ele)}
</EmptyStateFooter>
</EmptyState>
</PageSection>
);
Expand All @@ -32,4 +38,5 @@ interface EmptyProps {
title: string;
body: string;
button?: JSX.Element;
secondaryActions?: JSX.Element[];
}
67 changes: 67 additions & 0 deletions web/src/components/modals/OIDCTeamSyncModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {useState} from 'react';
import {
Alert,
Button,
Modal,
Text,
TextInput,
TextVariants,
} from '@patternfly/react-core';
import Conditional from 'src/components/empty/Conditional';

export default function OIDCTeamSyncModal(props: OIDCTeamSyncModalProps) {
const [groupName, setGroupName] = useState<string>('');

const handleInputChange = (value: string) => {
setGroupName(value);
};

return (
<Modal
width="50%"
title={props.titleText}
isOpen={props.isModalOpen}
onClose={props.toggleModal}
actions={[
<Button
key="confirm"
variant="primary"
onClick={() => props.onConfirmSync(groupName)}
>
Enable Sync
</Button>,
<Button key="cancel" variant="link" onClick={props.toggleModal}>
Cancel
</Button>,
]}
>
<Text component={TextVariants.p}>{props.primaryText}</Text>
<br />
<div>
<Conditional if={props.secondaryText != null}>
<Text component={TextVariants.p}>{props.secondaryText}</Text>
</Conditional>
<TextInput
value={groupName}
type="text"
onChange={(_event, value) => handleInputChange(value)}
id="team-sync-group-name"
/>
</div>
<br />
<Conditional if={props.alertText != null}>
<Alert isInline variant="warning" title={props.alertText} />
</Conditional>
</Modal>
);
}

type OIDCTeamSyncModalProps = {
isModalOpen: boolean;
toggleModal: () => void;
titleText: string;
primaryText: string;
onConfirmSync: (string) => void;
secondaryText?: string;
alertText?: string;
};
18 changes: 15 additions & 3 deletions web/src/hooks/UseMembers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export interface ITeamMember {
invited?: boolean;
}

export interface ITeamMembersResponse {
name: string;
members: ITeamMember[];
can_sync: object;
synced: object;
can_edit: boolean;
}

export function useFetchTeamMembersForOrg(orgName: string, teamName: string) {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
Expand All @@ -113,15 +121,17 @@ export function useFetchTeamMembersForOrg(orgName: string, teamName: string) {
isLoading,
isPlaceholderData,
isError: errorLoadingTeamMembers,
} = useQuery<ITeamMember[]>(
} = useQuery<ITeamMembersResponse>(
['teamMembers'],
({signal}) => fetchTeamMembersForOrg(orgName, teamName, signal),
{
placeholderData: [],
placeholderData: {},
},
);
const allMembers: ITeamMember[] = data?.members;

const allMembers: ITeamMember[] = data;
const teamCanSync = data?.can_sync;
const teamSyncInfo = data?.synced;

const filteredAllMembers =
search.query !== ''
Expand Down Expand Up @@ -176,6 +186,8 @@ export function useFetchTeamMembersForOrg(orgName: string, teamName: string) {
paginatedTeamMembers,
paginatedRobotAccounts,
paginatedInvited,
teamCanSync,
teamSyncInfo,
loading: isLoading || isPlaceholderData,
error: errorLoadingTeamMembers,
page,
Expand Down
25 changes: 25 additions & 0 deletions web/src/hooks/UseTeamSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {enableTeamSyncForOrg} from 'src/resources/TeamSyncResource';

export function useTeamSync({orgName, teamName, onSuccess, onError}) {
const queryClient = useQueryClient();

const enableTeamSyncMutator = useMutation(
async ({groupName, service}: {groupName: string; service: string}) => {
return enableTeamSyncForOrg(orgName, teamName, groupName, service);
},
{
onSuccess: () => {
onSuccess();
queryClient.invalidateQueries([orgName, teamName, 'teamSync']);
},
onError: (err) => {
onError(err);
},
},
);
return {
enableTeamSync: async (groupName: string, service: string) =>
enableTeamSyncMutator.mutate({groupName, service}),
};
}
2 changes: 1 addition & 1 deletion web/src/resources/MembersResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function fetchTeamMembersForOrg(
const teamMemberUrl = `/api/v1/organization/${org}/team/${teamName}/members?includePending=true`;
const teamMembersResponse = await axios.get(teamMemberUrl, {signal});
assertHttpCode(teamMembersResponse.status, 200);
return teamMembersResponse.data?.members;
return teamMembersResponse?.data;
}

export async function deleteTeamMemberForOrg(
Expand Down
21 changes: 21 additions & 0 deletions web/src/resources/TeamSyncResource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import axios from 'src/libs/axios';
import {assertHttpCode} from './ErrorHandling';
import {AxiosResponse} from 'axios';

export async function enableTeamSyncForOrg(
orgName: string,
teamName: string,
groupName: string,
service: string,
) {
const enableSyncUrl = `/api/v1/organization/${orgName}/team/${teamName}/syncing`;
let data = {};
if (service == 'oidc') {
data = {
group_config: `${orgName}:${groupName}`,
};
}
const response: AxiosResponse = await axios.post(enableSyncUrl, data);
assertHttpCode(response.status, 200);
return response.data;
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import DeleteModalForRowTemplate from 'src/components/modals/DeleteModalForRowTe
import Conditional from 'src/components/empty/Conditional';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import {useOrganization} from 'src/hooks/UseOrganization';
import {useTeamSync} from 'src/hooks/UseTeamSync';
import OIDCTeamSyncModal from 'src/components/modals/OIDCTeamSyncModal';

export enum TableModeType {
AllMembers = 'All Members',
Expand Down Expand Up @@ -79,6 +81,8 @@ export default function ManageMembersList(props: ManageMembersListProps) {
paginatedTeamMembers,
paginatedRobotAccounts,
paginatedInvited,
teamCanSync,
teamSyncInfo,
loading,
page,
setPage,
Expand All @@ -104,6 +108,7 @@ export default function ManageMembersList(props: ManageMembersListProps) {
const [teamDescr, setTeamDescr] = useState<string>();
const [isDeleteModalForRowOpen, setIsDeleteModalForRowOpen] = useState(false);
const [memberToBeDeleted, setMemberToBeDeleted] = useState<IMemberInfo>();
const [isOIDCTeamSyncModalOpen, setIsOIDCTeamSyncModalOpen] = useState(false);

useEffect(() => {
switch (tableMode) {
Expand Down Expand Up @@ -230,6 +235,18 @@ export default function ManageMembersList(props: ManageMembersListProps) {
const {updateTeamDetails, errorUpdateTeamDetails, successUpdateTeamDetails} =
useUpdateTeamDetails(organizationName);

const {enableTeamSync} = useTeamSync({
orgName: organizationName,
teamName: teamName,
onSuccess: () => setIsOIDCTeamSyncModalOpen(!isOIDCTeamSyncModalOpen),
onError: (err) => {
addAlert({
variant: AlertVariant.Failure,
title: `Error updating team sync config: ${err}`,
});
},
});

useEffect(() => {
if (successUpdateTeamDetails) {
addAlert({
Expand Down Expand Up @@ -333,26 +350,66 @@ export default function ManageMembersList(props: ManageMembersListProps) {
return <Spinner />;
}

const displaySyncDirectory =
teamCanSync != null &&
!teamSyncInfo &&
config?.registry_state !== 'readonly';

const fetchSyncBtn = () => {
if (displaySyncDirectory && teamCanSync?.service == 'oidc') {
return [
<Button
variant="link"
onClick={() => setIsOIDCTeamSyncModalOpen(!isOIDCTeamSyncModalOpen)}
key="sync-btn"
>
Enable Directory synchronization
</Button>,
];
}
return [];
};

const OIDCTeamSyncModalHolder = (
<OIDCTeamSyncModal
isModalOpen={isOIDCTeamSyncModalOpen}
toggleModal={() => setIsOIDCTeamSyncModalOpen(!isOIDCTeamSyncModalOpen)}
titleText="Enable OIDC Directory Syncing"
primaryText="Directory synchronization allows this team's user membership to be backed by a group in OIDC."
onConfirmSync={(groupName) =>
enableTeamSync(groupName, teamCanSync?.service)
}
secondaryText="Enter the name of the group you'd like sync membership with:"
alertText="Please note that once team syncing is enabled, the team's user membership from within Red Hat Quay will be read-only."
/>
);

if (allMembers?.length === 0) {
return (
<Empty
title="There are no viewable members for this team"
icon={CubesIcon}
body="Either no team members exist yet or you may not have permission to view any."
button={
<Button
variant="primary"
data-testid="add-new-member-button"
onClick={() =>
props.setDrawerContent(
OrganizationDrawerContentType.AddNewTeamMemberDrawer,
)
}
>
Add new member
</Button>
}
/>
<>
<Empty
title="There are no viewable members for this team"
icon={CubesIcon}
body="Either no team members exist yet or you may not have permission to view any."
button={
<Button
variant="primary"
data-testid="add-new-member-button"
onClick={() =>
props.setDrawerContent(
OrganizationDrawerContentType.AddNewTeamMemberDrawer,
)
}
>
Add new member
</Button>
}
secondaryActions={fetchSyncBtn()}
/>
<Conditional if={isOIDCTeamSyncModalOpen}>
{OIDCTeamSyncModalHolder}
</Conditional>
</>
);
}

Expand All @@ -374,10 +431,21 @@ export default function ManageMembersList(props: ManageMembersListProps) {
setDrawerContent={props.setDrawerContent}
isReadOnly={config?.registry_state === 'readonly'}
isAdmin={organization.is_admin}
displaySyncDirectory={displaySyncDirectory}
isOIDCTeamSyncModalOpen={isOIDCTeamSyncModalOpen}
toggleOIDCTeamSyncModal={() =>
setIsOIDCTeamSyncModalOpen(!isOIDCTeamSyncModalOpen)
}
teamCanSync={teamCanSync}
>
{viewToggle}
{teamDescriptionComponent}
<Conditional if={isDeleteModalForRowOpen}>{deleteRowModal}</Conditional>
<Conditional
if={isOIDCTeamSyncModalOpen && teamCanSync?.service == 'oidc'}
>
{OIDCTeamSyncModalHolder}
</Conditional>
<Table aria-label="Selectable table" variant="compact">
<Thead>
<Tr>
Expand Down
Loading

0 comments on commit 03d3c36

Please sign in to comment.