Customizable default member role for OIDC users (#6348)

This commit is contained in:
Kamil Kisiela 2025-01-16 16:48:21 +01:00 committed by GitHub
parent cc86bc5c7e
commit e754700212
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 494 additions and 38 deletions

View file

@ -0,0 +1,5 @@
---
'hive': minor
---
Adds ability to select a default role for new OIDC users

View file

@ -119,6 +119,36 @@ describe('oidc', () => {
});
});
it('default member role for first time oidc login', () => {
const organizationAdminUser = getUserData();
cy.visit('/');
cy.signup(organizationAdminUser);
const slug = generateRandomSlug();
cy.createOIDCIntegration(slug);
// Pick Admin role as the default role
cy.get('[data-cy="role-selector-trigger"]').click();
cy.contains('[data-cy="role-selector-item"]', 'Admin').click();
cy.visit('/logout');
// First time login
cy.clearAllCookies();
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.get('a[href^="/auth/sso"]').click();
cy.get('input[name="slug"]').type(slug);
cy.get('button[type="submit"]').click();
// OIDC login
cy.get('input[id="Input_Username"]').type('test-user-2');
cy.get('input[id="Input_Password"]').type('password');
cy.get('button[value="login"]').click();
cy.get('[data-cy="organization-picker-current"]').contains(slug);
// Check if the user has the Admin role by checking if the Members tab is visible
cy.get(`a[href^="/${slug}/view/members"]`).should('exist');
});
it('oidc login for invalid url shows correct error message', () => {
cy.clearAllCookies();
cy.clearAllLocalStorage();

View file

@ -36,10 +36,6 @@ Cypress.Commands.add('createOIDCIntegration', (organizationSlug: string) => {
cy.get('div[role="dialog"]').find('button[type="submit"]').last().click();
cy.url().then(url => {
return new URL(url).pathname.split('/')[0];
});
return cy
.get('div[role="dialog"]')
.find('input[id="sign-in-uri"]')

View file

@ -0,0 +1,25 @@
import { type MigrationExecutor } from '../pg-migrator';
export default {
name: '2025.01.13T10-08-00.default-role.ts',
noTransaction: true,
// Adds a default role to OIDC integration and set index on "oidc_integrations"."default_role_id"
run: ({ sql }) => [
{
name: 'Add a column',
query: sql`
ALTER TABLE "oidc_integrations"
ADD COLUMN IF NOT EXISTS "default_role_id" UUID REFERENCES organization_member_roles(id)
ON DELETE SET NULL;
`,
},
{
name: 'Create an index',
query: sql`
CREATE INDEX CONCURRENTLY IF NOT EXISTS "oidc_integrations_default_role_id_idx"
ON "oidc_integrations"("default_role_id")
WHERE "default_role_id" is not null;
`,
},
],
} satisfies MigrationExecutor;

View file

@ -154,5 +154,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
await import('./actions/2025.01.02T00-00-00.legacy-user-org-cleanup'),
await import('./actions/2025.01.09T00-00-00.legacy-member-scopes'),
await import('./actions/2025.01.10T00.00.00.breaking-changes-request-count'),
await import('./actions/2025.01.13T10-08-00.default-role'),
],
});

View file

@ -19,6 +19,7 @@ export default gql`
authorizationEndpoint: String!
organization: Organization!
oidcUserAccessOnly: Boolean!
defaultMemberRole: MemberRole!
}
extend type Mutation {
@ -26,6 +27,9 @@ export default gql`
updateOIDCIntegration(input: UpdateOIDCIntegrationInput!): UpdateOIDCIntegrationResult!
deleteOIDCIntegration(input: DeleteOIDCIntegrationInput!): DeleteOIDCIntegrationResult!
updateOIDCRestrictions(input: UpdateOIDCRestrictionsInput!): UpdateOIDCRestrictionsResult!
updateOIDCDefaultMemberRole(
input: UpdateOIDCDefaultMemberRoleInput!
): UpdateOIDCDefaultMemberRoleResult!
}
type Subscription {
@ -149,4 +153,25 @@ export default gql`
type UpdateOIDCRestrictionsError implements Error {
message: String!
}
input UpdateOIDCDefaultMemberRoleInput {
oidcIntegrationId: ID!
defaultMemberRoleId: ID!
}
"""
@oneOf
"""
type UpdateOIDCDefaultMemberRoleResult {
ok: UpdateOIDCDefaultMemberRoleOk
error: UpdateOIDCDefaultMemberRoleError
}
type UpdateOIDCDefaultMemberRoleOk {
updatedOIDCIntegration: OIDCIntegration!
}
type UpdateOIDCDefaultMemberRoleError implements Error {
message: String!
}
`;

View file

@ -363,6 +363,57 @@ export class OIDCIntegrationsProvider {
} as const;
}
async updateOIDCDefaultMemberRole(args: { oidcIntegrationId: string; roleId: string }) {
if (this.isEnabled() === false) {
return {
type: 'error',
message: 'OIDC integrations are disabled.',
} as const;
}
const oidcIntegration = await this.storage.getOIDCIntegrationById({
oidcIntegrationId: args.oidcIntegrationId,
});
if (oidcIntegration === null) {
return {
type: 'error',
message: 'Integration not found.',
} as const;
}
const viewer = await this.session.getViewer();
const [member, adminRole] = await Promise.all([
this.storage.getOrganizationMember({
organizationId: oidcIntegration.linkedOrganizationId,
userId: viewer.id,
}),
this.storage.getAdminOrganizationMemberRole({
organizationId: oidcIntegration.linkedOrganizationId,
}),
]);
if (member?.role.id !== adminRole.id) {
return {
type: 'error',
message: 'You do not have permission to update the default member role.',
} as const;
}
await this.session.assertPerformAction({
organizationId: oidcIntegration.linkedOrganizationId,
action: 'oidc:modify',
params: {
organizationId: oidcIntegration.linkedOrganizationId,
},
});
return {
type: 'ok',
oidcIntegration: await this.storage.updateOIDCDefaultMemberRole(args),
} as const;
}
async getOIDCIntegrationById(args: { oidcIntegrationId: string }) {
if (this.isEnabled() === false) {
return {

View file

@ -0,0 +1,25 @@
import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider';
import type { MutationResolvers } from './../../../../__generated__/types';
export const updateOIDCDefaultMemberRole: NonNullable<
MutationResolvers['updateOIDCDefaultMemberRole']
> = async (_, { input }, { injector }) => {
const result = await injector.get(OIDCIntegrationsProvider).updateOIDCDefaultMemberRole({
roleId: input.defaultMemberRoleId,
oidcIntegrationId: input.oidcIntegrationId,
});
if (result.type === 'error') {
return {
error: {
message: result.message,
},
};
}
return {
ok: {
updatedOIDCIntegration: result.oidcIntegration,
},
};
};

View file

@ -1,3 +1,4 @@
import { OrganizationManager } from '../../organization/providers/organization-manager';
import { OIDCIntegrationsProvider } from '../providers/oidc-integrations.provider';
import type { OidcIntegrationResolvers } from './../../../__generated__/types';
@ -9,4 +10,27 @@ export const OIDCIntegration: OidcIntegrationResolvers = {
clientId: oidcIntegration => oidcIntegration.clientId,
clientSecretPreview: (oidcIntegration, _, { injector }) =>
injector.get(OIDCIntegrationsProvider).getClientSecretPreview(oidcIntegration),
/**
* Fallbacks to Viewer if default member role is not set
*/
defaultMemberRole: async (oidcIntegration, _, { injector }) => {
if (!oidcIntegration.defaultMemberRoleId) {
return injector.get(OrganizationManager).getViewerMemberRole({
organizationId: oidcIntegration.linkedOrganizationId,
});
}
const role = await injector.get(OrganizationManager).getMemberRole({
organizationId: oidcIntegration.linkedOrganizationId,
roleId: oidcIntegration.defaultMemberRoleId,
});
if (!role) {
throw new Error(
`Default role not found (role_id=${oidcIntegration.defaultMemberRoleId}, organization=${oidcIntegration.linkedOrganizationId})`,
);
}
return role;
},
};

View file

@ -1419,6 +1419,20 @@ export class OrganizationManager {
});
}
async getViewerMemberRole(selector: { organizationId: string }) {
await this.session.assertPerformAction({
action: 'member:describe',
organizationId: selector.organizationId,
params: {
organizationId: selector.organizationId,
},
});
return this.storage.getViewerOrganizationMemberRole({
organizationId: selector.organizationId,
});
}
async canDeleteRole(
role: OrganizationMemberRole,
currentUserScopes: readonly (

View file

@ -638,6 +638,11 @@ export interface Storage {
oidcUserAccessOnly: boolean;
}): Promise<OIDCIntegration>;
updateOIDCDefaultMemberRole(_: {
oidcIntegrationId: string;
roleId: string;
}): Promise<OIDCIntegration>;
createCDNAccessToken(_: {
id: string;
targetId: string;

View file

@ -219,6 +219,7 @@ export interface OIDCIntegration {
userinfoEndpoint: string;
authorizationEndpoint: string;
oidcUserAccessOnly: boolean;
defaultMemberRoleId: string | null;
}
export interface CDNAccessToken {

View file

@ -157,6 +157,7 @@ export interface oidc_integrations {
client_id: string;
client_secret: string;
created_at: Date;
default_role_id: string | null;
id: string;
linked_organization_id: string;
oauth_api_url: string | null;

View file

@ -497,17 +497,24 @@ export async function createStorage(
return;
}
const viewerRole = await shared.getOrganizationMemberRoleByName(
{ organizationId: linkedOrganizationId, roleName: 'Viewer' },
connection,
);
// TODO: turn it into a default role and let the admin choose the default role
// Add user and assign default role (either Viewer or custom default role)
await connection.query(
sql`/* addOrganizationMemberViaOIDCIntegrationId */
INSERT INTO organization_member
(organization_id, user_id, role_id)
VALUES
(${linkedOrganizationId}, ${args.userId}, ${viewerRole.id})
(
${linkedOrganizationId},
${args.userId},
(
COALESCE(
(SELECT default_role_id FROM oidc_integrations
WHERE id = ${args.oidcIntegrationId}),
(SELECT id FROM organization_member_roles
WHERE organization_id = ${linkedOrganizationId} AND name = 'Viewer')
)
)
)
ON CONFLICT DO NOTHING
RETURNING *
`,
@ -605,7 +612,6 @@ export async function createStorage(
{
oidcIntegrationId: oidcIntegration.id,
userId: internalUser.id,
// TODO: pass a default role here
},
t,
);
@ -3051,6 +3057,7 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "oidc_user_access_only"
, "default_role_id"
FROM
"oidc_integrations"
WHERE
@ -3077,6 +3084,7 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "oidc_user_access_only"
, "default_role_id"
FROM
"oidc_integrations"
WHERE
@ -3139,6 +3147,7 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "oidc_user_access_only"
, "default_role_id"
`);
return {
@ -3193,6 +3202,7 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "oidc_user_access_only"
, "default_role_id"
`);
return decodeOktaIntegrationRecord(result);
@ -3215,11 +3225,51 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "oidc_user_access_only"
, "default_role_id"
`);
return decodeOktaIntegrationRecord(result);
},
async updateOIDCDefaultMemberRole(args) {
return tracedTransaction('updateOIDCDefaultMemberRole', pool, async trx => {
// Make sure the role exists and is associated with the organization
const roleId = await trx.oneFirst<string>(sql`/* checkRoleExists */
SELECT id FROM "organization_member_roles"
WHERE
"id" = ${args.roleId} AND
"organization_id" = (
SELECT "linked_organization_id" FROM "oidc_integrations" WHERE "id" = ${args.oidcIntegrationId}
)
`);
if (!roleId) {
throw new Error('Role does not exist');
}
const result = await pool.one(sql`/* updateOIDCDefaultMemberRole */
UPDATE "oidc_integrations"
SET
"default_role_id" = ${roleId}
WHERE
"id" = ${args.oidcIntegrationId}
RETURNING
"id"
, "linked_organization_id"
, "client_id"
, "client_secret"
, "oauth_api_url"
, "token_endpoint"
, "userinfo_endpoint"
, "authorization_endpoint"
, "oidc_user_access_only"
, "default_role_id"
`);
return decodeOktaIntegrationRecord(result);
});
},
async deleteOIDCIntegration(args) {
await pool.query<unknown>(sql`/* deleteOIDCIntegration */
DELETE FROM "oidc_integrations"
@ -4575,6 +4625,7 @@ const OktaIntegrationBaseModel = zod.object({
client_id: zod.string(),
client_secret: zod.string(),
oidc_user_access_only: zod.boolean(),
default_role_id: zod.string().nullable(),
});
const OktaIntegrationLegacyModel = zod.intersection(
@ -4609,6 +4660,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => {
userinfoEndpoint: `${rawRecord.oauth_api_url}/userinfo`,
authorizationEndpoint: `${rawRecord.oauth_api_url}/authorize`,
oidcUserAccessOnly: rawRecord.oidc_user_access_only,
defaultMemberRoleId: rawRecord.default_role_id,
};
}
@ -4621,6 +4673,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => {
userinfoEndpoint: rawRecord.userinfo_endpoint,
authorizationEndpoint: rawRecord.authorization_endpoint,
oidcUserAccessOnly: rawRecord.oidc_user_access_only,
defaultMemberRoleId: rawRecord.default_role_id,
};
};

View file

@ -29,6 +29,7 @@ export function RoleSelector<T>(props: {
reason?: string;
};
disabled?: boolean;
searchPlaceholder?: string;
onSelect(role: Role<T>): void | Promise<void>;
/**
* It's only needed for the migration flow, where we need to be able to select no role.
@ -46,7 +47,8 @@ export function RoleSelector<T>(props: {
<PopoverTrigger asChild>
<Button
variant="outline"
className="ml-auto"
className="ml-auto flex items-center gap-x-4"
data-cy="role-selector-trigger"
disabled={props.disabled === true || isBusy}
onClick={() => {
props.onBlur?.();
@ -58,7 +60,7 @@ export function RoleSelector<T>(props: {
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="end">
<Command>
<CommandInput placeholder="Select new role..." />
<CommandInput placeholder={props.searchPlaceholder ?? 'Search roles...'} />
<CommandList>
<CommandEmpty>No roles found.</CommandEmpty>
<CommandGroup>
@ -95,6 +97,7 @@ export function RoleSelector<T>(props: {
/[^a-z0-9\-\:\ ]+/gi,
'',
)}
data-cy="role-selector-item"
onSelect={() => {
setPhase('busy');
setOpen(false);

View file

@ -128,6 +128,7 @@ function OrganizationMemberRoleSwitcher(props: {
return (
<>
<RoleSelector
searchPlaceholder="Select new role..."
roles={roles}
onSelect={async role => {
try {

View file

@ -14,6 +14,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -48,6 +49,7 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from '@/gql/graphql';
import { scopes } from '@/lib/access/common';
import { zodResolver } from '@hookform/resolvers/zod';
import { Link } from '@tanstack/react-router';
export const roleFormSchema = z.object({
name: z
@ -638,6 +640,9 @@ const OrganizationMemberRoleRow_MemberRoleFragment = graphql(`
`);
function OrganizationMemberRoleRow(props: {
organizationSlug: string;
canChangeOIDCDefaultRole: boolean;
isOIDCDefaultRole: boolean;
role: FragmentType<typeof OrganizationMemberRoleRow_MemberRoleFragment>;
onEdit(role: FragmentType<typeof OrganizationMemberRoleRow_MemberRoleFragment>): void;
onDelete(role: FragmentType<typeof OrganizationMemberRoleRow_MemberRoleFragment>): void;
@ -668,6 +673,43 @@ function OrganizationMemberRoleRow(props: {
</TooltipProvider>
</div>
) : null}
{props.isOIDCDefaultRole ? (
<div className="ml-2">
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger>
<Badge variant="outline">default</Badge>
</TooltipTrigger>
<TooltipContent side="right">
<div className="flex flex-col items-start gap-y-2 p-2">
<div className="font-medium">Default role for new members</div>
<div className="text-sm text-gray-400">
<p>New members will be assigned to this role by default.</p>
{props.canChangeOIDCDefaultRole ? (
<p>
You can change it in the{' '}
<Link
to="/$organizationSlug/view/settings"
hash="manage-oidc-integration"
params={{
organizationSlug: props.organizationSlug,
}}
className="underline"
>
OIDC settings
</Link>
.
</p>
) : (
<p>Only admins can change it in the OIDC settings.</p>
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : null}
</div>
</td>
<td className="break-words py-3 text-sm text-gray-400" title={role.description}>
@ -770,9 +812,19 @@ const OrganizationMemberRoles_OrganizationFragment = graphql(`
}
me {
id
role {
id
name
}
...OrganizationMemberRoleCreator_MeFragment
...OrganizationMemberRoleEditor_MeFragment
}
oidcIntegration {
id
defaultMemberRole {
id
}
}
}
`);
@ -912,6 +964,9 @@ export function OrganizationMemberRoles(props: {
<tbody className="divide-y-[1px] divide-gray-500/20">
{organization.memberRoles?.map(role => (
<OrganizationMemberRoleRow
organizationSlug={organization.slug}
isOIDCDefaultRole={organization.oidcIntegration?.defaultMemberRole?.id === role.id}
canChangeOIDCDefaultRole={organization.me.role?.name === 'Admin'}
key={role.id}
role={role}
onEdit={() => setRoleToEdit(role)}

View file

@ -37,6 +37,7 @@ import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation as useRQMutation } from '@tanstack/react-query';
import { Link, useRouter } from '@tanstack/react-router';
import { RoleSelector } from '../members/common';
function CopyInput(props: { value: string; id?: string }) {
const copy = useClipboard();
@ -75,6 +76,16 @@ const OIDCIntegrationSection_OrganizationFragment = graphql(`
...UpdateOIDCIntegration_OIDCIntegrationFragment
authorizationEndpoint
}
memberRoles {
...OIDCDefaultRoleSelector_MemberRoleFragment
}
me {
id
role {
id
name
}
}
}
`);
@ -88,6 +99,7 @@ export function OIDCIntegrationSection(props: {
}): ReactElement {
const router = useRouter();
const organization = useFragment(OIDCIntegrationSection_OrganizationFragment, props.organization);
const isAdmin = organization.me.role.name === 'Admin';
const hash = router.latestLocation.hash;
const openCreateModalHash = 'create-oidc-integration';
@ -152,7 +164,10 @@ export function OIDCIntegrationSection(props: {
/>
<ManageOIDCIntegrationModal
key={organization.oidcIntegration?.id ?? 'noop'}
isAdmin={isAdmin}
organizationId={organization.id}
oidcIntegration={organization.oidcIntegration ?? null}
memberRoles={organization.memberRoles ?? null}
isOpen={isUpdateOIDCIntegrationModalOpen}
close={closeModal}
openCreateModalHash={openCreateModalHash}
@ -547,40 +562,138 @@ function CreateOIDCIntegrationForm(props: {
function ManageOIDCIntegrationModal(props: {
isOpen: boolean;
close: () => void;
oidcIntegration: FragmentType<typeof UpdateOIDCIntegration_OIDCIntegrationFragment> | null;
organizationId: string;
isAdmin: boolean;
openCreateModalHash: string;
}): ReactElement {
oidcIntegration: FragmentType<typeof UpdateOIDCIntegration_OIDCIntegrationFragment> | null;
memberRoles: Array<FragmentType<typeof OIDCDefaultRoleSelector_MemberRoleFragment>> | null;
}) {
const oidcIntegration = useFragment(
UpdateOIDCIntegration_OIDCIntegrationFragment,
props.oidcIntegration,
);
return oidcIntegration == null ? (
<Dialog open={props.isOpen} onOpenChange={props.close}>
<DialogContent className={classes.modal}>
<DialogHeader>
<DialogTitle>Manage OpenID Connect Integration</DialogTitle>
<DialogDescription>
You are trying to update an OpenID Connect integration for an organization that has no
integration.
</DialogDescription>
</DialogHeader>
<DialogFooter className="space-x-2 text-right">
<Button variant="outline" onClick={props.close}>
Close
</Button>
<Button asChild>
<Link hash={props.openCreateModalHash}>Connect OIDC Provider</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
if (oidcIntegration == null) {
return (
<Dialog open={props.isOpen} onOpenChange={props.close}>
<DialogContent className={classes.modal}>
<DialogHeader>
<DialogTitle>Manage OpenID Connect Integration</DialogTitle>
<DialogDescription>
You are trying to update an OpenID Connect integration for an organization that has no
integration.
</DialogDescription>
</DialogHeader>
<DialogFooter className="space-x-2 text-right">
<Button variant="outline" onClick={props.close}>
Close
</Button>
<Button asChild>
<Link hash={props.openCreateModalHash}>Connect OIDC Provider</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
if (!props.memberRoles) {
console.error('ManageOIDCIntegrationModal is missing member roles');
return null;
}
return (
<UpdateOIDCIntegrationForm
close={props.close}
isOpen={props.isOpen}
isAdmin={props.isAdmin}
key={oidcIntegration.id}
oidcIntegration={oidcIntegration}
memberRoles={props.memberRoles}
/>
);
}
const OIDCDefaultRoleSelector_MemberRoleFragment = graphql(`
fragment OIDCDefaultRoleSelector_MemberRoleFragment on MemberRole {
id
name
description
}
`);
const OIDCDefaultRoleSelector_UpdateMutation = graphql(`
mutation OIDCDefaultRoleSelector_UpdateMutation($input: UpdateOIDCDefaultMemberRoleInput!) {
updateOIDCDefaultMemberRole(input: $input) {
ok {
updatedOIDCIntegration {
id
defaultMemberRole {
...OIDCDefaultRoleSelector_MemberRoleFragment
}
}
}
error {
message
}
}
}
`);
function OIDCDefaultRoleSelector(props: {
oidcIntegrationId: string;
disabled: boolean;
defaultRole: FragmentType<typeof OIDCDefaultRoleSelector_MemberRoleFragment>;
memberRoles: Array<FragmentType<typeof OIDCDefaultRoleSelector_MemberRoleFragment>>;
}) {
const defaultRole = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.defaultRole);
const memberRoles = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.memberRoles);
const [_, mutate] = useMutation(OIDCDefaultRoleSelector_UpdateMutation);
const { toast } = useToast();
return (
<RoleSelector
roles={memberRoles}
defaultRole={defaultRole}
disabled={props.disabled}
onSelect={async role => {
if (role.id === defaultRole.id) {
return;
}
try {
const result = await mutate({
input: {
oidcIntegrationId: props.oidcIntegrationId,
defaultMemberRoleId: role.id,
},
});
if (result.data?.updateOIDCDefaultMemberRole.ok) {
toast({
title: 'Default member role updated',
description: `${role.name} is now the default role for new OIDC members`,
});
return;
}
toast({
title: 'Failed to update default member role',
description:
result.data?.updateOIDCDefaultMemberRole.error?.message ??
result.error?.message ??
'Please try again later',
});
} catch (error) {
toast({
title: 'Failed to update default member role',
description: 'Please try again later',
variant: 'destructive',
});
console.error(error);
}
}}
isRoleActive={_ => true}
/>
);
}
@ -594,6 +707,10 @@ const UpdateOIDCIntegration_OIDCIntegrationFragment = graphql(`
clientId
clientSecretPreview
oidcUserAccessOnly
defaultMemberRole {
id
...OIDCDefaultRoleSelector_MemberRoleFragment
}
}
`);
@ -648,6 +765,8 @@ function UpdateOIDCIntegrationForm(props: {
close: () => void;
isOpen: boolean;
oidcIntegration: DocumentType<typeof UpdateOIDCIntegration_OIDCIntegrationFragment>;
isAdmin: boolean;
memberRoles: Array<FragmentType<typeof OIDCDefaultRoleSelector_MemberRoleFragment>>;
}): ReactElement {
const [oidcUpdateMutation, oidcUpdateMutate] = useMutation(
UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation,
@ -771,8 +890,8 @@ function UpdateOIDCIntegrationForm(props: {
<Separator orientation="horizontal" />
<div className="space-y-5">
<div className="text-lg font-medium">Restrictions</div>
<div>
<div className="text-lg font-medium">Other Options</div>
<div className="space-y-5">
<div className="flex items-center justify-between space-x-4">
<div className="flex flex-col space-y-1 text-sm font-medium leading-none">
<p>OIDC-Only Access</p>
@ -790,6 +909,28 @@ function UpdateOIDCIntegrationForm(props: {
disabled={oidcRestrictionsMutation.fetching}
/>
</div>
<div
className={cn(
'flex items-center justify-between space-x-4',
props.isAdmin ? null : 'cursor-not-allowed',
)}
>
<div className="flex flex-col space-y-1 text-sm font-medium leading-none">
<p>Default Member Role</p>
<p className="text-muted-foreground text-xs font-normal leading-snug">
This role is assigned to new members who sign in via OIDC.{' '}
<span className="font-medium">
Only members with the Admin role can modify it.
</span>
</p>
</div>
<OIDCDefaultRoleSelector
disabled={!props.isAdmin}
oidcIntegrationId={props.oidcIntegration.id}
defaultRole={props.oidcIntegration.defaultMemberRole}
memberRoles={props.memberRoles}
/>
</div>
</div>
</div>
</div>