Skip to content

Commit 3f16713

Browse files
committed
feat: improve onboarding flow
1 parent 922a1c4 commit 3f16713

File tree

23 files changed

+516
-281
lines changed

23 files changed

+516
-281
lines changed

api-schema.graphql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ type Query {
168168
userFindManyIdentity(input: IdentityUserFindManyInput!): [Identity!]
169169
userFindManyUser(input: UserUserFindManyInput!): UserPaging!
170170
userFindOneUser(username: String!): User
171+
userGetOnboardingAvatarUrls: [String!]
172+
userGetOnboardingUsernames: [String!]
171173
userRequestIdentityChallenge(input: IdentityRequestChallengeInput!): IdentityChallenge
172174
}
173175

@@ -210,6 +212,7 @@ input UserAdminUpdateInput {
210212
developer: Boolean
211213
name: String
212214
onboarded: Boolean
215+
profile: String
213216
role: UserRole
214217
status: UserStatus
215218
username: String

libs/api/auth/data-access/src/lib/strategies/oauth/api-auth-strategy-google.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function createGoogleProfile(profile: Profile) {
3232
return {
3333
externalId: profile.id,
3434
username: (profile.emails as Array<{ value?: string }>)[0].value,
35-
avatarUrl: profile.photos?.[0].value,
35+
// avatarUrl: profile.photos?.[0].value,
3636
name: profile.displayName,
3737
}
3838
}
Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,94 @@
11
import { Injectable } from '@nestjs/common'
2-
import { ApiCoreService } from '@pubkey-network/api-core-data-access'
2+
import { IdentityProvider } from '@prisma/client'
3+
import { ApiCoreService, ellipsify } from '@pubkey-network/api-core-data-access'
34

45
@Injectable()
56
export class ApiOnboardingService {
67
constructor(private readonly core: ApiCoreService) {}
8+
9+
async getOnboardingUsernames(userId: string) {
10+
const found = await this.core.data.user.findUnique({
11+
where: { id: userId },
12+
include: { identities: true },
13+
})
14+
if (!found) {
15+
throw new Error(`User ${userId} not found`)
16+
}
17+
const usernames = found.identities
18+
.filter((i) => i?.profile)
19+
.map((i) => i.profile as { username?: string })
20+
// Remove any identities that don't have a username
21+
.filter((i) => i.username)
22+
// Take the username property
23+
.map((i) => i.username as string)
24+
// Take the first part of any email addresses
25+
.map((i) => i?.split('@')[0])
26+
// Convert any special characters to lowercase
27+
.map((i) => i.replace(/[^a-z0-9]/gi, '_').toLowerCase())
28+
29+
// For all usernames with an underscore, also offer the username without the underscore
30+
for (const username of usernames) {
31+
if (username.includes('_')) {
32+
usernames.push(username.replace('_', ''))
33+
}
34+
}
35+
36+
const wallets = found.identities
37+
// Remove any identities that are not Solana wallets
38+
.filter((i) => i?.provider === IdentityProvider.Solana)
39+
// Take the providerId property
40+
.map((i) => i.providerId as string)
41+
// Convert any special characters to lowercase
42+
.map((i) => ellipsify(i, 4, '__').toLowerCase())
43+
44+
usernames.push(...wallets)
45+
46+
// Remove any duplicates and sort the usernames
47+
return Array.from(new Set(usernames)).sort()
48+
}
49+
50+
async getOnboardingAvatarUrls(userId: string) {
51+
const found = await this.core.data.user.findUnique({
52+
where: { id: userId },
53+
include: { identities: true },
54+
})
55+
if (!found) {
56+
throw new Error(`User ${userId} not found`)
57+
}
58+
59+
const usernames: string[] = []
60+
61+
const avatarUrls = found.identities
62+
.filter((i) => i?.profile)
63+
.map((i) => i.profile as { avatarUrl?: string; username?: string })
64+
.map((i) => {
65+
// Collect any usernames
66+
if (i.username) {
67+
usernames.push(i.username)
68+
}
69+
return i
70+
})
71+
// Remove any identities that don't have a avatarUrl
72+
.filter((i) => i.avatarUrl)
73+
// Take the avatarUrl property
74+
.map((i) => i.avatarUrl as string)
75+
76+
const cleaned = cleanupUsernames(usernames)
77+
78+
for (const username of cleaned) {
79+
avatarUrls.push(`https://api.dicebear.com/9.x/initials/svg?backgroundColor=b6e3f4,c0aede,d1d4f9&seed=${username}`)
80+
}
81+
82+
// Remove any duplicates and sort the avatarUrls
83+
return Array.from(new Set(avatarUrls))
84+
}
85+
}
86+
87+
function cleanupUsernames(usernames: string[]) {
88+
return (
89+
usernames // Take the first part of any email addresses
90+
.map((i) => i?.split('@')[0])
91+
// Convert any special characters to lowercase
92+
.map((i) => i.replace(/[^a-z0-9]/gi, '_').toLowerCase())
93+
)
794
}
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
import { Resolver } from '@nestjs/graphql'
1+
import { UseGuards } from '@nestjs/common'
2+
import { Query, Resolver } from '@nestjs/graphql'
3+
import { ApiAuthGraphQLAdminGuard, CtxUserId } from '@pubkey-network/api-auth-data-access'
24
import { ApiOnboardingService } from '@pubkey-network/api-onboarding-data-access'
35

46
@Resolver()
7+
@UseGuards(ApiAuthGraphQLAdminGuard)
58
export class ApiOnboardingResolver {
69
constructor(private readonly service: ApiOnboardingService) {}
10+
11+
@Query(() => [String], { nullable: true })
12+
async userGetOnboardingUsernames(@CtxUserId() userId: string) {
13+
return this.service.getOnboardingUsernames(userId)
14+
}
15+
16+
@Query(() => [String], { nullable: true })
17+
async userGetOnboardingAvatarUrls(@CtxUserId() userId: string) {
18+
return this.service.getOnboardingAvatarUrls(userId)
19+
}
720
}

libs/api/user/data-access/src/lib/dto/user-admin-update.input.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export class UserAdminUpdateInput {
1111
@Field({ nullable: true })
1212
avatarUrl?: string
1313
@Field({ nullable: true })
14+
profile?: string
15+
@Field({ nullable: true })
1416
developer?: boolean
1517
@Field({ nullable: true })
1618
onboarded?: boolean

libs/sdk/src/generated/graphql-sdk.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ export type Query = {
261261
userFindManyIdentity?: Maybe<Array<Identity>>
262262
userFindManyUser: UserPaging
263263
userFindOneUser?: Maybe<User>
264+
userGetOnboardingAvatarUrls?: Maybe<Array<Scalars['String']['output']>>
265+
userGetOnboardingUsernames?: Maybe<Array<Scalars['String']['output']>>
264266
userRequestIdentityChallenge?: Maybe<IdentityChallenge>
265267
}
266268

@@ -357,6 +359,7 @@ export type UserAdminUpdateInput = {
357359
developer?: InputMaybe<Scalars['Boolean']['input']>
358360
name?: InputMaybe<Scalars['String']['input']>
359361
onboarded?: InputMaybe<Scalars['Boolean']['input']>
362+
profile?: InputMaybe<Scalars['String']['input']>
360363
role?: InputMaybe<UserRole>
361364
status?: InputMaybe<UserStatus>
362365
username?: InputMaybe<Scalars['String']['input']>
@@ -744,6 +747,14 @@ export type AnonVerifyIdentityChallengeMutation = {
744747
} | null
745748
}
746749

750+
export type UserGetOnboardingUsernamesQueryVariables = Exact<{ [key: string]: never }>
751+
752+
export type UserGetOnboardingUsernamesQuery = { __typename?: 'Query'; usernames?: Array<string> | null }
753+
754+
export type UserGetOnboardingAvatarUrlsQueryVariables = Exact<{ [key: string]: never }>
755+
756+
export type UserGetOnboardingAvatarUrlsQuery = { __typename?: 'Query'; avatarUrls?: Array<string> | null }
757+
747758
export type PubkeyProfileDetailsFragment = {
748759
__typename?: 'PubkeyProfile'
749760
publicKey: string
@@ -1304,6 +1315,16 @@ export const AnonVerifyIdentityChallengeDocument = gql`
13041315
}
13051316
${IdentityChallengeDetailsFragmentDoc}
13061317
`
1318+
export const UserGetOnboardingUsernamesDocument = gql`
1319+
query userGetOnboardingUsernames {
1320+
usernames: userGetOnboardingUsernames
1321+
}
1322+
`
1323+
export const UserGetOnboardingAvatarUrlsDocument = gql`
1324+
query userGetOnboardingAvatarUrls {
1325+
avatarUrls: userGetOnboardingAvatarUrls
1326+
}
1327+
`
13071328
export const CreateUserProfileDocument = gql`
13081329
mutation createUserProfile($publicKey: String!) {
13091330
created: createUserProfile(publicKey: $publicKey)
@@ -1469,6 +1490,8 @@ const UserVerifyIdentityChallengeDocumentString = print(UserVerifyIdentityChalle
14691490
const UserLinkIdentityDocumentString = print(UserLinkIdentityDocument)
14701491
const AnonRequestIdentityChallengeDocumentString = print(AnonRequestIdentityChallengeDocument)
14711492
const AnonVerifyIdentityChallengeDocumentString = print(AnonVerifyIdentityChallengeDocument)
1493+
const UserGetOnboardingUsernamesDocumentString = print(UserGetOnboardingUsernamesDocument)
1494+
const UserGetOnboardingAvatarUrlsDocumentString = print(UserGetOnboardingAvatarUrlsDocument)
14721495
const CreateUserProfileDocumentString = print(CreateUserProfileDocument)
14731496
const ProfileIdentityAddDocumentString = print(ProfileIdentityAddDocument)
14741497
const ProfileIdentityRemoveDocumentString = print(ProfileIdentityRemoveDocument)
@@ -1792,6 +1815,48 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
17921815
variables,
17931816
)
17941817
},
1818+
userGetOnboardingUsernames(
1819+
variables?: UserGetOnboardingUsernamesQueryVariables,
1820+
requestHeaders?: GraphQLClientRequestHeaders,
1821+
): Promise<{
1822+
data: UserGetOnboardingUsernamesQuery
1823+
errors?: GraphQLError[]
1824+
extensions?: any
1825+
headers: Headers
1826+
status: number
1827+
}> {
1828+
return withWrapper(
1829+
(wrappedRequestHeaders) =>
1830+
client.rawRequest<UserGetOnboardingUsernamesQuery>(UserGetOnboardingUsernamesDocumentString, variables, {
1831+
...requestHeaders,
1832+
...wrappedRequestHeaders,
1833+
}),
1834+
'userGetOnboardingUsernames',
1835+
'query',
1836+
variables,
1837+
)
1838+
},
1839+
userGetOnboardingAvatarUrls(
1840+
variables?: UserGetOnboardingAvatarUrlsQueryVariables,
1841+
requestHeaders?: GraphQLClientRequestHeaders,
1842+
): Promise<{
1843+
data: UserGetOnboardingAvatarUrlsQuery
1844+
errors?: GraphQLError[]
1845+
extensions?: any
1846+
headers: Headers
1847+
status: number
1848+
}> {
1849+
return withWrapper(
1850+
(wrappedRequestHeaders) =>
1851+
client.rawRequest<UserGetOnboardingAvatarUrlsQuery>(UserGetOnboardingAvatarUrlsDocumentString, variables, {
1852+
...requestHeaders,
1853+
...wrappedRequestHeaders,
1854+
}),
1855+
'userGetOnboardingAvatarUrls',
1856+
'query',
1857+
variables,
1858+
)
1859+
},
17951860
createUserProfile(
17961861
variables: CreateUserProfileMutationVariables,
17971862
requestHeaders?: GraphQLClientRequestHeaders,
@@ -2273,6 +2338,7 @@ export function UserAdminUpdateInputSchema(): z.ZodObject<Properties<UserAdminUp
22732338
developer: z.boolean().nullish(),
22742339
name: z.string().nullish(),
22752340
onboarded: z.boolean().nullish(),
2341+
profile: z.string().nullish(),
22762342
role: UserRoleSchema.nullish(),
22772343
status: UserStatusSchema.nullish(),
22782344
username: z.string().nullish(),
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
query userGetOnboardingUsernames {
2+
usernames: userGetOnboardingUsernames
3+
}
4+
5+
query userGetOnboardingAvatarUrls {
6+
avatarUrls: userGetOnboardingAvatarUrls
7+
}

libs/web/core/data-access/src/lib/sdk-provider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { createContext, ReactNode, useContext } from 'react'
33

44
const Context = createContext<Sdk>({} as Sdk)
55

6-
export function SdkProvider({ children }: { children: ReactNode }) {
7-
const sdk: Sdk = getGraphQLSdk('/graphql')
6+
export const sdk: Sdk = getGraphQLSdk('/graphql')
87

8+
export function SdkProvider({ children }: { children: ReactNode }) {
99
return <Context.Provider value={sdk}>{children}</Context.Provider>
1010
}
1111

libs/web/core/feature/src/lib/web-core-routes-user.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { SettingsFeature } from '@pubkey-network/web-settings-feature'
55
import { SolanaFeature } from '@pubkey-network/web-solana-feature'
66
import { UserFeature } from '@pubkey-network/web-user-feature'
77
import { UiDashboardItem, UiNotFound } from '@pubkey-ui/core'
8-
import { IconCurrencySolana, IconSettings, IconUser, IconUsers } from '@tabler/icons-react'
8+
import { IconCurrencySolana, IconSettings, IconStar, IconUser, IconUsers } from '@tabler/icons-react'
99
import { Navigate, RouteObject, useRoutes } from 'react-router-dom'
1010

1111
const links: UiDashboardItem[] = [
@@ -14,6 +14,7 @@ const links: UiDashboardItem[] = [
1414
{ label: 'Settings', icon: IconSettings, to: '/settings' },
1515
{ label: 'Solana', icon: IconCurrencySolana, to: '/solana' },
1616
{ label: 'Users', icon: IconUsers, to: '/u' },
17+
{ label: 'Onboarding', icon: IconStar, to: '/onboarding' },
1718
]
1819

1920
const routes: RouteObject[] = [

libs/web/core/ui/src/lib/ui-header-profile.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function UiHeaderProfile({ user, logout }: { user?: User | null; logout:
2626
<Button p={0} variant={open ? 'light' : 'default'} radius="xl">
2727
<UiAvatar
2828
url={user?.avatarUrl}
29-
name={user?.username}
29+
name={user?.username ?? ''}
3030
alt={user?.username ?? 'User Avatar'}
3131
radius={100}
3232
size={34}

0 commit comments

Comments
 (0)