mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Customizable default member role for OIDC users (#6348)
This commit is contained in:
parent
cc86bc5c7e
commit
e754700212
18 changed files with 494 additions and 38 deletions
5
.changeset/few-mice-buy.md
Normal file
5
.changeset/few-mice-buy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'hive': minor
|
||||
---
|
||||
|
||||
Adds ability to select a default role for new OIDC users
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"]')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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'),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -638,6 +638,11 @@ export interface Storage {
|
|||
oidcUserAccessOnly: boolean;
|
||||
}): Promise<OIDCIntegration>;
|
||||
|
||||
updateOIDCDefaultMemberRole(_: {
|
||||
oidcIntegrationId: string;
|
||||
roleId: string;
|
||||
}): Promise<OIDCIntegration>;
|
||||
|
||||
createCDNAccessToken(_: {
|
||||
id: string;
|
||||
targetId: string;
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ export interface OIDCIntegration {
|
|||
userinfoEndpoint: string;
|
||||
authorizationEndpoint: string;
|
||||
oidcUserAccessOnly: boolean;
|
||||
defaultMemberRoleId: string | null;
|
||||
}
|
||||
|
||||
export interface CDNAccessToken {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ function OrganizationMemberRoleSwitcher(props: {
|
|||
return (
|
||||
<>
|
||||
<RoleSelector
|
||||
searchPlaceholder="Select new role..."
|
||||
roles={roles}
|
||||
onSelect={async role => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue