mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
show all linked auth methods in members list (#7661)
This commit is contained in:
parent
e0336e8941
commit
0c756fec6a
10 changed files with 143 additions and 44 deletions
|
|
@ -41,7 +41,6 @@ export class AuditLogRecorder {
|
|||
email: actor.user.email,
|
||||
fullName: actor.user.fullName,
|
||||
displayName: actor.user.displayName,
|
||||
provider: actor.user.provider,
|
||||
}
|
||||
: undefined;
|
||||
const accessToken =
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export default gql`
|
|||
fullName: String!
|
||||
displayName: String! @tag(name: "public")
|
||||
provider: AuthProviderType! @tag(name: "public")
|
||||
providers: [AuthProviderType!]!
|
||||
isAdmin: Boolean!
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,7 @@ import type { UserResolvers } from './../../../__generated__/types';
|
|||
|
||||
export const User: Pick<
|
||||
UserResolvers,
|
||||
'displayName' | 'email' | 'fullName' | 'id' | 'isAdmin' | 'provider'
|
||||
> = {};
|
||||
'displayName' | 'email' | 'fullName' | 'id' | 'isAdmin' | 'provider' | 'providers'
|
||||
> = {
|
||||
provider: user => user.providers[0],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AuthProviderType } from '../../__generated__/types';
|
||||
import type {
|
||||
Organization,
|
||||
OrganizationGetStarted,
|
||||
|
|
@ -20,6 +21,10 @@ export type MemberRoleMapper = OrganizationMemberRole;
|
|||
export type OrganizationGetStartedMapper = OrganizationGetStarted;
|
||||
export type OrganizationInvitationMapper = OrganizationInvitation;
|
||||
export type MemberMapper = OrganizationMembership;
|
||||
export type MemberAuthProviderMapper = {
|
||||
type: AuthProviderType;
|
||||
disabledReason?: string | null | undefined;
|
||||
};
|
||||
export type OrganizationAccessTokenMapper = OrganizationAccessToken;
|
||||
export type PersonalAccessTokenMapper = OrganizationAccessToken;
|
||||
export type ProjectAccessTokenMapper = OrganizationAccessToken;
|
||||
|
|
|
|||
|
|
@ -1257,6 +1257,7 @@ export default gql`
|
|||
type Member {
|
||||
id: ID!
|
||||
user: User! @tag(name: "public")
|
||||
authProviders: [MemberAuthProvider!]!
|
||||
isOwner: Boolean! @tag(name: "public")
|
||||
canLeaveOrganization: Boolean!
|
||||
role: MemberRole! @tag(name: "public")
|
||||
|
|
@ -1384,6 +1385,11 @@ export default gql`
|
|||
projects: [ProjectResourceAssignment!] @tag(name: "public")
|
||||
}
|
||||
|
||||
type MemberAuthProvider {
|
||||
type: AuthProviderType!
|
||||
disabledReason: String
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
"""
|
||||
Paginated list of access tokens issued for the project.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,26 @@ export const Member: MemberResolvers = {
|
|||
}
|
||||
return user;
|
||||
},
|
||||
authProviders: async (member, _arg, { injector }) => {
|
||||
const storage = injector.get(Storage);
|
||||
const [user, oidcIntegration] = await Promise.all([
|
||||
storage.getUserById({ id: member.userId }),
|
||||
storage.getOIDCIntegrationForOrganization({ organizationId: member.organizationId }),
|
||||
]);
|
||||
if (!user) {
|
||||
throw new Error('User not found.');
|
||||
}
|
||||
|
||||
const nonOIDCProvidersDisabled = oidcIntegration?.oidcUserAccessOnly && !member.isOwner;
|
||||
|
||||
return user.providers.map(provider => ({
|
||||
type: provider,
|
||||
disabledReason:
|
||||
nonOIDCProvidersDisabled && provider !== 'OIDC'
|
||||
? 'OIDC authentication is enforced in the organization OIDC configuration'
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
resourceAssignment: async (member, _arg, { injector }) => {
|
||||
return injector.get(ResourceAssignments).resolveGraphQLMemberResourceAssignment({
|
||||
organizationId: member.organizationId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import type { MemberAuthProviderResolvers } from './../../../__generated__/types';
|
||||
|
||||
/*
|
||||
* Note: This object type is generated because "MemberAuthProviderMapper" is declared. This is to ensure runtime safety.
|
||||
*
|
||||
* When a mapper is used, it is possible to hit runtime errors in some scenarios:
|
||||
* - given a field name, the schema type's field type does not match mapper's field type
|
||||
* - or a schema type's field does not exist in the mapper's fields
|
||||
*
|
||||
* If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config.
|
||||
*/
|
||||
export const MemberAuthProvider: MemberAuthProviderResolvers = {};
|
||||
|
|
@ -346,7 +346,7 @@ export interface User {
|
|||
email: string;
|
||||
fullName: string;
|
||||
displayName: string;
|
||||
provider: AuthProviderType;
|
||||
providers: AuthProviderType[];
|
||||
superTokensUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
zendeskId: string | null;
|
||||
|
|
|
|||
|
|
@ -446,11 +446,9 @@ export async function createStorage(
|
|||
) {
|
||||
const record = await connection.maybeOne<unknown>(sql`/* getUserBySuperTokenId */
|
||||
SELECT
|
||||
${userFields(sql`"users".`, sql`"stu".`)}
|
||||
${userFields(sql`"users".`)}
|
||||
FROM
|
||||
"users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
WHERE
|
||||
"users"."supertoken_user_id" = ${superTokensUserId}
|
||||
LIMIT 1
|
||||
|
|
@ -468,11 +466,9 @@ export async function createStorage(
|
|||
const userIds = input.map(i => i.id);
|
||||
const records = await input[0].connection.any<unknown>(sql`/* getUserById */
|
||||
SELECT
|
||||
${userFields(sql`"users".`, sql`"stu".`)}
|
||||
${userFields(sql`"users".`)}
|
||||
FROM
|
||||
"users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
WHERE
|
||||
"users"."id" = ANY(${sql.array(userIds, 'uuid')})
|
||||
`);
|
||||
|
|
@ -663,10 +659,8 @@ export async function createStorage(
|
|||
.maybeOne<unknown>(
|
||||
sql`
|
||||
SELECT
|
||||
${userFields(sql`"users".`, sql`"stu".`)}
|
||||
${userFields(sql`"users".`)}
|
||||
FROM "users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
WHERE
|
||||
"users"."supertoken_user_id" = ${superTokensUserId}
|
||||
OR EXISTS (
|
||||
|
|
@ -683,10 +677,8 @@ export async function createStorage(
|
|||
const sameEmailUsers = await t
|
||||
.any<unknown>(
|
||||
sql`/* ensureUserExists */
|
||||
SELECT ${userFields(sql`"users".`, sql`"stu".`)}
|
||||
SELECT ${userFields(sql`"users".`)}
|
||||
FROM "users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
WHERE "users"."email" = ${email}
|
||||
ORDER BY "users"."created_at";
|
||||
`,
|
||||
|
|
@ -933,7 +925,7 @@ export async function createStorage(
|
|||
>(
|
||||
sql`/* getOrganizationOwner */
|
||||
SELECT
|
||||
${userFields(sql`"u".`, sql`"stu".`)},
|
||||
${userFields(sql`"u".`)},
|
||||
omr.scopes as scopes,
|
||||
om.organization_id,
|
||||
om.connected_to_zendesk,
|
||||
|
|
@ -946,7 +938,6 @@ export async function createStorage(
|
|||
LEFT JOIN users as u ON (u.id = o.user_id)
|
||||
LEFT JOIN organization_member as om ON (om.user_id = u.id AND om.organization_id = o.id)
|
||||
LEFT JOIN organization_member_roles as omr ON (omr.organization_id = o.id AND omr.id = om.role_id)
|
||||
LEFT JOIN supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id)
|
||||
WHERE o.id = ANY(${sql.array(organizations, 'uuid')})`,
|
||||
);
|
||||
|
||||
|
|
@ -983,7 +974,7 @@ export async function createStorage(
|
|||
>(
|
||||
sql`/* getOrganizationMember */
|
||||
SELECT
|
||||
${userFields(sql`"u".`, sql`"stu".`)},
|
||||
${userFields(sql`"u".`)},
|
||||
omr.scopes as scopes,
|
||||
om.organization_id,
|
||||
om.connected_to_zendesk,
|
||||
|
|
@ -997,7 +988,6 @@ export async function createStorage(
|
|||
LEFT JOIN organizations as o ON (o.id = om.organization_id)
|
||||
LEFT JOIN users as u ON (u.id = om.user_id)
|
||||
LEFT JOIN organization_member_roles as omr ON (omr.organization_id = o.id AND omr.id = om.role_id)
|
||||
LEFT JOIN supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id)
|
||||
WHERE (om.organization_id, om.user_id) IN ((${sql.join(
|
||||
selectors.map(s => sql`${s.organizationId}, ${s.userId}`),
|
||||
sql`), (`,
|
||||
|
|
@ -5651,10 +5641,7 @@ export type PaginatedOrganizationInvitationConnection = Readonly<{
|
|||
}>;
|
||||
}>;
|
||||
|
||||
export const userFields = (
|
||||
user: TaggedTemplateLiteralInvocation,
|
||||
superTokensThirdParty: TaggedTemplateLiteralInvocation,
|
||||
) => sql`
|
||||
export const userFields = (user: TaggedTemplateLiteralInvocation) => sql`
|
||||
${user}"id"
|
||||
, ${user}"email"
|
||||
, to_json(${user}"created_at") AS "createdAt"
|
||||
|
|
@ -5664,7 +5651,19 @@ export const userFields = (
|
|||
, ${user}"is_admin" AS "isAdmin"
|
||||
, ${user}"oidc_integration_id" AS "oidcIntegrationId"
|
||||
, ${user}"zendesk_user_id" AS "zendeskId"
|
||||
, ${superTokensThirdParty}"third_party_id" AS "provider"
|
||||
, (
|
||||
SELECT ARRAY_AGG(DISTINCT "sub_stu"."third_party_id")
|
||||
FROM (
|
||||
SELECT ${user}"supertoken_user_id"::text "id"
|
||||
WHERE ${user}"supertoken_user_id" IS NOT NULL
|
||||
UNION
|
||||
SELECT "sub_uli"."identity_id"::text "id"
|
||||
FROM "users_linked_identities" "sub_uli"
|
||||
WHERE "sub_uli"."user_id" = ${user}"id"
|
||||
) "sub_ids"
|
||||
LEFT JOIN "supertokens_thirdparty_users" "sub_stu"
|
||||
ON "sub_stu"."user_id" = "sub_ids"."id"
|
||||
) AS "providers"
|
||||
`;
|
||||
|
||||
export const UserModel = zod.object({
|
||||
|
|
@ -5680,21 +5679,23 @@ export const UserModel = zod.object({
|
|||
.transform(value => value ?? false),
|
||||
oidcIntegrationId: zod.string().nullable(),
|
||||
zendeskId: zod.string().nullable(),
|
||||
provider: zod
|
||||
.string()
|
||||
.nullable()
|
||||
.transform(provider => {
|
||||
if (provider === 'oidc') {
|
||||
return 'OIDC' as const;
|
||||
}
|
||||
if (provider === 'google') {
|
||||
return 'GOOGLE' as const;
|
||||
}
|
||||
if (provider === 'github') {
|
||||
return 'GITHUB' as const;
|
||||
}
|
||||
return 'USERNAME_PASSWORD' as const;
|
||||
}),
|
||||
providers: zod.array(
|
||||
zod
|
||||
.string()
|
||||
.nullable()
|
||||
.transform(provider => {
|
||||
if (provider === 'oidc') {
|
||||
return 'OIDC' as const;
|
||||
}
|
||||
if (provider === 'google') {
|
||||
return 'GOOGLE' as const;
|
||||
}
|
||||
if (provider === 'github') {
|
||||
return 'GITHUB' as const;
|
||||
}
|
||||
return 'USERNAME_PASSWORD' as const;
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type UserType = zod.TypeOf<typeof UserModel>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo, useEffect, useState } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
import { FaUserLock } from 'react-icons/fa';
|
||||
import { FaGithub, FaGoogle, FaOpenid, FaUser, FaUserLock } from 'react-icons/fa';
|
||||
import { IconType } from 'react-icons/lib';
|
||||
import { useMutation, type UseQueryExecute } from 'urql';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import {
|
||||
|
|
@ -28,10 +29,36 @@ import { useToast } from '@/components/ui/use-toast';
|
|||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import * as GraphQLSchema from '@/gql/graphql';
|
||||
import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { organizationMembersRoute } from '../../../router';
|
||||
import { MemberInvitationButton } from './invitations';
|
||||
import { MemberRolePicker } from './member-role-picker';
|
||||
|
||||
export const authProviderToIconAndTextMap: Record<
|
||||
GraphQLSchema.AuthProviderType,
|
||||
{
|
||||
Icon: IconType;
|
||||
text: string;
|
||||
}
|
||||
> = {
|
||||
[GraphQLSchema.AuthProviderType.Google]: {
|
||||
Icon: FaGoogle,
|
||||
text: 'Google OAuth 2.0',
|
||||
},
|
||||
[GraphQLSchema.AuthProviderType.Github]: {
|
||||
Icon: FaGithub,
|
||||
text: 'GitHub OAuth 2.0',
|
||||
},
|
||||
[GraphQLSchema.AuthProviderType.Oidc]: {
|
||||
Icon: FaOpenid,
|
||||
text: 'OpenID Connect',
|
||||
},
|
||||
[GraphQLSchema.AuthProviderType.UsernamePassword]: {
|
||||
Icon: FaUserLock,
|
||||
text: 'Email & Password',
|
||||
},
|
||||
};
|
||||
|
||||
const OrganizationMemberRow_DeleteMember = graphql(`
|
||||
mutation OrganizationMemberRow_DeleteMember($input: OrganizationMemberInput!) {
|
||||
deleteOrganizationMember(input: $input) {
|
||||
|
|
@ -54,10 +81,13 @@ const OrganizationMemberRow_MemberFragment = graphql(`
|
|||
id
|
||||
user {
|
||||
id
|
||||
provider
|
||||
displayName
|
||||
email
|
||||
}
|
||||
authProviders {
|
||||
type
|
||||
disabledReason
|
||||
}
|
||||
role {
|
||||
id
|
||||
}
|
||||
|
|
@ -136,11 +166,34 @@ const OrganizationMemberRow = memo(function OrganizationMemberRow(props: {
|
|||
<tr key={member.id}>
|
||||
<td className="w-12">
|
||||
<div>
|
||||
<FaUserLock className="mx-auto size-5" />
|
||||
<FaUser className="mx-auto size-5" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="grow overflow-hidden py-3 text-sm font-medium">
|
||||
<h3 className="line-clamp-1 font-medium">{member.user.displayName}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="line-clamp-1 font-medium">{member.user.displayName}</h3>
|
||||
{member.authProviders.map(provider => {
|
||||
const providerDisplay = authProviderToIconAndTextMap[provider.type];
|
||||
return (
|
||||
<TooltipProvider key={provider.type}>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex gap-1">
|
||||
<providerDisplay.Icon
|
||||
className={cn('size-4', provider.disabledReason && 'text-neutral-7')}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-center">
|
||||
{provider.disabledReason
|
||||
? `${providerDisplay.text} (Disabled - ${provider.disabledReason})`
|
||||
: providerDisplay.text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<h4 className="text-neutral-10 text-xs">{member.user.email}</h4>
|
||||
</td>
|
||||
<td className="relative py-3 text-center text-sm">
|
||||
|
|
|
|||
Loading…
Reference in a new issue