diff --git a/api-schema.graphql b/api-schema.graphql index 5e12ebd..851c09f 100644 --- a/api-schema.graphql +++ b/api-schema.graphql @@ -120,10 +120,29 @@ type Mutation { syncUserProfile: JSON userDeleteIdentity(identityId: String!): Boolean userLinkIdentity(input: IdentityUserLinkInput!): Identity + userOnboardingCreateProfile(publicKey: String!): [Int!] + userOnboardingCustomizeProfile(avatarUrl: String!, username: String!): Boolean userUpdateUser(input: UserUserUpdateInput!): User userVerifyIdentityChallenge(input: IdentityVerifyChallengeInput!): IdentityChallenge } +type OnboardingRequirements { + profileAccount: String + socialIdentities: Int + solanaIdentities: Int + step: OnboardingStep! + validAvatarUrl: Boolean + validUsername: Boolean +} + +enum OnboardingStep { + CreateProfile + CustomizeProfile + Finished + LinkSocialIdentities + LinkSolanaWallets +} + type PagingMeta { currentPage: Int! isFirstPage: Boolean! @@ -138,7 +157,6 @@ type PubkeyProfile { authorities: [String!]! avatarUrl: String! bump: Int! - feePayer: String! identities: [PubkeyProfileIdentity!]! publicKey: String! username: String! @@ -168,6 +186,9 @@ type Query { userFindManyIdentity(input: IdentityUserFindManyInput!): [Identity!] userFindManyUser(input: UserUserFindManyInput!): UserPaging! userFindOneUser(username: String!): User + userGetOnboardingAvatarUrls: [String!] + userGetOnboardingUsernames: [String!] + userOnboardingRequirements: OnboardingRequirements userRequestIdentityChallenge(input: IdentityRequestChallengeInput!): IdentityChallenge } @@ -183,6 +204,7 @@ type User { id: String! identities: [Identity!] name: String + onboarded: Boolean profile: String profileUrl: String! role: UserRole @@ -208,6 +230,8 @@ input UserAdminUpdateInput { avatarUrl: String developer: Boolean name: String + onboarded: Boolean + profile: String role: UserRole status: UserStatus username: String diff --git a/libs/api/auth/data-access/src/lib/strategies/oauth/api-auth-strategy-google.ts b/libs/api/auth/data-access/src/lib/strategies/oauth/api-auth-strategy-google.ts index 7ff059c..ea8a8ef 100644 --- a/libs/api/auth/data-access/src/lib/strategies/oauth/api-auth-strategy-google.ts +++ b/libs/api/auth/data-access/src/lib/strategies/oauth/api-auth-strategy-google.ts @@ -32,7 +32,7 @@ function createGoogleProfile(profile: Profile) { return { externalId: profile.id, username: (profile.emails as Array<{ value?: string }>)[0].value, - avatarUrl: profile.photos?.[0].value, + // avatarUrl: profile.photos?.[0].value, name: profile.displayName, } } diff --git a/libs/api/core/data-access/src/lib/api-core.service.ts b/libs/api/core/data-access/src/lib/api-core.service.ts index 8b78729..143b77d 100644 --- a/libs/api/core/data-access/src/lib/api-core.service.ts +++ b/libs/api/core/data-access/src/lib/api-core.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' -import { IdentityProvider } from '@prisma/client' +import { IdentityProvider, Prisma } from '@prisma/client' import { ApiCorePrismaClient, prismaClient } from './api-core-prisma-client' import { ApiCoreConfigService } from './config/api-core-config.service' import { slugifyId } from './helpers/slugify-id' @@ -11,6 +11,17 @@ export class ApiCoreService { readonly data: ApiCorePrismaClient = prismaClient constructor(readonly config: ApiCoreConfigService, readonly eventEmitter: EventEmitter2) {} + async ensureUserById(userId: string) { + const user = await this.data.user.findUnique({ + where: { id: userId }, + include: { identities: true }, + }) + if (!user) { + throw new Error(`User ${userId} not found`) + } + return user + } + async findUserByIdentity({ provider, providerId }: { provider: IdentityProvider; providerId: string }) { return this.data.identity.findUnique({ where: { provider_providerId: { provider, providerId } }, @@ -45,4 +56,8 @@ export class ApiCoreService { include: { identities: true }, }) } + + async updateUserById(userId: string, data: Prisma.UserUpdateInput) { + return this.data.user.update({ where: { id: userId }, data }) + } } diff --git a/libs/api/core/data-access/src/lib/config/api-core-config.service.ts b/libs/api/core/data-access/src/lib/config/api-core-config.service.ts index daef743..a364b06 100644 --- a/libs/api/core/data-access/src/lib/config/api-core-config.service.ts +++ b/libs/api/core/data-access/src/lib/config/api-core-config.service.ts @@ -238,6 +238,10 @@ export class ApiCoreConfigService { return '/api' } + get pubkeyProtocolCommunity(): string { + return this.service.get('pubkeyProtocolCommunity') as string + } + get pubkeyProtocolSigner(): Keypair { return this.service.get('pubkeyProtocolSigner') as Keypair } diff --git a/libs/api/core/data-access/src/lib/config/configuration.ts b/libs/api/core/data-access/src/lib/config/configuration.ts index 530c707..dc1c7f4 100644 --- a/libs/api/core/data-access/src/lib/config/configuration.ts +++ b/libs/api/core/data-access/src/lib/config/configuration.ts @@ -64,6 +64,7 @@ export interface ApiCoreConfig { jwtSecret: string port: number sessionSecret: string + pubkeyProtocolCommunity: string pubkeyProtocolSigner: Keypair pubkeyProtocolSignerMinimalBalance: number solanaEndpoint: string @@ -106,6 +107,7 @@ export function configuration(): ApiCoreConfig { host: process.env['HOST'] as string, jwtSecret: process.env['JWT_SECRET'] as string, port: parseInt(process.env['PORT'] as string, 10) || 3000, + pubkeyProtocolCommunity: process.env['PUBKEY_PROTOCOL_COMMUNITY'] as string, pubkeyProtocolSigner: getKeypairFromByteArray( JSON.parse(process.env['PUBKEY_PROTOCOL_SIGNER_SECRET_KEY'] as string), ), diff --git a/libs/api/core/data-access/src/lib/config/validation-schema.ts b/libs/api/core/data-access/src/lib/config/validation-schema.ts index 9c2c8fa..b3fca61 100644 --- a/libs/api/core/data-access/src/lib/config/validation-schema.ts +++ b/libs/api/core/data-access/src/lib/config/validation-schema.ts @@ -47,6 +47,7 @@ export const validationSchema = Joi.object({ HOST: Joi.string().default('0.0.0.0'), NODE_ENV: Joi.string().valid('development', 'production', 'test', 'provision').default('development'), PORT: Joi.number().default(3000), + PUBKEY_PROTOCOL_COMMUNITY: Joi.string().required(), PUBKEY_PROTOCOL_SIGNER_SECRET_KEY: Joi.string().required(), PUBKEY_PROTOCOL_SIGNER_MINIMAL_BALANCE: Joi.number().default(1), SESSION_SECRET: Joi.string().required(), diff --git a/libs/api/core/feature/src/lib/api-core-feature.module.ts b/libs/api/core/feature/src/lib/api-core-feature.module.ts index 2ef5ed9..b860dae 100644 --- a/libs/api/core/feature/src/lib/api-core-feature.module.ts +++ b/libs/api/core/feature/src/lib/api-core-feature.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common' import { ApiAuthFeatureModule } from '@pubkey-network/api-auth-feature' import { ApiCoreDataAccessModule } from '@pubkey-network/api-core-data-access' import { ApiIdentityFeatureModule } from '@pubkey-network/api-identity-feature' +import { ApiOnboardingFeatureModule } from '@pubkey-network/api-onboarding-feature' import { ApiProtocolFeatureModule } from '@pubkey-network/api-protocol-feature' import { ApiSolanaFeatureModule } from '@pubkey-network/api-solana-feature' import { ApiUserFeatureModule } from '@pubkey-network/api-user-feature' @@ -13,6 +14,7 @@ const imports = [ ApiAuthFeatureModule, ApiCoreDataAccessModule, ApiIdentityFeatureModule, + ApiOnboardingFeatureModule, ApiProtocolFeatureModule, ApiSolanaFeatureModule, ApiUserFeatureModule, diff --git a/libs/api/onboarding/data-access/.eslintrc.json b/libs/api/onboarding/data-access/.eslintrc.json new file mode 100644 index 0000000..632e9b0 --- /dev/null +++ b/libs/api/onboarding/data-access/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/api/onboarding/data-access/README.md b/libs/api/onboarding/data-access/README.md new file mode 100644 index 0000000..f01db66 --- /dev/null +++ b/libs/api/onboarding/data-access/README.md @@ -0,0 +1,7 @@ +# api-onboarding-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test api-onboarding-data-access` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/api/onboarding/data-access/jest.config.ts b/libs/api/onboarding/data-access/jest.config.ts new file mode 100644 index 0000000..90667fd --- /dev/null +++ b/libs/api/onboarding/data-access/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api-onboarding-data-access', + preset: '../../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../../coverage/libs/api/onboarding/data-access', +} diff --git a/libs/api/onboarding/data-access/project.json b/libs/api/onboarding/data-access/project.json new file mode 100644 index 0000000..f6692d8 --- /dev/null +++ b/libs/api/onboarding/data-access/project.json @@ -0,0 +1,19 @@ +{ + "name": "api-onboarding-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api/onboarding/data-access/src", + "projectType": "library", + "tags": ["app:api", "type:data-access"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/api/onboarding/data-access/jest.config.ts" + } + } + } +} diff --git a/libs/api/onboarding/data-access/src/index.ts b/libs/api/onboarding/data-access/src/index.ts new file mode 100644 index 0000000..8d1fde8 --- /dev/null +++ b/libs/api/onboarding/data-access/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/api-onboarding.data-access.module' +export * from './lib/api-onboarding.service' +export * from './lib/entity/onboarding-step' +export * from './lib/entity/onboarding.entity' diff --git a/libs/api/onboarding/data-access/src/lib/api-onboarding.data-access.module.ts b/libs/api/onboarding/data-access/src/lib/api-onboarding.data-access.module.ts new file mode 100644 index 0000000..eefa56c --- /dev/null +++ b/libs/api/onboarding/data-access/src/lib/api-onboarding.data-access.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { ApiCoreDataAccessModule } from '@pubkey-network/api-core-data-access' +import { ApiProtocolDataAccessModule } from '@pubkey-network/api-protocol-data-access' +import { ApiOnboardingService } from './api-onboarding.service' + +@Module({ + imports: [ApiCoreDataAccessModule, ApiProtocolDataAccessModule], + providers: [ApiOnboardingService], + exports: [ApiOnboardingService], +}) +export class ApiOnboardingDataAccessModule {} diff --git a/libs/api/onboarding/data-access/src/lib/api-onboarding.service.ts b/libs/api/onboarding/data-access/src/lib/api-onboarding.service.ts new file mode 100644 index 0000000..7400ace --- /dev/null +++ b/libs/api/onboarding/data-access/src/lib/api-onboarding.service.ts @@ -0,0 +1,179 @@ +import { Injectable, Logger } from '@nestjs/common' +import { IdentityProvider } from '@prisma/client' +import { ApiCoreService, ellipsify } from '@pubkey-network/api-core-data-access' +import { ApiProtocolService } from '@pubkey-network/api-protocol-data-access' +import { OnboardingStep } from './entity/onboarding-step' +import { OnboardingRequirements } from './entity/onboarding.entity' + +@Injectable() +export class ApiOnboardingService { + readonly socialProviders = [ + IdentityProvider.Discord, + IdentityProvider.Github, + IdentityProvider.Google, + // IdentityProvider.Telegram, + IdentityProvider.X, + ] + + private readonly logger = new Logger(ApiOnboardingService.name) + constructor(private readonly core: ApiCoreService, private readonly protocol: ApiProtocolService) {} + + async getOnboardingUsernames(userId: string) { + const user = await this.core.ensureUserById(userId) + const rawUsernames = user.identities + .filter((i) => i?.profile) + .map((i) => i.profile as { username?: string }) + // Remove any identities that don't have a username + .filter((i) => i.username) + // Take the username property + .map((i) => i.username as string) + + const usernames = cleanupUsernames(rawUsernames) + + // For all usernames with an underscore, also offer the username without the underscore + for (const username of usernames) { + if (username.includes('_')) { + usernames.push(username.replace('_', '')) + } + } + + // Remove any duplicates and sort the usernames + const sortedUsernames = Array.from(new Set(usernames)).sort() + + // Create usernames based on Solana wallets for users that like to be pseudonymous + const wallets = user.identities + // Remove any identities that are not Solana wallets + .filter((i) => i?.provider === IdentityProvider.Solana) + // Take the providerId property + .map((i) => i.providerId as string) + // Convert any special characters to lowercase + .map((i) => ellipsify(i, 4, '__').toLowerCase()) + + // Remove any duplicates and sort the wallets + const sortedWallets = Array.from(new Set(wallets)).sort() + + return [...sortedUsernames, ...sortedWallets] + } + + async getOnboardingAvatarUrls(userId: string) { + const user = await this.core.ensureUserById(userId) + const usernames: string[] = [] + + const avatarUrls = user.identities + .filter((i) => i?.profile) + .map((i) => i.profile as { avatarUrl?: string; username?: string }) + .map((i) => { + // Collect any usernames + if (i.username) { + usernames.push(i.username) + } + return i + }) + // Remove any identities that don't have a avatarUrl + .filter((i) => i.avatarUrl) + // Take the avatarUrl property + .map((i) => i.avatarUrl as string) + + const cleaned = cleanupUsernames(usernames) + + for (const username of cleaned) { + avatarUrls.push( + `https://api.dicebear.com/9.x/avataaars/svg?backgroundColor=b6e3f4,c0aede,d1d4f9&seed=${username}`, + ) + avatarUrls.push(`https://api.dicebear.com/9.x/initials/svg?backgroundColor=b6e3f4,c0aede,d1d4f9&seed=${username}`) + } + + // Remove any duplicates and sort the avatarUrls + return Array.from(new Set(avatarUrls)) + } + + async getOnboardingRequirements(userId: string): Promise { + const user = await this.core.ensureUserById(userId) + + const [usernames, avatarUrls] = await Promise.all([ + this.getOnboardingUsernames(userId), + this.getOnboardingAvatarUrls(userId), + ]) + + const socialIdentities = user.identities.filter((i) => identityProvidersSocial.includes(i?.provider)) + const solanaIdentities = user.identities.filter((i) => i?.provider === IdentityProvider.Solana) + const validAvatarUrl = avatarUrls.includes(user.avatarUrl ?? '') + const validUsername = usernames.includes(user.username) + const profileAccount = user.profile ?? null + + let step: OnboardingStep + + if (!socialIdentities.length) { + step = OnboardingStep.LinkSocialIdentities + } else if (!solanaIdentities.length) { + step = OnboardingStep.LinkSolanaWallets + } else if (!validAvatarUrl || !validUsername) { + step = OnboardingStep.CustomizeProfile + } else if (!user.profile) { + step = OnboardingStep.CreateProfile + } else { + step = OnboardingStep.Finished + } + + return { + profileAccount, + socialIdentities: socialIdentities.length ?? 0, + solanaIdentities: solanaIdentities.length ?? 0, + validAvatarUrl: avatarUrls.includes(user.avatarUrl ?? ''), + validUsername: usernames.includes(user.username), + step, + } + } + + async createProfile(userId: string, publicKey: string) { + const user = await this.core.ensureUserById(userId) + const requirementsMet = await this.getOnboardingRequirements(user.id) + if (!requirementsMet) { + throw new Error(`User ${user.id} has not met the onboarding requirements`) + } + + return this.protocol.createUserProfile(user.id, publicKey) + } + + async customizeProfile(userId: string, username: string, avatarUrl: string) { + const user = await this.core.ensureUserById(userId) + const [usernames, avatarUrls] = await Promise.all([ + this.getOnboardingUsernames(user.id), + this.getOnboardingAvatarUrls(user.id), + ]) + + if (!usernames.includes(username)) { + throw new Error(`User ${user.username} has not met the onboarding requirements`) + } + + if (!avatarUrls.includes(avatarUrl)) { + throw new Error(`User ${user.username} has not met the onboarding requirements`) + } + + try { + const updated = await this.core.updateUserById(user.id, { username, avatarUrl }) + this.logger.log(`User ${user.username} has been updated to ${updated.username} and ${updated.avatarUrl}`) + return true + } catch (error) { + console.error(error) + throw new Error(`User ${user.username} could not be updated`) + } + } +} + +const identityProvidersSocial: IdentityProvider[] = [ + IdentityProvider.Discord, + IdentityProvider.Github, + IdentityProvider.Google, + IdentityProvider.Telegram, + IdentityProvider.X, +] + +function cleanupUsernames(usernames: string[]) { + return ( + usernames // Take the first part of any email addresses + .map((i) => i?.split('@')[0]) + // Convert any special characters to lowercase + .map((i) => i.replace(/[^a-z0-9]/gi, '_').toLowerCase()) + ) +} diff --git a/libs/api/onboarding/data-access/src/lib/entity/onboarding-step.ts b/libs/api/onboarding/data-access/src/lib/entity/onboarding-step.ts new file mode 100644 index 0000000..0f45687 --- /dev/null +++ b/libs/api/onboarding/data-access/src/lib/entity/onboarding-step.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql' + +export enum OnboardingStep { + CreateProfile = 'CreateProfile', + CustomizeProfile = 'CustomizeProfile', + Finished = 'Finished', + LinkSocialIdentities = 'LinkSocialIdentities', + LinkSolanaWallets = 'LinkSolanaWallets', +} + +registerEnumType(OnboardingStep, { name: 'OnboardingStep' }) diff --git a/libs/api/onboarding/data-access/src/lib/entity/onboarding.entity.ts b/libs/api/onboarding/data-access/src/lib/entity/onboarding.entity.ts new file mode 100644 index 0000000..35cf0ad --- /dev/null +++ b/libs/api/onboarding/data-access/src/lib/entity/onboarding.entity.ts @@ -0,0 +1,18 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { OnboardingStep } from './onboarding-step' + +@ObjectType() +export class OnboardingRequirements { + @Field(() => String, { nullable: true }) + profileAccount!: string | null + @Field(() => Int, { nullable: true }) + socialIdentities!: number | null + @Field(() => Int, { nullable: true }) + solanaIdentities!: number | null + @Field(() => Boolean, { nullable: true }) + validUsername!: boolean + @Field(() => Boolean, { nullable: true }) + validAvatarUrl!: boolean + @Field(() => OnboardingStep) + step!: OnboardingStep +} diff --git a/libs/api/onboarding/data-access/tsconfig.json b/libs/api/onboarding/data-access/tsconfig.json new file mode 100644 index 0000000..4022fd4 --- /dev/null +++ b/libs/api/onboarding/data-access/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/api/onboarding/data-access/tsconfig.lib.json b/libs/api/onboarding/data-access/tsconfig.lib.json new file mode 100644 index 0000000..c6b908a --- /dev/null +++ b/libs/api/onboarding/data-access/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es6", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/api/onboarding/data-access/tsconfig.spec.json b/libs/api/onboarding/data-access/tsconfig.spec.json new file mode 100644 index 0000000..56497b8 --- /dev/null +++ b/libs/api/onboarding/data-access/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/api/onboarding/feature/.eslintrc.json b/libs/api/onboarding/feature/.eslintrc.json new file mode 100644 index 0000000..632e9b0 --- /dev/null +++ b/libs/api/onboarding/feature/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/api/onboarding/feature/README.md b/libs/api/onboarding/feature/README.md new file mode 100644 index 0000000..cdbb966 --- /dev/null +++ b/libs/api/onboarding/feature/README.md @@ -0,0 +1,7 @@ +# api-onboarding-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test api-onboarding-feature` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/api/onboarding/feature/jest.config.ts b/libs/api/onboarding/feature/jest.config.ts new file mode 100644 index 0000000..4efb179 --- /dev/null +++ b/libs/api/onboarding/feature/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api-onboarding-feature', + preset: '../../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../../coverage/libs/api/onboarding/feature', +} diff --git a/libs/api/onboarding/feature/project.json b/libs/api/onboarding/feature/project.json new file mode 100644 index 0000000..c1f72f6 --- /dev/null +++ b/libs/api/onboarding/feature/project.json @@ -0,0 +1,19 @@ +{ + "name": "api-onboarding-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api/onboarding/feature/src", + "projectType": "library", + "tags": ["app:api", "type:feature"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/api/onboarding/feature/jest.config.ts" + } + } + } +} diff --git a/libs/api/onboarding/feature/src/index.ts b/libs/api/onboarding/feature/src/index.ts new file mode 100644 index 0000000..c7b96ea --- /dev/null +++ b/libs/api/onboarding/feature/src/index.ts @@ -0,0 +1 @@ +export * from './lib/api-onboarding.feature.module' diff --git a/libs/api/onboarding/feature/src/lib/api-onboarding.feature.module.ts b/libs/api/onboarding/feature/src/lib/api-onboarding.feature.module.ts new file mode 100644 index 0000000..06f9675 --- /dev/null +++ b/libs/api/onboarding/feature/src/lib/api-onboarding.feature.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { ApiOnboardingDataAccessModule } from '@pubkey-network/api-onboarding-data-access' +import { ApiOnboardingResolver } from './api-onboarding.resolver' + +@Module({ + imports: [ApiOnboardingDataAccessModule], + providers: [ApiOnboardingResolver], +}) +export class ApiOnboardingFeatureModule {} diff --git a/libs/api/onboarding/feature/src/lib/api-onboarding.resolver.ts b/libs/api/onboarding/feature/src/lib/api-onboarding.resolver.ts new file mode 100644 index 0000000..a133237 --- /dev/null +++ b/libs/api/onboarding/feature/src/lib/api-onboarding.resolver.ts @@ -0,0 +1,40 @@ +import { UseGuards } from '@nestjs/common' +import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql' +import { ApiAuthGraphQLAdminGuard, CtxUserId } from '@pubkey-network/api-auth-data-access' +import { ApiOnboardingService } from '@pubkey-network/api-onboarding-data-access' +import { OnboardingRequirements } from '@pubkey-network/api-onboarding-data-access' + +@Resolver() +@UseGuards(ApiAuthGraphQLAdminGuard) +export class ApiOnboardingResolver { + constructor(private readonly service: ApiOnboardingService) {} + + @Query(() => [String], { nullable: true }) + async userGetOnboardingUsernames(@CtxUserId() userId: string) { + return this.service.getOnboardingUsernames(userId) + } + + @Query(() => [String], { nullable: true }) + async userGetOnboardingAvatarUrls(@CtxUserId() userId: string) { + return this.service.getOnboardingAvatarUrls(userId) + } + + @Query(() => OnboardingRequirements, { nullable: true }) + async userOnboardingRequirements(@CtxUserId() userId: string) { + return this.service.getOnboardingRequirements(userId) + } + + @Mutation(() => Boolean, { nullable: true }) + async userOnboardingCustomizeProfile( + @CtxUserId() userId: string, + @Args('username') username: string, + @Args('avatarUrl') avatarUrl: string, + ) { + return this.service.customizeProfile(userId, username, avatarUrl) + } + + @Mutation(() => [Int], { nullable: true }) + async userOnboardingCreateProfile(@CtxUserId() userId: string, @Args('publicKey') publicKey: string) { + return this.service.createProfile(userId, publicKey) + } +} diff --git a/libs/api/onboarding/feature/tsconfig.json b/libs/api/onboarding/feature/tsconfig.json new file mode 100644 index 0000000..4022fd4 --- /dev/null +++ b/libs/api/onboarding/feature/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/api/onboarding/feature/tsconfig.lib.json b/libs/api/onboarding/feature/tsconfig.lib.json new file mode 100644 index 0000000..c6b908a --- /dev/null +++ b/libs/api/onboarding/feature/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es6", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/api/onboarding/feature/tsconfig.spec.json b/libs/api/onboarding/feature/tsconfig.spec.json new file mode 100644 index 0000000..56497b8 --- /dev/null +++ b/libs/api/onboarding/feature/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/api/protocol/data-access/src/lib/api-protocol.service.ts b/libs/api/protocol/data-access/src/lib/api-protocol.service.ts index 7cd5731..0502134 100644 --- a/libs/api/protocol/data-access/src/lib/api-protocol.service.ts +++ b/libs/api/protocol/data-access/src/lib/api-protocol.service.ts @@ -1,10 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common' +import { Injectable, Logger, NotFoundException } from '@nestjs/common' import { Identity, IdentityProvider as PrismaIdentityProvider } from '@prisma/client' import { ApiCoreService } from '@pubkey-network/api-core-data-access' import { ApiSolanaService } from '@pubkey-network/api-solana-data-access' import { IdentityProvider as IdentityProvider, PUBKEY_PROTOCOL_PROGRAM_ID, + PubKeyCommunity, PubKeyProfile, PubKeyProtocolSdk, } from '@pubkey-protocol/sdk' @@ -12,6 +13,8 @@ import { Keypair, PublicKey } from '@solana/web3.js' @Injectable() export class ApiProtocolService { + private readonly logger = new Logger(ApiProtocolService.name) + private community: PubKeyCommunity | undefined private readonly sdk: PubKeyProtocolSdk private readonly feePayer: Keypair private readonly validProviders: PrismaIdentityProvider[] = [ @@ -30,6 +33,12 @@ export class ApiProtocolService { provider: this.solana.getAnchorProvider(this.feePayer), programId: PUBKEY_PROTOCOL_PROGRAM_ID, }) + this.loadCommunity() + } + + async loadCommunity() { + this.community = await this.sdk.communityGet({ community: this.core.config.pubkeyProtocolCommunity }) + this.logger.log(`Community loaded: ${this.community.name}`) } async createUserProfile(userId: string, publicKey: string) { @@ -37,6 +46,9 @@ export class ApiProtocolService { if (!user) { throw new Error('User not found') } + if (!this.community) { + throw new Error('Community not loaded') + } const authority = ensureAuthority(user.identities, publicKey) const { tx: transaction } = await this.sdk.profileCreate({ @@ -44,7 +56,7 @@ export class ApiProtocolService { avatarUrl: user.avatarUrl ?? '', feePayer: this.feePayer.publicKey, authority, - community: PublicKey.unique(), + community: this.community?.publicKey, name: user.username, }) @@ -65,6 +77,10 @@ export class ApiProtocolService { this.ensureValidProvider(provider) const { user, profile } = await this.ensureUserProfile(userId) + if (!this.community) { + throw new Error('Community not loaded') + } + const existing = profile.identities.find((i) => i.providerId === providerId && i.provider === provider) if (existing) { throw new Error(`Identity ${provider} ${providerId} already linked`) @@ -88,7 +104,7 @@ export class ApiProtocolService { provider, providerId, name, - community: PublicKey.unique(), + community: this.community?.publicKey, }) transaction.sign([this.feePayer]) @@ -209,7 +225,9 @@ export class ApiProtocolService { throw new Error('User not found') } - return this.sdk.profileGetByUsernameNullable({ username: user.username }) + const res = await this.sdk.profileGetByUsernameNullable({ username: user.username }) + console.log(`Profile found: ${res ? res.username : 'None'}`, res) + return res } async getUserProfileByUsername(username: string): Promise { @@ -272,6 +290,13 @@ export class ApiProtocolService { if (!profile) { throw new Error('User profile not found') } + if (!user.profile) { + const updated = await this.core.data.user.update({ + where: { id: userId }, + data: { profile: profile.publicKey.toString(), onboarded: true }, + }) + this.logger.log(`Profile updated: ${updated.profile}`) + } return { user, profile } } @@ -280,7 +305,7 @@ export class ApiProtocolService { const profile = await this.sdk.profileGetByUsernameNullable({ username }) if (profile) { - console.log(`No profile found`) + console.log(`Profile found: ${profile.username}`) return profile } diff --git a/libs/api/protocol/data-access/src/lib/entity/api-pubkey-profile.entity.ts b/libs/api/protocol/data-access/src/lib/entity/api-pubkey-profile.entity.ts index b3c773a..2e6998c 100644 --- a/libs/api/protocol/data-access/src/lib/entity/api-pubkey-profile.entity.ts +++ b/libs/api/protocol/data-access/src/lib/entity/api-pubkey-profile.entity.ts @@ -11,9 +11,6 @@ export class PubkeyProfile { @Field() avatarUrl?: string - @Field() - feePayer!: string - @Field(() => [String]) authorities!: string[] diff --git a/libs/api/user/data-access/src/lib/dto/user-admin-update.input.ts b/libs/api/user/data-access/src/lib/dto/user-admin-update.input.ts index 0d37d8c..66744a5 100644 --- a/libs/api/user/data-access/src/lib/dto/user-admin-update.input.ts +++ b/libs/api/user/data-access/src/lib/dto/user-admin-update.input.ts @@ -11,8 +11,12 @@ export class UserAdminUpdateInput { @Field({ nullable: true }) avatarUrl?: string @Field({ nullable: true }) + profile?: string + @Field({ nullable: true }) developer?: boolean @Field({ nullable: true }) + onboarded?: boolean + @Field({ nullable: true }) name?: string @Field({ nullable: true }) username?: string diff --git a/libs/api/user/data-access/src/lib/entity/user.entity.ts b/libs/api/user/data-access/src/lib/entity/user.entity.ts index 9c53f74..44b9fe6 100644 --- a/libs/api/user/data-access/src/lib/entity/user.entity.ts +++ b/libs/api/user/data-access/src/lib/entity/user.entity.ts @@ -22,6 +22,8 @@ export class User { @Field({ nullable: true }) developer!: boolean @Field({ nullable: true }) + onboarded?: boolean | null + @Field({ nullable: true }) name?: string | null @Field({ nullable: true }) username!: string diff --git a/libs/sdk/src/generated/graphql-sdk.ts b/libs/sdk/src/generated/graphql-sdk.ts index bacf715..d08bf96 100644 --- a/libs/sdk/src/generated/graphql-sdk.ts +++ b/libs/sdk/src/generated/graphql-sdk.ts @@ -136,6 +136,8 @@ export type Mutation = { syncUserProfile?: Maybe userDeleteIdentity?: Maybe userLinkIdentity?: Maybe + userOnboardingCreateProfile?: Maybe> + userOnboardingCustomizeProfile?: Maybe userUpdateUser?: Maybe userVerifyIdentityChallenge?: Maybe } @@ -205,6 +207,15 @@ export type MutationUserLinkIdentityArgs = { input: IdentityUserLinkInput } +export type MutationUserOnboardingCreateProfileArgs = { + publicKey: Scalars['String']['input'] +} + +export type MutationUserOnboardingCustomizeProfileArgs = { + avatarUrl: Scalars['String']['input'] + username: Scalars['String']['input'] +} + export type MutationUserUpdateUserArgs = { input: UserUserUpdateInput } @@ -213,6 +224,24 @@ export type MutationUserVerifyIdentityChallengeArgs = { input: IdentityVerifyChallengeInput } +export type OnboardingRequirements = { + __typename?: 'OnboardingRequirements' + profileAccount?: Maybe + socialIdentities?: Maybe + solanaIdentities?: Maybe + step: OnboardingStep + validAvatarUrl?: Maybe + validUsername?: Maybe +} + +export enum OnboardingStep { + CreateProfile = 'CreateProfile', + CustomizeProfile = 'CustomizeProfile', + Finished = 'Finished', + LinkSocialIdentities = 'LinkSocialIdentities', + LinkSolanaWallets = 'LinkSolanaWallets', +} + export type PagingMeta = { __typename?: 'PagingMeta' currentPage: Scalars['Int']['output'] @@ -229,7 +258,6 @@ export type PubkeyProfile = { authorities: Array avatarUrl: Scalars['String']['output'] bump: Scalars['Int']['output'] - feePayer: Scalars['String']['output'] identities: Array publicKey: Scalars['String']['output'] username: Scalars['String']['output'] @@ -261,6 +289,9 @@ export type Query = { userFindManyIdentity?: Maybe> userFindManyUser: UserPaging userFindOneUser?: Maybe + userGetOnboardingAvatarUrls?: Maybe> + userGetOnboardingUsernames?: Maybe> + userOnboardingRequirements?: Maybe userRequestIdentityChallenge?: Maybe } @@ -330,6 +361,7 @@ export type User = { id: Scalars['String']['output'] identities?: Maybe> name?: Maybe + onboarded?: Maybe profile?: Maybe profileUrl: Scalars['String']['output'] role?: Maybe @@ -355,6 +387,8 @@ export type UserAdminUpdateInput = { avatarUrl?: InputMaybe developer?: InputMaybe name?: InputMaybe + onboarded?: InputMaybe + profile?: InputMaybe role?: InputMaybe status?: InputMaybe username?: InputMaybe @@ -402,6 +436,7 @@ export type LoginMutation = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -428,6 +463,7 @@ export type RegisterMutation = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -448,6 +484,7 @@ export type MeQuery = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -569,6 +606,7 @@ export type AdminFindManyIdentityQuery = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -738,13 +776,58 @@ export type AnonVerifyIdentityChallengeMutation = { } | null } +export type OnboardingRequirementsDetailsFragment = { + __typename?: 'OnboardingRequirements' + profileAccount?: string | null + socialIdentities?: number | null + solanaIdentities?: number | null + validUsername?: boolean | null + validAvatarUrl?: boolean | null + step: OnboardingStep +} + +export type UserGetOnboardingUsernamesQueryVariables = Exact<{ [key: string]: never }> + +export type UserGetOnboardingUsernamesQuery = { __typename?: 'Query'; usernames?: Array | null } + +export type UserGetOnboardingAvatarUrlsQueryVariables = Exact<{ [key: string]: never }> + +export type UserGetOnboardingAvatarUrlsQuery = { __typename?: 'Query'; avatarUrls?: Array | null } + +export type UserOnboardingRequirementsQueryVariables = Exact<{ [key: string]: never }> + +export type UserOnboardingRequirementsQuery = { + __typename?: 'Query' + item?: { + __typename?: 'OnboardingRequirements' + profileAccount?: string | null + socialIdentities?: number | null + solanaIdentities?: number | null + validUsername?: boolean | null + validAvatarUrl?: boolean | null + step: OnboardingStep + } | null +} + +export type UserOnboardingCreateProfileMutationVariables = Exact<{ + publicKey: Scalars['String']['input'] +}> + +export type UserOnboardingCreateProfileMutation = { __typename?: 'Mutation'; created?: Array | null } + +export type UserOnboardingCustomizeProfileMutationVariables = Exact<{ + username: Scalars['String']['input'] + avatarUrl: Scalars['String']['input'] +}> + +export type UserOnboardingCustomizeProfileMutation = { __typename?: 'Mutation'; updated?: boolean | null } + export type PubkeyProfileDetailsFragment = { __typename?: 'PubkeyProfile' publicKey: string bump: number username: string avatarUrl: string - feePayer: string authorities: Array identities: Array<{ __typename?: 'PubkeyProfileIdentity'; provider: string; providerId: string; name: string }> } @@ -796,7 +879,6 @@ export type GetUserProfileQuery = { bump: number username: string avatarUrl: string - feePayer: string authorities: Array identities: Array<{ __typename?: 'PubkeyProfileIdentity'; provider: string; providerId: string; name: string }> } | null @@ -814,7 +896,6 @@ export type GetUserProfileByUsernameQuery = { bump: number username: string avatarUrl: string - feePayer: string authorities: Array identities: Array<{ __typename?: 'PubkeyProfileIdentity'; provider: string; providerId: string; name: string }> } | null @@ -833,7 +914,6 @@ export type GetUserProfileByProviderQuery = { bump: number username: string avatarUrl: string - feePayer: string authorities: Array identities: Array<{ __typename?: 'PubkeyProfileIdentity'; provider: string; providerId: string; name: string }> } | null @@ -856,6 +936,7 @@ export type UserDetailsFragment = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -877,6 +958,7 @@ export type AdminCreateUserMutation = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -907,6 +989,7 @@ export type AdminFindManyUserQuery = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -953,6 +1036,7 @@ export type AdminFindOneUserQuery = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -976,6 +1060,7 @@ export type AdminUpdateUserMutation = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -1000,6 +1085,7 @@ export type UserFindManyUserQuery = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -1033,6 +1119,7 @@ export type UserFindOneUserQuery = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -1068,6 +1155,7 @@ export type UserUpdateUserMutation = { developer?: boolean | null id: string name?: string | null + onboarded?: boolean | null profile?: string | null profileUrl: string role?: UserRole | null @@ -1129,6 +1217,16 @@ export const IdentityChallengeDetailsFragmentDoc = gql` verified } ` +export const OnboardingRequirementsDetailsFragmentDoc = gql` + fragment OnboardingRequirementsDetails on OnboardingRequirements { + profileAccount + socialIdentities + solanaIdentities + validUsername + validAvatarUrl + step + } +` export const PubkeyProfileIdentityDetailsFragmentDoc = gql` fragment PubkeyProfileIdentityDetails on PubkeyProfileIdentity { provider @@ -1142,7 +1240,6 @@ export const PubkeyProfileDetailsFragmentDoc = gql` bump username avatarUrl - feePayer authorities identities { ...PubkeyProfileIdentityDetails @@ -1157,6 +1254,7 @@ export const UserDetailsFragmentDoc = gql` developer id name + onboarded profile profileUrl role @@ -1289,6 +1387,34 @@ export const AnonVerifyIdentityChallengeDocument = gql` } ${IdentityChallengeDetailsFragmentDoc} ` +export const UserGetOnboardingUsernamesDocument = gql` + query userGetOnboardingUsernames { + usernames: userGetOnboardingUsernames + } +` +export const UserGetOnboardingAvatarUrlsDocument = gql` + query userGetOnboardingAvatarUrls { + avatarUrls: userGetOnboardingAvatarUrls + } +` +export const UserOnboardingRequirementsDocument = gql` + query userOnboardingRequirements { + item: userOnboardingRequirements { + ...OnboardingRequirementsDetails + } + } + ${OnboardingRequirementsDetailsFragmentDoc} +` +export const UserOnboardingCreateProfileDocument = gql` + mutation userOnboardingCreateProfile($publicKey: String!) { + created: userOnboardingCreateProfile(publicKey: $publicKey) + } +` +export const UserOnboardingCustomizeProfileDocument = gql` + mutation userOnboardingCustomizeProfile($username: String!, $avatarUrl: String!) { + updated: userOnboardingCustomizeProfile(username: $username, avatarUrl: $avatarUrl) + } +` export const CreateUserProfileDocument = gql` mutation createUserProfile($publicKey: String!) { created: createUserProfile(publicKey: $publicKey) @@ -1454,6 +1580,11 @@ const UserVerifyIdentityChallengeDocumentString = print(UserVerifyIdentityChalle const UserLinkIdentityDocumentString = print(UserLinkIdentityDocument) const AnonRequestIdentityChallengeDocumentString = print(AnonRequestIdentityChallengeDocument) const AnonVerifyIdentityChallengeDocumentString = print(AnonVerifyIdentityChallengeDocument) +const UserGetOnboardingUsernamesDocumentString = print(UserGetOnboardingUsernamesDocument) +const UserGetOnboardingAvatarUrlsDocumentString = print(UserGetOnboardingAvatarUrlsDocument) +const UserOnboardingRequirementsDocumentString = print(UserOnboardingRequirementsDocument) +const UserOnboardingCreateProfileDocumentString = print(UserOnboardingCreateProfileDocument) +const UserOnboardingCustomizeProfileDocumentString = print(UserOnboardingCustomizeProfileDocument) const CreateUserProfileDocumentString = print(CreateUserProfileDocument) const ProfileIdentityAddDocumentString = print(ProfileIdentityAddDocument) const ProfileIdentityRemoveDocumentString = print(ProfileIdentityRemoveDocument) @@ -1777,6 +1908,112 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = variables, ) }, + userGetOnboardingUsernames( + variables?: UserGetOnboardingUsernamesQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: UserGetOnboardingUsernamesQuery + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(UserGetOnboardingUsernamesDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'userGetOnboardingUsernames', + 'query', + variables, + ) + }, + userGetOnboardingAvatarUrls( + variables?: UserGetOnboardingAvatarUrlsQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: UserGetOnboardingAvatarUrlsQuery + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(UserGetOnboardingAvatarUrlsDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'userGetOnboardingAvatarUrls', + 'query', + variables, + ) + }, + userOnboardingRequirements( + variables?: UserOnboardingRequirementsQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: UserOnboardingRequirementsQuery + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(UserOnboardingRequirementsDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'userOnboardingRequirements', + 'query', + variables, + ) + }, + userOnboardingCreateProfile( + variables: UserOnboardingCreateProfileMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: UserOnboardingCreateProfileMutation + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest(UserOnboardingCreateProfileDocumentString, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'userOnboardingCreateProfile', + 'mutation', + variables, + ) + }, + userOnboardingCustomizeProfile( + variables: UserOnboardingCustomizeProfileMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise<{ + data: UserOnboardingCustomizeProfileMutation + errors?: GraphQLError[] + extensions?: any + headers: Headers + status: number + }> { + return withWrapper( + (wrappedRequestHeaders) => + client.rawRequest( + UserOnboardingCustomizeProfileDocumentString, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + 'userOnboardingCustomizeProfile', + 'mutation', + variables, + ) + }, createUserProfile( variables: CreateUserProfileMutationVariables, requestHeaders?: GraphQLClientRequestHeaders, @@ -2172,6 +2409,8 @@ export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny export const IdentityProviderSchema = z.nativeEnum(IdentityProvider) +export const OnboardingStepSchema = z.nativeEnum(OnboardingStep) + export const UserRoleSchema = z.nativeEnum(UserRole) export const UserStatusSchema = z.nativeEnum(UserStatus) @@ -2257,6 +2496,8 @@ export function UserAdminUpdateInputSchema(): z.ZodObject : +} diff --git a/libs/web/core/data-access/src/lib/sdk-provider.tsx b/libs/web/core/data-access/src/lib/sdk-provider.tsx index 220e270..8cf622a 100644 --- a/libs/web/core/data-access/src/lib/sdk-provider.tsx +++ b/libs/web/core/data-access/src/lib/sdk-provider.tsx @@ -3,9 +3,9 @@ import { createContext, ReactNode, useContext } from 'react' const Context = createContext({} as Sdk) -export function SdkProvider({ children }: { children: ReactNode }) { - const sdk: Sdk = getGraphQLSdk('/graphql') +export const sdk: Sdk = getGraphQLSdk('/graphql') +export function SdkProvider({ children }: { children: ReactNode }) { return {children} } diff --git a/libs/web/core/feature/src/lib/web-core-routes-user.tsx b/libs/web/core/feature/src/lib/web-core-routes-user.tsx index 091c6bc..3c1cf0b 100644 --- a/libs/web/core/feature/src/lib/web-core-routes-user.tsx +++ b/libs/web/core/feature/src/lib/web-core-routes-user.tsx @@ -1,10 +1,11 @@ +import { AuthUiUserOnboardedGuard } from '@pubkey-network/web-auth-ui' import { UiDashboard } from '@pubkey-network/web-core-ui' import { UserDirectoryRoutes, UserProfileRoutes } from '@pubkey-network/web-protocol-feature' import { SettingsFeature } from '@pubkey-network/web-settings-feature' import { SolanaFeature } from '@pubkey-network/web-solana-feature' import { UserFeature } from '@pubkey-network/web-user-feature' import { UiDashboardItem, UiNotFound } from '@pubkey-ui/core' -import { IconCurrencySolana, IconSettings, IconUser, IconUsers } from '@tabler/icons-react' +import { IconCurrencySolana, IconSettings, IconStar, IconUser, IconUsers } from '@tabler/icons-react' import { Navigate, RouteObject, useRoutes } from 'react-router-dom' const links: UiDashboardItem[] = [ @@ -13,6 +14,7 @@ const links: UiDashboardItem[] = [ { label: 'Settings', icon: IconSettings, to: '/settings' }, { label: 'Solana', icon: IconCurrencySolana, to: '/solana' }, { label: 'Users', icon: IconUsers, to: '/u' }, + { label: 'Onboarding', icon: IconStar, to: '/onboarding' }, ] const routes: RouteObject[] = [ @@ -26,9 +28,15 @@ const routes: RouteObject[] = [ export default function WebCoreRoutesUser() { return useRoutes([ - { index: true, element: }, - { path: '/dashboard', element: }, - ...routes, + { + // This guard makes sure that the user is onboarded + element: , + children: [ + { index: true, element: }, + { path: '/dashboard', element: }, + ...routes, + ], + }, { path: '*', element: }, ]) } diff --git a/libs/web/core/feature/src/lib/web-core-routes.tsx b/libs/web/core/feature/src/lib/web-core-routes.tsx index 57250da..c3098af 100644 --- a/libs/web/core/feature/src/lib/web-core-routes.tsx +++ b/libs/web/core/feature/src/lib/web-core-routes.tsx @@ -1,6 +1,7 @@ import { useAuth } from '@pubkey-network/web-auth-data-access' import { AuthLoginFeature, AuthRegisterFeature } from '@pubkey-network/web-auth-feature' import { HOME_ROUTES } from '@pubkey-network/web-home-feature' +import { UserOnboardingFeature } from '@pubkey-network/web-onboarding-feature' import { UiNotFound } from '@pubkey-ui/core' import { lazy } from 'react' import { Navigate } from 'react-router-dom' @@ -25,7 +26,7 @@ export function WebCoreRoutes() { ], full: [ // Here you can add routes that are not part of the main layout, visit /custom-full-page to see this route - // { path: 'custom-full-page', element:
CUSTOM FULL PAGE
}, + { path: '/onboarding/*', element: }, ], public: [ // Routes for the auth feature diff --git a/libs/web/core/ui/src/lib/ui-header-profile.tsx b/libs/web/core/ui/src/lib/ui-header-profile.tsx index 05392d0..d4cd6e6 100644 --- a/libs/web/core/ui/src/lib/ui-header-profile.tsx +++ b/libs/web/core/ui/src/lib/ui-header-profile.tsx @@ -26,7 +26,7 @@ export function UiHeaderProfile({ user, logout }: { user?: User | null; logout: + + + + + + + ) +} diff --git a/libs/web/identity/data-access/src/lib/use-user-find-many-identity.ts b/libs/web/identity/data-access/src/lib/use-user-find-many-identity.ts index 62d1623..d98c79f 100644 --- a/libs/web/identity/data-access/src/lib/use-user-find-many-identity.ts +++ b/libs/web/identity/data-access/src/lib/use-user-find-many-identity.ts @@ -50,7 +50,7 @@ export function useUserFindManyIdentity({ username }: { username: string }) { hasSolana: items.some((x) => x.provider === IdentityProvider.Solana), items, query, - deleteIdentity(identityId: string) { + async deleteIdentity(identityId: string) { if (!window.confirm('Are you sure?')) { return } diff --git a/libs/web/identity/ui/src/index.ts b/libs/web/identity/ui/src/index.ts index f3ef2ff..f435935 100644 --- a/libs/web/identity/ui/src/index.ts +++ b/libs/web/identity/ui/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/get-identity-provider-color' export * from './lib/identity-ui-avatar' export * from './lib/identity-ui-avatar-group' export * from './lib/identity-ui-badge' +export * from './lib/identity-ui-grid' export * from './lib/identity-ui-group-list' export * from './lib/identity-ui-icon' export * from './lib/identity-ui-link' @@ -12,6 +13,7 @@ export * from './lib/identity-ui-list' export * from './lib/identity-ui-login-button' export * from './lib/identity-ui-login-buttons' export * from './lib/identity-ui-provider-button' +export * from './lib/identity-ui-provider-header' export * from './lib/identity-ui-solana-link-button' export * from './lib/identity-ui-solana-link-wizard' export * from './lib/identity-ui-solana-login-button' @@ -21,3 +23,4 @@ export * from './lib/identity-ui-solana-verify-wizard' export * from './lib/identity-ui-solana-wizard' export * from './lib/identity-ui-solana-wizard-modal' export * from './lib/identity-ui-verified' +export { IdentityUiGridItem } from './lib/identity-ui-grid-item' diff --git a/libs/web/identity/ui/src/lib/get-identity-provider-color.tsx b/libs/web/identity/ui/src/lib/get-identity-provider-color.tsx index e5e7a8a..580b9fa 100644 --- a/libs/web/identity/ui/src/lib/get-identity-provider-color.tsx +++ b/libs/web/identity/ui/src/lib/get-identity-provider-color.tsx @@ -13,7 +13,7 @@ export function getIdentityProviderColor(provider: IdentityProvider) { case IdentityProvider.Telegram: return '#0088cc' case IdentityProvider.X: - return '#1DA1F2' + return '#000000' default: return '#333333' } diff --git a/libs/web/identity/ui/src/lib/identity-ui-avatar.tsx b/libs/web/identity/ui/src/lib/identity-ui-avatar.tsx index 0b283ad..6e2f695 100644 --- a/libs/web/identity/ui/src/lib/identity-ui-avatar.tsx +++ b/libs/web/identity/ui/src/lib/identity-ui-avatar.tsx @@ -17,7 +17,7 @@ export function IdentityUiAvatar({ item, withTooltip = false }: { item: Identity ) : item.profile?.avatarUrl ? ( ) : ( - {item.profile?.username.substring(0, 1)} + ) return withTooltip ? ( diff --git a/libs/web/identity/ui/src/lib/identity-ui-grid-item.tsx b/libs/web/identity/ui/src/lib/identity-ui-grid-item.tsx new file mode 100644 index 0000000..393d0ee --- /dev/null +++ b/libs/web/identity/ui/src/lib/identity-ui-grid-item.tsx @@ -0,0 +1,60 @@ +import { ActionIcon, Badge, Code, Group, Text, Tooltip } from '@mantine/core' +import { ellipsify, Identity } from '@pubkey-network/sdk' +import { UiCard, UiDebugModal, UiGroup } from '@pubkey-ui/core' +import { IconTrash } from '@tabler/icons-react' +import { IdentityUiAvatar } from './identity-ui-avatar' +import { IdentityUiLink } from './identity-ui-link' +import { IdentityUiProviderHeader } from './identity-ui-provider-header' +import { IdentityUiSolanaVerifyButton } from './identity-ui-solana-verify-button' +import { IdentityUiVerified } from './identity-ui-verified' + +export function IdentityUiGridItem({ + deleteIdentity, + refresh, + item, +}: { + refresh?: () => void + deleteIdentity?: (id: string) => void + item: Identity +}) { + return ( + + + {deleteIdentity && ( + + deleteIdentity(item.id)}> + + + + )} + + + + + + {item.profile?.username ? ( + + {item.profile?.username} + + ) : ( + {ellipsify(item.providerId)} + )} + {item.verified ? ( + + ) : refresh ? ( + + ) : ( + + Not verified + + )} + + + + + + + + + ) +} diff --git a/libs/web/identity/ui/src/lib/identity-ui-grid.tsx b/libs/web/identity/ui/src/lib/identity-ui-grid.tsx new file mode 100644 index 0000000..26cf425 --- /dev/null +++ b/libs/web/identity/ui/src/lib/identity-ui-grid.tsx @@ -0,0 +1,21 @@ +import { SimpleGrid } from '@mantine/core' +import { Identity } from '@pubkey-network/sdk' +import { IdentityUiGridItem } from './identity-ui-grid-item' + +export function IdentityUiGrid({ + deleteIdentity, + refresh, + items, +}: { + refresh?: () => void + deleteIdentity?: (id: string) => void + items: Identity[] +}) { + return ( + + {items?.map((item) => ( + + ))} + + ) +} diff --git a/libs/web/identity/ui/src/lib/identity-ui-link.tsx b/libs/web/identity/ui/src/lib/identity-ui-link.tsx index 513cff3..06fc15b 100644 --- a/libs/web/identity/ui/src/lib/identity-ui-link.tsx +++ b/libs/web/identity/ui/src/lib/identity-ui-link.tsx @@ -1,11 +1,11 @@ -import { ActionIcon, Tooltip } from '@mantine/core' +import { ActionIcon, ActionIconProps, Tooltip } from '@mantine/core' import { Identity } from '@pubkey-network/sdk' import { IconExternalLink } from '@tabler/icons-react' -export function IdentityUiLink({ item }: { item: Identity }) { +export function IdentityUiLink({ item, ...props }: ActionIconProps & { item: Identity }) { return item.url ? ( - + diff --git a/libs/web/identity/ui/src/lib/identity-ui-list.tsx b/libs/web/identity/ui/src/lib/identity-ui-list.tsx index 17931c6..fa63887 100644 --- a/libs/web/identity/ui/src/lib/identity-ui-list.tsx +++ b/libs/web/identity/ui/src/lib/identity-ui-list.tsx @@ -3,6 +3,7 @@ import { ellipsify, Identity } from '@pubkey-network/sdk' import { UiCard, UiDebugModal, UiGroup, UiStack } from '@pubkey-ui/core' import { IconDotsVertical, IconTrash } from '@tabler/icons-react' import { IdentityUiAvatar } from './identity-ui-avatar' +import { IdentityUiIcon } from './identity-ui-icon' import { IdentityUiLink } from './identity-ui-link' import { IdentityUiSolanaVerifyButton } from './identity-ui-solana-verify-button' import { IdentityUiVerified } from './identity-ui-verified' @@ -11,10 +12,12 @@ export function IdentityUiList({ deleteIdentity, refresh, items, + showProvider = false, }: { refresh?: () => void deleteIdentity?: (id: string) => void items: Identity[] + showProvider?: boolean }) { return ( @@ -22,6 +25,7 @@ export function IdentityUiList({ + {showProvider ? : null} {item.profile?.username ? ( diff --git a/libs/web/identity/ui/src/lib/identity-ui-provider-header.tsx b/libs/web/identity/ui/src/lib/identity-ui-provider-header.tsx new file mode 100644 index 0000000..49a47d9 --- /dev/null +++ b/libs/web/identity/ui/src/lib/identity-ui-provider-header.tsx @@ -0,0 +1,27 @@ +import { Group, Text } from '@mantine/core' +import { IdentityProvider } from '@pubkey-network/sdk' +import { ReactNode } from 'react' +import { getIdentityProviderColor } from './get-identity-provider-color' +import { IdentityUiIcon } from './identity-ui-icon' + +export function IdentityUiProviderHeader({ children, provider }: { children: ReactNode; provider: IdentityProvider }) { + const color = getIdentityProviderColor(provider) + return ( + + + + + {provider} + + + {children} + + ) +} diff --git a/libs/web/onboarding/data-access/.babelrc b/libs/web/onboarding/data-access/.babelrc new file mode 100644 index 0000000..1ea870e --- /dev/null +++ b/libs/web/onboarding/data-access/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/web/onboarding/data-access/.eslintrc.json b/libs/web/onboarding/data-access/.eslintrc.json new file mode 100644 index 0000000..772a43d --- /dev/null +++ b/libs/web/onboarding/data-access/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/web/onboarding/data-access/README.md b/libs/web/onboarding/data-access/README.md new file mode 100644 index 0000000..f50b5d5 --- /dev/null +++ b/libs/web/onboarding/data-access/README.md @@ -0,0 +1,7 @@ +# web-onboarding-data-access + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test web-onboarding-data-access` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/web/onboarding/data-access/project.json b/libs/web/onboarding/data-access/project.json new file mode 100644 index 0000000..e25f30e --- /dev/null +++ b/libs/web/onboarding/data-access/project.json @@ -0,0 +1,12 @@ +{ + "name": "web-onboarding-data-access", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/web/onboarding/data-access/src", + "projectType": "library", + "tags": ["app:web", "type:data-access"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/web/onboarding/data-access/src/index.ts b/libs/web/onboarding/data-access/src/index.ts new file mode 100644 index 0000000..eee167c --- /dev/null +++ b/libs/web/onboarding/data-access/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/use-user-get-onboarding-avatar-urls-query' +export * from './lib/use-user-get-onboarding-username-query' +export * from './lib/use-user-onboarding-create-profile' +export * from './lib/use-user-onboarding-customize-profile' +export * from './lib/use-user-onboarding-requirements' +export * from './lib/user-onboarding-provider' diff --git a/libs/web/onboarding/data-access/src/lib/use-user-get-onboarding-avatar-urls-query.tsx b/libs/web/onboarding/data-access/src/lib/use-user-get-onboarding-avatar-urls-query.tsx new file mode 100644 index 0000000..c1284e1 --- /dev/null +++ b/libs/web/onboarding/data-access/src/lib/use-user-get-onboarding-avatar-urls-query.tsx @@ -0,0 +1,9 @@ +import { sdk } from '@pubkey-network/web-core-data-access' +import { useQuery } from '@tanstack/react-query' + +export function useUserGetOnboardingAvatarUrlsQuery() { + return useQuery({ + queryKey: ['user', 'get-onboarding-avatar-urls'], + queryFn: () => sdk.userGetOnboardingAvatarUrls().then((res) => res.data.avatarUrls), + }) +} diff --git a/libs/web/onboarding/data-access/src/lib/use-user-get-onboarding-username-query.tsx b/libs/web/onboarding/data-access/src/lib/use-user-get-onboarding-username-query.tsx new file mode 100644 index 0000000..4f73bc8 --- /dev/null +++ b/libs/web/onboarding/data-access/src/lib/use-user-get-onboarding-username-query.tsx @@ -0,0 +1,9 @@ +import { sdk } from '@pubkey-network/web-core-data-access' +import { useQuery } from '@tanstack/react-query' + +export function useUserGetOnboardingUsernameQuery() { + return useQuery({ + queryKey: ['user', 'get-onboarding-usernames'], + queryFn: () => sdk.userGetOnboardingUsernames().then((res) => res.data.usernames), + }) +} diff --git a/libs/web/onboarding/data-access/src/lib/use-user-onboarding-create-profile.tsx b/libs/web/onboarding/data-access/src/lib/use-user-onboarding-create-profile.tsx new file mode 100644 index 0000000..b1ed433 --- /dev/null +++ b/libs/web/onboarding/data-access/src/lib/use-user-onboarding-create-profile.tsx @@ -0,0 +1,43 @@ +import { sdk } from '@pubkey-network/web-core-data-access' +import { toastError } from '@pubkey-ui/core' +import { useConnection, useWallet } from '@solana/wallet-adapter-react' +import { VersionedTransaction } from '@solana/web3.js' +import { useMutation } from '@tanstack/react-query' + +export function useUserOnboardingCreateProfile() { + const { publicKey, signTransaction } = useWallet() + const { connection } = useConnection() + + return useMutation({ + mutationFn: async () => { + if (!publicKey) { + toastError(`Connect your wallet to continue`) + throw new Error('No public key') + } + if (!signTransaction) { + toastError(`Connect your wallet to continue (no Sign Transaction)`) + throw new Error('No sign transaction') + } + return sdk.userOnboardingCreateProfile({ publicKey: publicKey.toBase58() }).then(async (res) => { + if (!res.data.created) { + toastError(`Error creating profile`) + throw new Error('Error creating profile') + } + console.log(`res.data.created`, res.data.created) + const unsigned = VersionedTransaction.deserialize(Uint8Array.from(res.data.created)) + + const tx = await signTransaction(unsigned).catch((err) => toastError(err.message)) + + if (!tx) { + toastError(`Error creating profile (singing transaction)`) + return + } + return sdk + .solanaSignAndConfirmTransaction({ + tx: Array.from(tx.serialize()), + }) + .then((res) => res.data.signature ?? '') + }) + }, + }) +} diff --git a/libs/web/onboarding/data-access/src/lib/use-user-onboarding-customize-profile.tsx b/libs/web/onboarding/data-access/src/lib/use-user-onboarding-customize-profile.tsx new file mode 100644 index 0000000..7555c41 --- /dev/null +++ b/libs/web/onboarding/data-access/src/lib/use-user-onboarding-customize-profile.tsx @@ -0,0 +1,9 @@ +import { sdk } from '@pubkey-network/web-core-data-access' +import { useMutation } from '@tanstack/react-query' + +export function useUserOnboardingCustomizeProfile() { + return useMutation({ + mutationFn: ({ avatarUrl, username }: { avatarUrl: string; username: string }) => + sdk.userOnboardingCustomizeProfile({ avatarUrl, username }).then((res) => res.data.updated), + }) +} diff --git a/libs/web/onboarding/data-access/src/lib/use-user-onboarding-requirements.tsx b/libs/web/onboarding/data-access/src/lib/use-user-onboarding-requirements.tsx new file mode 100644 index 0000000..0260ce0 --- /dev/null +++ b/libs/web/onboarding/data-access/src/lib/use-user-onboarding-requirements.tsx @@ -0,0 +1,9 @@ +import { sdk } from '@pubkey-network/web-core-data-access' +import { useQuery } from '@tanstack/react-query' + +export function useUserOnboardingRequirements() { + return useQuery({ + queryKey: ['user', 'onboarding-requirements'], + queryFn: () => sdk.userOnboardingRequirements().then((res) => res.data.item), + }) +} diff --git a/libs/web/onboarding/data-access/src/lib/user-onboarding-provider.tsx b/libs/web/onboarding/data-access/src/lib/user-onboarding-provider.tsx new file mode 100644 index 0000000..e984c30 --- /dev/null +++ b/libs/web/onboarding/data-access/src/lib/user-onboarding-provider.tsx @@ -0,0 +1,84 @@ +import { OnboardingRequirements } from '@pubkey-network/sdk' +import React, { ReactNode, useEffect, useMemo, useState } from 'react' +import { useUserGetOnboardingAvatarUrlsQuery } from './use-user-get-onboarding-avatar-urls-query' +import { useUserGetOnboardingUsernameQuery } from './use-user-get-onboarding-username-query' +import { useUserOnboardingCreateProfile } from './use-user-onboarding-create-profile' +import { useUserOnboardingCustomizeProfile } from './use-user-onboarding-customize-profile' +import { useUserOnboardingRequirements } from './use-user-onboarding-requirements' + +export interface UserOnboardingProviderContext { + avatarUrls: string[] + onboarded: boolean + requirements?: OnboardingRequirements | null + usernames: string[] + username: string + setUsername: (username: string) => void + avatarUrl: string + setAvatarUrl: (avatarUrl: string) => void + refresh: () => Promise + createProfile: () => Promise + updateProfile: () => Promise +} + +const UserOnboardingContext = React.createContext({} as UserOnboardingProviderContext) + +export function UserOnboardingProvider(props: { children: ReactNode }) { + const { children } = props + const avatarUrlsQuery = useUserGetOnboardingAvatarUrlsQuery() + const usernameQuery = useUserGetOnboardingUsernameQuery() + const requirementsQuery = useUserOnboardingRequirements() + const createMutation = useUserOnboardingCreateProfile() + const updateMutation = useUserOnboardingCustomizeProfile() + const avatarUrls = useMemo(() => avatarUrlsQuery.data ?? [], [avatarUrlsQuery.data]) + const usernames = useMemo(() => usernameQuery.data ?? [], [usernameQuery.data]) + + const [username, setUsername] = useState('') + const [avatarUrl, setAvatarUrl] = useState('') + + useEffect(() => { + if (avatarUrls?.length && avatarUrl === '') { + setAvatarUrl(avatarUrls[0]) + } + if (usernames?.length && username === '') { + setUsername(usernames[0]) + } + }, [avatarUrls, usernames, avatarUrl, username]) + + async function refresh() { + await Promise.all([avatarUrlsQuery.refetch(), usernameQuery.refetch(), requirementsQuery.refetch()]) + } + + async function createProfile() { + return createMutation.mutateAsync().then(async (result) => { + await refresh() + return result ?? '' + }) + } + + async function updateProfile() { + return updateMutation.mutateAsync({ avatarUrl, username }).then(async (result) => { + await refresh() + return result ?? false + }) + } + + const value = { + onboarded: false, + avatarUrls: avatarUrlsQuery.data ?? [], + usernames: usernameQuery.data ?? [], + username, + requirements: requirementsQuery.data, + setUsername, + avatarUrl, + setAvatarUrl, + refresh, + createProfile, + updateProfile, + } + + return {children} +} + +export function useUserOnboarding() { + return React.useContext(UserOnboardingContext) +} diff --git a/libs/web/onboarding/data-access/tsconfig.json b/libs/web/onboarding/data-access/tsconfig.json new file mode 100644 index 0000000..d8c59fe --- /dev/null +++ b/libs/web/onboarding/data-access/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../../../tsconfig.base.json" +} diff --git a/libs/web/onboarding/data-access/tsconfig.lib.json b/libs/web/onboarding/data-access/tsconfig.lib.json new file mode 100644 index 0000000..45b2297 --- /dev/null +++ b/libs/web/onboarding/data-access/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/web/onboarding/feature/.babelrc b/libs/web/onboarding/feature/.babelrc new file mode 100644 index 0000000..1ea870e --- /dev/null +++ b/libs/web/onboarding/feature/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/web/onboarding/feature/.eslintrc.json b/libs/web/onboarding/feature/.eslintrc.json new file mode 100644 index 0000000..772a43d --- /dev/null +++ b/libs/web/onboarding/feature/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/web/onboarding/feature/README.md b/libs/web/onboarding/feature/README.md new file mode 100644 index 0000000..b9937d4 --- /dev/null +++ b/libs/web/onboarding/feature/README.md @@ -0,0 +1,7 @@ +# web-onboarding-feature + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test web-onboarding-feature` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/web/onboarding/feature/project.json b/libs/web/onboarding/feature/project.json new file mode 100644 index 0000000..ab53b19 --- /dev/null +++ b/libs/web/onboarding/feature/project.json @@ -0,0 +1,12 @@ +{ + "name": "web-onboarding-feature", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/web/onboarding/feature/src", + "projectType": "library", + "tags": ["app:web", "type:feature"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/web/onboarding/feature/src/index.ts b/libs/web/onboarding/feature/src/index.ts new file mode 100644 index 0000000..cd279e5 --- /dev/null +++ b/libs/web/onboarding/feature/src/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react' + +export const UserOnboardingFeature = lazy(() => import('./lib/user-onboarding.routes')) diff --git a/libs/web/onboarding/feature/src/lib/onboarding-ui-select-username.tsx b/libs/web/onboarding/feature/src/lib/onboarding-ui-select-username.tsx new file mode 100644 index 0000000..e0959fd --- /dev/null +++ b/libs/web/onboarding/feature/src/lib/onboarding-ui-select-username.tsx @@ -0,0 +1,88 @@ +import { Avatar, Combobox, Group, InputBase, Text, useCombobox } from '@mantine/core' +import { useUserOnboarding } from '@pubkey-network/web-onboarding-data-access' +import React, { useEffect } from 'react' + +export function OnboardingUiSelectAvatarUrl({ + avatarUrl, + setAvatarUrl, +}: { + avatarUrl: string + setAvatarUrl: (avatarUrl: string) => void +}) { + const { avatarUrls } = useUserOnboarding() + + useEffect(() => { + if (!avatarUrls?.length) { + return + } + if (!avatarUrl.length) { + setAvatarUrl(avatarUrls[0]) + } + }, [avatarUrls, avatarUrl]) + + return +} + +function SelectOptionUrl({ url }: { url: string }) { + if (!url) { + return null + } + return ( + + + {url?.split('/')[2]} + + ) +} + +export function SelectOptionComponent({ + urls, + url, + setUrl, +}: { + urls: string[] + url: string + setUrl: (url: string) => void +}) { + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + }) + + const options = urls.map((url) => ( + + + + )) + + return ( + { + setUrl(val as string) + combobox.closeDropdown() + }} + > + + } + onClick={() => combobox.toggleDropdown()} + rightSectionPointerEvents="none" + multiline + > + + + + + + {options} + + + ) +} diff --git a/libs/web/onboarding/feature/src/lib/user-onboarding-index.tsx b/libs/web/onboarding/feature/src/lib/user-onboarding-index.tsx new file mode 100644 index 0000000..d8cc327 --- /dev/null +++ b/libs/web/onboarding/feature/src/lib/user-onboarding-index.tsx @@ -0,0 +1,195 @@ +import { Button, Group, Paper, Select, Stack, Text } from '@mantine/core' +import { IdentityProvider } from '@pubkey-network/sdk' +import { useAuth } from '@pubkey-network/web-auth-data-access' +import { useUserFindManyIdentity } from '@pubkey-network/web-identity-data-access' +import { IdentityUiGrid, IdentityUiLinkButton } from '@pubkey-network/web-identity-ui' +import { useUserOnboarding } from '@pubkey-network/web-onboarding-data-access' +import { UiStack } from '@pubkey-ui/core' +import { IconCurrencySolana, IconRocket, IconSocial, IconUserHeart } from '@tabler/icons-react' +import React, { ComponentType } from 'react' +import { OnboardingUiSelectAvatarUrl } from './onboarding-ui-select-username' + +export function UserOnboardingIndex() { + const { user, refresh: authRefresh } = useAuth() + const { deleteIdentity, query, items } = useUserFindManyIdentity({ username: user?.username as string }) + const { + refresh, + usernames, + avatarUrl, + avatarUrls, + username, + setUsername, + setAvatarUrl, + requirements, + createProfile, + updateProfile, + } = useUserOnboarding() + + // Get the Solana identities + const solana = items.filter((item) => item.provider === IdentityProvider.Solana) + // Get the social identities for these providers + const providers = [ + IdentityProvider.Discord, + IdentityProvider.Github, + IdentityProvider.Google, + // IdentityProvider.Telegram, + IdentityProvider.X, + ] + const found = items.filter((item) => providers.includes(item.provider)) + // Get the missing social identities + const missing = providers.filter((provider) => !found.find((item) => item.provider === provider)) + + return ( + + + + + {found.length ? ( + { + await query.refetch() + await refresh() + }} + deleteIdentity={async (id) => { + await deleteIdentity(id) + await refresh() + }} + /> + ) : null} + + + {missing.map((provider) => ( + { + await query.refetch() + await refresh() + }} + provider={provider} + size="sm" + w={210} + /> + ))} + + + + + + {solana?.length ? ( + { + await query.refetch() + await refresh() + }} + deleteIdentity={async (id) => { + await deleteIdentity(id) + await refresh() + }} + /> + ) : null} + + + { + await query.refetch() + await refresh() + }} + provider={IdentityProvider.Solana} + size="sm" + w={210} + /> + + + + + + + +