Skip to content

Commit 0c756fe

Browse files
authored
show all linked auth methods in members list (#7661)
1 parent e0336e8 commit 0c756fe

File tree

10 files changed

+143
-44
lines changed

10 files changed

+143
-44
lines changed

packages/services/api/src/modules/audit-logs/providers/audit-log-recorder.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ export class AuditLogRecorder {
4141
email: actor.user.email,
4242
fullName: actor.user.fullName,
4343
displayName: actor.user.displayName,
44-
provider: actor.user.provider,
4544
}
4645
: undefined;
4746
const accessToken =

packages/services/api/src/modules/auth/module.graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export default gql`
9191
fullName: String!
9292
displayName: String! @tag(name: "public")
9393
provider: AuthProviderType! @tag(name: "public")
94+
providers: [AuthProviderType!]!
9495
isAdmin: Boolean!
9596
}
9697

packages/services/api/src/modules/auth/resolvers/User.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@ import type { UserResolvers } from './../../../__generated__/types';
22

33
export const User: Pick<
44
UserResolvers,
5-
'displayName' | 'email' | 'fullName' | 'id' | 'isAdmin' | 'provider'
6-
> = {};
5+
'displayName' | 'email' | 'fullName' | 'id' | 'isAdmin' | 'provider' | 'providers'
6+
> = {
7+
provider: user => user.providers[0],
8+
};

packages/services/api/src/modules/organization/module.graphql.mappers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuthProviderType } from '../../__generated__/types';
12
import type {
23
Organization,
34
OrganizationGetStarted,
@@ -20,6 +21,10 @@ export type MemberRoleMapper = OrganizationMemberRole;
2021
export type OrganizationGetStartedMapper = OrganizationGetStarted;
2122
export type OrganizationInvitationMapper = OrganizationInvitation;
2223
export type MemberMapper = OrganizationMembership;
24+
export type MemberAuthProviderMapper = {
25+
type: AuthProviderType;
26+
disabledReason?: string | null | undefined;
27+
};
2328
export type OrganizationAccessTokenMapper = OrganizationAccessToken;
2429
export type PersonalAccessTokenMapper = OrganizationAccessToken;
2530
export type ProjectAccessTokenMapper = OrganizationAccessToken;

packages/services/api/src/modules/organization/module.graphql.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,7 @@ export default gql`
12571257
type Member {
12581258
id: ID!
12591259
user: User! @tag(name: "public")
1260+
authProviders: [MemberAuthProvider!]!
12601261
isOwner: Boolean! @tag(name: "public")
12611262
canLeaveOrganization: Boolean!
12621263
role: MemberRole! @tag(name: "public")
@@ -1384,6 +1385,11 @@ export default gql`
13841385
projects: [ProjectResourceAssignment!] @tag(name: "public")
13851386
}
13861387
1388+
type MemberAuthProvider {
1389+
type: AuthProviderType!
1390+
disabledReason: String
1391+
}
1392+
13871393
extend type Project {
13881394
"""
13891395
Paginated list of access tokens issued for the project.

packages/services/api/src/modules/organization/resolvers/Member.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,26 @@ export const Member: MemberResolvers = {
3636
}
3737
return user;
3838
},
39+
authProviders: async (member, _arg, { injector }) => {
40+
const storage = injector.get(Storage);
41+
const [user, oidcIntegration] = await Promise.all([
42+
storage.getUserById({ id: member.userId }),
43+
storage.getOIDCIntegrationForOrganization({ organizationId: member.organizationId }),
44+
]);
45+
if (!user) {
46+
throw new Error('User not found.');
47+
}
48+
49+
const nonOIDCProvidersDisabled = oidcIntegration?.oidcUserAccessOnly && !member.isOwner;
50+
51+
return user.providers.map(provider => ({
52+
type: provider,
53+
disabledReason:
54+
nonOIDCProvidersDisabled && provider !== 'OIDC'
55+
? 'OIDC authentication is enforced in the organization OIDC configuration'
56+
: null,
57+
}));
58+
},
3959
resourceAssignment: async (member, _arg, { injector }) => {
4060
return injector.get(ResourceAssignments).resolveGraphQLMemberResourceAssignment({
4161
organizationId: member.organizationId,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { MemberAuthProviderResolvers } from './../../../__generated__/types';
2+
3+
/*
4+
* Note: This object type is generated because "MemberAuthProviderMapper" is declared. This is to ensure runtime safety.
5+
*
6+
* When a mapper is used, it is possible to hit runtime errors in some scenarios:
7+
* - given a field name, the schema type's field type does not match mapper's field type
8+
* - or a schema type's field does not exist in the mapper's fields
9+
*
10+
* If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config.
11+
*/
12+
export const MemberAuthProvider: MemberAuthProviderResolvers = {};

packages/services/api/src/shared/entities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export interface User {
346346
email: string;
347347
fullName: string;
348348
displayName: string;
349-
provider: AuthProviderType;
349+
providers: AuthProviderType[];
350350
superTokensUserId: string | null;
351351
isAdmin: boolean;
352352
zendeskId: string | null;

packages/services/storage/src/index.ts

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -446,11 +446,9 @@ export async function createStorage(
446446
) {
447447
const record = await connection.maybeOne<unknown>(sql`/* getUserBySuperTokenId */
448448
SELECT
449-
${userFields(sql`"users".`, sql`"stu".`)}
449+
${userFields(sql`"users".`)}
450450
FROM
451451
"users"
452-
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
453-
ON ("stu"."user_id" = "users"."supertoken_user_id")
454452
WHERE
455453
"users"."supertoken_user_id" = ${superTokensUserId}
456454
LIMIT 1
@@ -468,11 +466,9 @@ export async function createStorage(
468466
const userIds = input.map(i => i.id);
469467
const records = await input[0].connection.any<unknown>(sql`/* getUserById */
470468
SELECT
471-
${userFields(sql`"users".`, sql`"stu".`)}
469+
${userFields(sql`"users".`)}
472470
FROM
473471
"users"
474-
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
475-
ON ("stu"."user_id" = "users"."supertoken_user_id")
476472
WHERE
477473
"users"."id" = ANY(${sql.array(userIds, 'uuid')})
478474
`);
@@ -663,10 +659,8 @@ export async function createStorage(
663659
.maybeOne<unknown>(
664660
sql`
665661
SELECT
666-
${userFields(sql`"users".`, sql`"stu".`)}
662+
${userFields(sql`"users".`)}
667663
FROM "users"
668-
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
669-
ON ("stu"."user_id" = "users"."supertoken_user_id")
670664
WHERE
671665
"users"."supertoken_user_id" = ${superTokensUserId}
672666
OR EXISTS (
@@ -683,10 +677,8 @@ export async function createStorage(
683677
const sameEmailUsers = await t
684678
.any<unknown>(
685679
sql`/* ensureUserExists */
686-
SELECT ${userFields(sql`"users".`, sql`"stu".`)}
680+
SELECT ${userFields(sql`"users".`)}
687681
FROM "users"
688-
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
689-
ON ("stu"."user_id" = "users"."supertoken_user_id")
690682
WHERE "users"."email" = ${email}
691683
ORDER BY "users"."created_at";
692684
`,
@@ -933,7 +925,7 @@ export async function createStorage(
933925
>(
934926
sql`/* getOrganizationOwner */
935927
SELECT
936-
${userFields(sql`"u".`, sql`"stu".`)},
928+
${userFields(sql`"u".`)},
937929
omr.scopes as scopes,
938930
om.organization_id,
939931
om.connected_to_zendesk,
@@ -946,7 +938,6 @@ export async function createStorage(
946938
LEFT JOIN users as u ON (u.id = o.user_id)
947939
LEFT JOIN organization_member as om ON (om.user_id = u.id AND om.organization_id = o.id)
948940
LEFT JOIN organization_member_roles as omr ON (omr.organization_id = o.id AND omr.id = om.role_id)
949-
LEFT JOIN supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id)
950941
WHERE o.id = ANY(${sql.array(organizations, 'uuid')})`,
951942
);
952943

@@ -983,7 +974,7 @@ export async function createStorage(
983974
>(
984975
sql`/* getOrganizationMember */
985976
SELECT
986-
${userFields(sql`"u".`, sql`"stu".`)},
977+
${userFields(sql`"u".`)},
987978
omr.scopes as scopes,
988979
om.organization_id,
989980
om.connected_to_zendesk,
@@ -997,7 +988,6 @@ export async function createStorage(
997988
LEFT JOIN organizations as o ON (o.id = om.organization_id)
998989
LEFT JOIN users as u ON (u.id = om.user_id)
999990
LEFT JOIN organization_member_roles as omr ON (omr.organization_id = o.id AND omr.id = om.role_id)
1000-
LEFT JOIN supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id)
1001991
WHERE (om.organization_id, om.user_id) IN ((${sql.join(
1002992
selectors.map(s => sql`${s.organizationId}, ${s.userId}`),
1003993
sql`), (`,
@@ -5651,10 +5641,7 @@ export type PaginatedOrganizationInvitationConnection = Readonly<{
56515641
}>;
56525642
}>;
56535643

5654-
export const userFields = (
5655-
user: TaggedTemplateLiteralInvocation,
5656-
superTokensThirdParty: TaggedTemplateLiteralInvocation,
5657-
) => sql`
5644+
export const userFields = (user: TaggedTemplateLiteralInvocation) => sql`
56585645
${user}"id"
56595646
, ${user}"email"
56605647
, to_json(${user}"created_at") AS "createdAt"
@@ -5664,7 +5651,19 @@ export const userFields = (
56645651
, ${user}"is_admin" AS "isAdmin"
56655652
, ${user}"oidc_integration_id" AS "oidcIntegrationId"
56665653
, ${user}"zendesk_user_id" AS "zendeskId"
5667-
, ${superTokensThirdParty}"third_party_id" AS "provider"
5654+
, (
5655+
SELECT ARRAY_AGG(DISTINCT "sub_stu"."third_party_id")
5656+
FROM (
5657+
SELECT ${user}"supertoken_user_id"::text "id"
5658+
WHERE ${user}"supertoken_user_id" IS NOT NULL
5659+
UNION
5660+
SELECT "sub_uli"."identity_id"::text "id"
5661+
FROM "users_linked_identities" "sub_uli"
5662+
WHERE "sub_uli"."user_id" = ${user}"id"
5663+
) "sub_ids"
5664+
LEFT JOIN "supertokens_thirdparty_users" "sub_stu"
5665+
ON "sub_stu"."user_id" = "sub_ids"."id"
5666+
) AS "providers"
56685667
`;
56695668

56705669
export const UserModel = zod.object({
@@ -5680,21 +5679,23 @@ export const UserModel = zod.object({
56805679
.transform(value => value ?? false),
56815680
oidcIntegrationId: zod.string().nullable(),
56825681
zendeskId: zod.string().nullable(),
5683-
provider: zod
5684-
.string()
5685-
.nullable()
5686-
.transform(provider => {
5687-
if (provider === 'oidc') {
5688-
return 'OIDC' as const;
5689-
}
5690-
if (provider === 'google') {
5691-
return 'GOOGLE' as const;
5692-
}
5693-
if (provider === 'github') {
5694-
return 'GITHUB' as const;
5695-
}
5696-
return 'USERNAME_PASSWORD' as const;
5697-
}),
5682+
providers: zod.array(
5683+
zod
5684+
.string()
5685+
.nullable()
5686+
.transform(provider => {
5687+
if (provider === 'oidc') {
5688+
return 'OIDC' as const;
5689+
}
5690+
if (provider === 'google') {
5691+
return 'GOOGLE' as const;
5692+
}
5693+
if (provider === 'github') {
5694+
return 'GITHUB' as const;
5695+
}
5696+
return 'USERNAME_PASSWORD' as const;
5697+
}),
5698+
),
56985699
});
56995700

57005701
type UserType = zod.TypeOf<typeof UserModel>;

packages/web/app/src/components/organization/members/list.tsx

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memo, useEffect, useState } from 'react';
22
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
3-
import { FaUserLock } from 'react-icons/fa';
3+
import { FaGithub, FaGoogle, FaOpenid, FaUser, FaUserLock } from 'react-icons/fa';
4+
import { IconType } from 'react-icons/lib';
45
import { useMutation, type UseQueryExecute } from 'urql';
56
import { useDebouncedCallback } from 'use-debounce';
67
import {
@@ -28,10 +29,36 @@ import { useToast } from '@/components/ui/use-toast';
2829
import { FragmentType, graphql, useFragment } from '@/gql';
2930
import * as GraphQLSchema from '@/gql/graphql';
3031
import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters';
32+
import { cn } from '@/lib/utils';
3133
import { organizationMembersRoute } from '../../../router';
3234
import { MemberInvitationButton } from './invitations';
3335
import { MemberRolePicker } from './member-role-picker';
3436

37+
export const authProviderToIconAndTextMap: Record<
38+
GraphQLSchema.AuthProviderType,
39+
{
40+
Icon: IconType;
41+
text: string;
42+
}
43+
> = {
44+
[GraphQLSchema.AuthProviderType.Google]: {
45+
Icon: FaGoogle,
46+
text: 'Google OAuth 2.0',
47+
},
48+
[GraphQLSchema.AuthProviderType.Github]: {
49+
Icon: FaGithub,
50+
text: 'GitHub OAuth 2.0',
51+
},
52+
[GraphQLSchema.AuthProviderType.Oidc]: {
53+
Icon: FaOpenid,
54+
text: 'OpenID Connect',
55+
},
56+
[GraphQLSchema.AuthProviderType.UsernamePassword]: {
57+
Icon: FaUserLock,
58+
text: 'Email & Password',
59+
},
60+
};
61+
3562
const OrganizationMemberRow_DeleteMember = graphql(`
3663
mutation OrganizationMemberRow_DeleteMember($input: OrganizationMemberInput!) {
3764
deleteOrganizationMember(input: $input) {
@@ -54,10 +81,13 @@ const OrganizationMemberRow_MemberFragment = graphql(`
5481
id
5582
user {
5683
id
57-
provider
5884
displayName
5985
email
6086
}
87+
authProviders {
88+
type
89+
disabledReason
90+
}
6191
role {
6292
id
6393
}
@@ -136,11 +166,34 @@ const OrganizationMemberRow = memo(function OrganizationMemberRow(props: {
136166
<tr key={member.id}>
137167
<td className="w-12">
138168
<div>
139-
<FaUserLock className="mx-auto size-5" />
169+
<FaUser className="mx-auto size-5" />
140170
</div>
141171
</td>
142172
<td className="grow overflow-hidden py-3 text-sm font-medium">
143-
<h3 className="line-clamp-1 font-medium">{member.user.displayName}</h3>
173+
<div className="flex items-center gap-2">
174+
<h3 className="line-clamp-1 font-medium">{member.user.displayName}</h3>
175+
{member.authProviders.map(provider => {
176+
const providerDisplay = authProviderToIconAndTextMap[provider.type];
177+
return (
178+
<TooltipProvider key={provider.type}>
179+
<Tooltip delayDuration={100}>
180+
<TooltipTrigger asChild>
181+
<div className="flex gap-1">
182+
<providerDisplay.Icon
183+
className={cn('size-4', provider.disabledReason && 'text-neutral-7')}
184+
/>
185+
</div>
186+
</TooltipTrigger>
187+
<TooltipContent className="text-center">
188+
{provider.disabledReason
189+
? `${providerDisplay.text} (Disabled - ${provider.disabledReason})`
190+
: providerDisplay.text}
191+
</TooltipContent>
192+
</Tooltip>
193+
</TooltipProvider>
194+
);
195+
})}
196+
</div>
144197
<h4 className="text-neutral-10 text-xs">{member.user.email}</h4>
145198
</td>
146199
<td className="relative py-3 text-center text-sm">

0 commit comments

Comments
 (0)