show all linked auth methods in members list (#7661)

This commit is contained in:
Iha Shin (신의하) 2026-02-11 21:39:23 +09:00 committed by GitHub
parent e0336e8941
commit 0c756fec6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 143 additions and 44 deletions

View file

@ -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 =

View file

@ -91,6 +91,7 @@ export default gql`
fullName: String!
displayName: String! @tag(name: "public")
provider: AuthProviderType! @tag(name: "public")
providers: [AuthProviderType!]!
isAdmin: Boolean!
}

View file

@ -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],
};

View file

@ -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;

View file

@ -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.

View file

@ -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,

View file

@ -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 = {};

View file

@ -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;

View file

@ -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>;

View file

@ -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">