diff --git a/apps/server/src/api/client/routes/accounts/google-login.ts b/apps/server/src/api/client/routes/accounts/google-login.ts index 391567d8..fa41ad11 100644 --- a/apps/server/src/api/client/routes/accounts/google-login.ts +++ b/apps/server/src/api/client/routes/accounts/google-login.ts @@ -1,10 +1,11 @@ +import { PutObjectCommand } from '@aws-sdk/client-s3'; import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod'; import ky from 'ky'; +import sharp from 'sharp'; import { AccountStatus, generateId, - GoogleUserInfo, IdType, ApiErrorCode, apiErrorOutputSchema, @@ -12,10 +13,120 @@ import { googleLoginInputSchema, } from '@colanode/core'; import { database } from '@colanode/server/data/database'; -import { buildLoginSuccessOutput } from '@colanode/server/lib/accounts'; +import { UpdateAccount } from '@colanode/server/data/schema'; +import { s3Client } from '@colanode/server/data/storage'; +import { + buildLoginSuccessOutput, + buildLoginVerifyOutput, +} from '@colanode/server/lib/accounts'; import { config } from '@colanode/server/lib/config'; +import { AccountAttributes } from '@colanode/server/types/accounts'; const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo'; +const GoogleTokenUrl = 'https://oauth2.googleapis.com/token'; + +// While implementing this I was getting significant latencies from Google responses +// and I thought that was normal, therefore I set the timeout to 10 seconds. +// Later that day, I realized that Google was experiencing a large outage (https://status.cloud.google.com/incidents/ow5i3PPK96RduMcb1SsW) +// and now I decided to keep it at 10 seconds as a memory. +const GoogleRequestTimeout = 1000 * 10; + +interface GoogleTokenResponse { + access_token: string; + expires_in: number; + scope: string; + token_type: string; + id_token?: string; + refresh_token?: string; +} + +interface GoogleUserResponse { + id: string; + email: string; + name: string; + verified_email: boolean; + picture?: string | null; +} + +const fetchGoogleToken = async ( + code: string, + clientId: string, + clientSecret: string +): Promise => { + try { + const params = new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: 'postmessage', + grant_type: 'authorization_code', + }); + + const token = await ky + .post(GoogleTokenUrl, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + timeout: GoogleRequestTimeout, + }) + .json(); + + return token; + } catch { + return null; + } +}; + +const fetchGoogleUser = async ( + accessToken: string +): Promise => { + try { + const user = await ky + .get(GoogleUserInfoUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + timeout: GoogleRequestTimeout, + }) + .json(); + + return user; + } catch { + return null; + } +}; + +const uploadGooglePictureAsAvatar = async ( + pictureUrl: string +): Promise => { + try { + const arrayBuffer = await ky + .get(pictureUrl, { timeout: GoogleRequestTimeout }) + .arrayBuffer(); + + const originalBuffer = Buffer.from(arrayBuffer); + + const jpegBuffer = await sharp(originalBuffer) + .resize({ width: 500, height: 500, fit: 'inside' }) + .jpeg() + .toBuffer(); + + const avatarId = generateId(IdType.Avatar); + const command = new PutObjectCommand({ + Bucket: config.storage.bucket, + Key: `avatars/${avatarId}.jpeg`, + Body: jpegBuffer, + ContentType: 'image/jpeg', + }); + + await s3Client.send(command); + + return avatarId; + } catch { + return null; + } +}; export const googleLoginRoute: FastifyPluginCallbackZod = ( instance, @@ -34,7 +145,7 @@ export const googleLoginRoute: FastifyPluginCallbackZod = ( }, }, handler: async (request, reply) => { - if (!config.account.allowGoogleLogin) { + if (!config.account.google.enabled) { return reply.code(400).send({ code: ApiErrorCode.GoogleAuthFailed, message: 'Google login is not allowed.', @@ -42,52 +153,115 @@ export const googleLoginRoute: FastifyPluginCallbackZod = ( } const input = request.body; - const url = `${GoogleUserInfoUrl}?access_token=${input.access_token}`; - const response = await ky.get(url).json(); - if (!response) { + const token = await fetchGoogleToken( + input.code, + config.account.google.clientId, + config.account.google.clientSecret + ); + + if (!token?.access_token) { + return reply.code(400).send({ + code: ApiErrorCode.GoogleAuthFailed, + message: 'Google access token not found.', + }); + } + + const googleUser = await fetchGoogleUser(token.access_token); + if (!googleUser) { return reply.code(400).send({ code: ApiErrorCode.GoogleAuthFailed, message: 'Failed to authenticate with Google.', }); } - const existingAccount = await database + let existingAccount = await database .selectFrom('accounts') - .where('email', '=', response.email) + .where('email', '=', googleUser.email) .selectAll() .executeTakeFirst(); if (existingAccount) { - if (existingAccount.status !== AccountStatus.Active) { - await database + const existingGoogleId = existingAccount.attributes?.googleId; + if (existingGoogleId && existingGoogleId !== googleUser.id) { + return reply.code(400).send({ + code: ApiErrorCode.GoogleAuthFailed, + message: 'Google account already exists.', + }); + } + + const updateAccount: UpdateAccount = {}; + + if (existingGoogleId !== googleUser.id) { + const newAttributes: AccountAttributes = { + ...existingAccount.attributes, + googleId: googleUser.id, + }; + + updateAccount.attributes = JSON.stringify(newAttributes); + } + + if ( + existingAccount.status !== AccountStatus.Active && + googleUser.verified_email + ) { + updateAccount.status = AccountStatus.Active; + } + + if (!existingAccount.avatar && googleUser.picture) { + updateAccount.avatar = await uploadGooglePictureAsAvatar( + googleUser.picture + ); + } + + if (Object.keys(updateAccount).length > 0) { + updateAccount.updated_at = new Date(); + existingAccount = await database .updateTable('accounts') - .set({ - attrs: JSON.stringify({ googleId: response.id }), - updated_at: new Date(), - status: AccountStatus.Active, - }) + .returningAll() + .set(updateAccount) .where('id', '=', existingAccount.id) - .execute(); + .executeTakeFirst(); + } + + if (!existingAccount) { + return reply.code(400).send({ + code: ApiErrorCode.GoogleAuthFailed, + message: 'Google account not found.', + }); } const output = await buildLoginSuccessOutput( existingAccount, request.client ); + return output; } + let avatar: string | null = null; + if (googleUser.picture) { + avatar = await uploadGooglePictureAsAvatar(googleUser.picture); + } + + let status = AccountStatus.Unverified; + if (googleUser.verified_email) { + status = AccountStatus.Active; + } else if (config.account.verificationType === 'automatic') { + status = AccountStatus.Active; + } + const newAccount = await database .insertInto('accounts') .values({ id: generateId(IdType.Account), - name: response.name, - email: response.email, - status: AccountStatus.Active, + name: googleUser.name, + email: googleUser.email, + avatar, + status, created_at: new Date(), password: null, - attrs: JSON.stringify({ googleId: response.id }), + attributes: JSON.stringify({ googleId: googleUser.id }), }) .returningAll() .executeTakeFirst(); @@ -99,6 +273,19 @@ export const googleLoginRoute: FastifyPluginCallbackZod = ( }); } + if (newAccount.status === AccountStatus.Unverified) { + if (config.account.verificationType === 'email') { + const output = await buildLoginVerifyOutput(newAccount); + return output; + } + + return reply.code(400).send({ + code: ApiErrorCode.AccountPendingVerification, + message: + 'Account is not verified yet. Contact your administrator to verify your account.', + }); + } + const output = await buildLoginSuccessOutput(newAccount, request.client); return output; }, diff --git a/apps/server/src/api/client/routes/workspaces/users/users-create.ts b/apps/server/src/api/client/routes/workspaces/users/users-create.ts index 846d26c0..e4da8f6a 100644 --- a/apps/server/src/api/client/routes/workspaces/users/users-create.ts +++ b/apps/server/src/api/client/routes/workspaces/users/users-create.ts @@ -174,7 +174,7 @@ const getOrCreateAccount = async ( name: getNameFromEmail(email), email: email, avatar: null, - attrs: null, + attributes: null, password: null, status: AccountStatus.Pending, created_at: new Date(), diff --git a/apps/server/src/api/config.ts b/apps/server/src/api/config.ts index 1cd903c0..f15cd518 100644 --- a/apps/server/src/api/config.ts +++ b/apps/server/src/api/config.ts @@ -20,6 +20,16 @@ export const configGetRoute: FastifyPluginCallbackZod = (instance, _, done) => { sha: config.server.sha, ip: request.client.ip, pathPrefix: config.server.pathPrefix, + account: { + google: config.account.google.enabled + ? { + enabled: config.account.google.enabled, + clientId: config.account.google.clientId, + } + : { + enabled: false, + }, + }, }; return output; diff --git a/apps/server/src/data/migrations/00021-rename-account-attributes-column.ts b/apps/server/src/data/migrations/00021-rename-account-attributes-column.ts new file mode 100644 index 00000000..dba61f78 --- /dev/null +++ b/apps/server/src/data/migrations/00021-rename-account-attributes-column.ts @@ -0,0 +1,17 @@ +import { Migration } from 'kysely'; + +// This migration is being done for the sake of consistency. We use the full name 'attributes' in other tables, +export const renameAccountAttributesColumn: Migration = { + up: async (db) => { + await db.schema + .alterTable('accounts') + .renameColumn('attrs', 'attributes') + .execute(); + }, + down: async (db) => { + await db.schema + .alterTable('accounts') + .renameColumn('attributes', 'attrs') + .execute(); + }, +}; diff --git a/apps/server/src/data/migrations/index.ts b/apps/server/src/data/migrations/index.ts index d48a54ce..04823b85 100644 --- a/apps/server/src/data/migrations/index.ts +++ b/apps/server/src/data/migrations/index.ts @@ -20,6 +20,7 @@ import { createVectorExtension } from './00017-create-vector-extension'; import { createNodeEmbeddingsTable } from './00018-create-node-embeddings-table'; import { createDocumentEmbeddingsTable } from './00019-create-document-embeddings-table'; import { alterDevicesPlatformColumn } from './00020-alter-devices-platform-column'; +import { renameAccountAttributesColumn } from './00021-rename-account-attributes-column'; export const databaseMigrations: Record = { '00001_create_accounts_table': createAccountsTable, @@ -42,4 +43,5 @@ export const databaseMigrations: Record = { '00018_create_node_embeddings_table': createNodeEmbeddingsTable, '00019_create_document_embeddings_table': createDocumentEmbeddingsTable, '00020_alter_devices_platform_column': alterDevicesPlatformColumn, + '00021_rename_account_attributes_column': renameAccountAttributesColumn, }; diff --git a/apps/server/src/data/schema.ts b/apps/server/src/data/schema.ts index fac53866..172ac76d 100644 --- a/apps/server/src/data/schema.ts +++ b/apps/server/src/data/schema.ts @@ -16,6 +16,7 @@ import { DocumentContent, UpdateMergeMetadata, } from '@colanode/core'; +import { AccountAttributes } from '@colanode/server/types/accounts'; interface AccountTable { id: ColumnType; @@ -23,7 +24,11 @@ interface AccountTable { email: ColumnType; avatar: ColumnType; password: ColumnType; - attrs: ColumnType; + attributes: JSONColumnType< + AccountAttributes | null, + string | null, + string | null + >; created_at: ColumnType; updated_at: ColumnType; status: ColumnType; diff --git a/apps/server/src/lib/config/account.ts b/apps/server/src/lib/config/account.ts index d2ed077f..26f0e78f 100644 --- a/apps/server/src/lib/config/account.ts +++ b/apps/server/src/lib/config/account.ts @@ -5,14 +5,32 @@ export const accountVerificationTypeSchema = z.enum([ 'manual', 'email', ]); + export type AccountVerificationType = z.infer< typeof accountVerificationTypeSchema >; +export const googleConfigSchema = z.discriminatedUnion('enabled', [ + z.object({ + enabled: z.literal(true), + clientId: z.string({ + error: 'Google client ID is required when Google login is enabled.', + }), + clientSecret: z.string({ + error: 'Google client secret is required when Google login is enabled.', + }), + }), + z.object({ + enabled: z.literal(false), + }), +]); + export const accountConfigSchema = z.object({ verificationType: accountVerificationTypeSchema.default('manual'), otpTimeout: z.coerce.number().default(600), - allowGoogleLogin: z.boolean().default(false), + google: googleConfigSchema.default({ + enabled: false, + }), }); export type AccountConfig = z.infer; @@ -21,6 +39,10 @@ export const readAccountConfigVariables = () => { return { verificationType: process.env.ACCOUNT_VERIFICATION_TYPE, otpTimeout: process.env.ACCOUNT_OTP_TIMEOUT, - allowGoogleLogin: process.env.ACCOUNT_ALLOW_GOOGLE_LOGIN === 'true', + google: { + enabled: process.env.ACCOUNT_GOOGLE_ENABLED === 'true', + clientId: process.env.ACCOUNT_GOOGLE_CLIENT_ID, + clientSecret: process.env.ACCOUNT_GOOGLE_CLIENT_SECRET, + }, }; }; diff --git a/apps/server/src/types/accounts.ts b/apps/server/src/types/accounts.ts new file mode 100644 index 00000000..f83fbcdf --- /dev/null +++ b/apps/server/src/types/accounts.ts @@ -0,0 +1,3 @@ +export type AccountAttributes = { + googleId?: string | null; +}; diff --git a/docker-compose.yaml b/docker-compose.yaml index 6ab191fc..6d8f026c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -107,7 +107,11 @@ services: # Possible values for ACCOUNT_VERIFICATION_TYPE: 'automatic', 'manual', 'email' ACCOUNT_VERIFICATION_TYPE: 'automatic' ACCOUNT_OTP_TIMEOUT: '600' # in seconds - ACCOUNT_ALLOW_GOOGLE_LOGIN: 'false' + + # If you want to enable Google login, you need to set the following variables: + # ACCOUNT_GOOGLE_ENABLED: 'true' + # ACCOUNT_GOOGLE_CLIENT_ID: 'your_google_client_id' + # ACCOUNT_GOOGLE_CLIENT_SECRET: 'your_google_client_secret' # ─────────────────────────────────────────────────────────────── # User Configuration diff --git a/package-lock.json b/package-lock.json index d7dd1f57..98e692bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7253,6 +7253,16 @@ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", "license": "MIT" }, + "node_modules/@react-oauth/google": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz", + "integrity": "sha512-d1GVm2uD4E44EJft2RbKtp8Z1fp/gK8Lb6KHgs3pHlM0PxCXGLaq8LLYQYENnN4xPWO1gkL4apBtlPKzpLvZwg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@redis/bloom": { "version": "5.5.6", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.5.6.tgz", @@ -25379,6 +25389,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@react-oauth/google": "^0.12.2", "@tanstack/react-query": "^5.80.6", "@tanstack/react-virtual": "^3.13.10", "@tiptap/extension-blockquote": "^2.14.0", diff --git a/packages/client/src/handlers/mutations/accounts/google-login.ts b/packages/client/src/handlers/mutations/accounts/google-login.ts new file mode 100644 index 00000000..2586f76c --- /dev/null +++ b/packages/client/src/handlers/mutations/accounts/google-login.ts @@ -0,0 +1,53 @@ +import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base'; +import { parseApiError } from '@colanode/client/lib/ky'; +import { MutationHandler } from '@colanode/client/lib/types'; +import { + GoogleLoginMutationInput, + MutationError, + MutationErrorCode, +} from '@colanode/client/mutations'; +import { AppService } from '@colanode/client/services/app-service'; +import { GoogleLoginInput, LoginOutput } from '@colanode/core'; + +export class GoogleLoginMutationHandler + extends AccountMutationHandlerBase + implements MutationHandler +{ + constructor(appService: AppService) { + super(appService); + } + + async handleMutation(input: GoogleLoginMutationInput): Promise { + const server = this.app.getServer(input.server); + + if (!server) { + throw new MutationError( + MutationErrorCode.ServerNotFound, + `Server ${input.server} was not found! Try using a different server.` + ); + } + + try { + const body: GoogleLoginInput = { + code: input.code, + }; + + const response = await this.app.client + .post(`${server.httpBaseUrl}/v1/accounts/google/login`, { + json: body, + }) + .json(); + + if (response.type === 'verify') { + return response; + } + + await this.handleLoginSuccess(response, server); + + return response; + } catch (error) { + const apiError = await parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); + } + } +} diff --git a/packages/client/src/handlers/mutations/index.ts b/packages/client/src/handlers/mutations/index.ts index 29cb59ba..b79ff26f 100644 --- a/packages/client/src/handlers/mutations/index.ts +++ b/packages/client/src/handlers/mutations/index.ts @@ -11,6 +11,7 @@ import { EmailPasswordResetCompleteMutationHandler } from './accounts/email-pass import { EmailPasswordResetInitMutationHandler } from './accounts/email-password-reset-init'; import { EmailRegisterMutationHandler } from './accounts/email-register'; import { EmailVerifyMutationHandler } from './accounts/email-verify'; +import { GoogleLoginMutationHandler } from './accounts/google-login'; import { AppMetadataDeleteMutationHandler } from './apps/app-metadata-delete'; import { AppMetadataUpdateMutationHandler } from './apps/app-metadata-update'; import { AvatarUploadMutationHandler } from './avatars/avatar-upload'; @@ -83,6 +84,7 @@ export const buildMutationHandlerMap = ( 'email.login': new EmailLoginMutationHandler(app), 'email.register': new EmailRegisterMutationHandler(app), 'email.verify': new EmailVerifyMutationHandler(app), + 'google.login': new GoogleLoginMutationHandler(app), 'view.create': new ViewCreateMutationHandler(app), 'channel.create': new ChannelCreateMutationHandler(app), 'channel.delete': new ChannelDeleteMutationHandler(app), diff --git a/packages/client/src/mutations/accounts/google-login.ts b/packages/client/src/mutations/accounts/google-login.ts new file mode 100644 index 00000000..d21f923e --- /dev/null +++ b/packages/client/src/mutations/accounts/google-login.ts @@ -0,0 +1,16 @@ +import { LoginOutput } from '@colanode/core'; + +export type GoogleLoginMutationInput = { + type: 'google.login'; + server: string; + code: string; +}; + +declare module '@colanode/client/mutations' { + interface MutationMap { + 'google.login': { + input: GoogleLoginMutationInput; + output: LoginOutput; + }; + } +} diff --git a/packages/client/src/mutations/index.ts b/packages/client/src/mutations/index.ts index e06b5e2e..65d4c53d 100644 --- a/packages/client/src/mutations/index.ts +++ b/packages/client/src/mutations/index.ts @@ -7,6 +7,7 @@ export * from './accounts/email-password-reset-complete'; export * from './accounts/email-password-reset-init'; export * from './accounts/email-register'; export * from './accounts/email-verify'; +export * from './accounts/google-login'; export * from './apps/app-metadata-delete'; export * from './apps/app-metadata-update'; export * from './avatars/avatar-upload'; diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts index f145b9d0..9a86d31d 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -193,6 +193,14 @@ export class AppService { const attributes: ServerAttributes = { pathPrefix: config.pathPrefix, insecure: url.protocol === 'http:', + account: config.account?.google.enabled + ? { + google: { + enabled: config.account.google.enabled, + clientId: config.account.google.clientId, + }, + } + : undefined, }; const createdServer = await this.database diff --git a/packages/client/src/services/server-service.ts b/packages/client/src/services/server-service.ts index ff84395a..5aceed4a 100644 --- a/packages/client/src/services/server-service.ts +++ b/packages/client/src/services/server-service.ts @@ -6,7 +6,7 @@ import { EventLoop } from '@colanode/client/lib/event-loop'; import { mapServer } from '@colanode/client/lib/mappers'; import { isServerOutdated } from '@colanode/client/lib/servers'; import { AppService } from '@colanode/client/services/app-service'; -import { Server } from '@colanode/client/types/servers'; +import { Server, ServerAttributes } from '@colanode/client/types/servers'; import { createDebugger, ServerConfig } from '@colanode/core'; type ServerState = { @@ -86,6 +86,18 @@ export class ServerService { ); if (config) { + const attributes: ServerAttributes = { + ...this.server.attributes, + account: config.account?.google.enabled + ? { + google: { + enabled: config.account.google.enabled, + clientId: config.account.google.clientId, + }, + } + : undefined, + }; + const updatedServer = await this.app.database .updateTable('servers') .returningAll() @@ -94,6 +106,7 @@ export class ServerService { avatar: config.avatar, name: config.name, version: config.version, + attributes: JSON.stringify(attributes), }) .where('domain', '=', this.server.domain) .executeTakeFirst(); @@ -101,6 +114,7 @@ export class ServerService { this.server.avatar = config.avatar; this.server.name = config.name; this.server.version = config.version; + this.server.attributes = attributes; this.isOutdated = isServerOutdated(config.version); if (updatedServer) { diff --git a/packages/client/src/types/servers.ts b/packages/client/src/types/servers.ts index 8959d3a6..4cebad7f 100644 --- a/packages/client/src/types/servers.ts +++ b/packages/client/src/types/servers.ts @@ -1,6 +1,14 @@ +export type ServerAccountAttributes = { + google: { + enabled: boolean; + clientId: string; + }; +}; + export type ServerAttributes = { pathPrefix?: string | null; insecure?: boolean; + account?: ServerAccountAttributes; }; export type Server = { diff --git a/packages/core/src/types/accounts.ts b/packages/core/src/types/accounts.ts index b61a590b..e7d868f5 100644 --- a/packages/core/src/types/accounts.ts +++ b/packages/core/src/types/accounts.ts @@ -127,18 +127,7 @@ export type EmailPasswordResetCompleteOutput = z.infer< >; export const googleLoginInputSchema = z.object({ - access_token: z.string(), - token_type: z.string(), - expires_in: z.number(), + code: z.string(), }); export type GoogleLoginInput = z.infer; - -export const googleUserInfoSchema = z.object({ - id: z.string(), - email: z.string().email(), - name: z.string(), - picture: z.string(), -}); - -export type GoogleUserInfo = z.infer; diff --git a/packages/core/src/types/servers.ts b/packages/core/src/types/servers.ts index 932b8139..7a9702d6 100644 --- a/packages/core/src/types/servers.ts +++ b/packages/core/src/types/servers.ts @@ -1,5 +1,19 @@ import { z } from 'zod/v4'; +export const serverGoogleConfigSchema = z.discriminatedUnion('enabled', [ + z.object({ + enabled: z.literal(true), + clientId: z.string(), + }), + z.object({ + enabled: z.literal(false), + }), +]); + +export const serverAccountConfigSchema = z.object({ + google: serverGoogleConfigSchema, +}); + export const serverConfigSchema = z.object({ name: z.string(), avatar: z.string(), @@ -7,6 +21,7 @@ export const serverConfigSchema = z.object({ sha: z.string(), ip: z.string().nullable().optional(), pathPrefix: z.string().nullable().optional(), + account: serverAccountConfigSchema.nullable().optional(), }); export type ServerConfig = z.infer; diff --git a/packages/ui/package.json b/packages/ui/package.json index 4d487d45..b9a4faaa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@react-oauth/google": "^0.12.2", "@tanstack/react-query": "^5.80.6", "@tanstack/react-virtual": "^3.13.10", "@tiptap/extension-blockquote": "^2.14.0", diff --git a/packages/ui/src/components/accounts/email-login.tsx b/packages/ui/src/components/accounts/email-login.tsx index 1e2c9e38..5c0a13f8 100644 --- a/packages/ui/src/components/accounts/email-login.tsx +++ b/packages/ui/src/components/accounts/email-login.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { z } from 'zod/v4'; import { LoginOutput } from '@colanode/core'; +import { GoogleLogin } from '@colanode/ui/components/accounts/google-login'; import { Button } from '@colanode/ui/components/ui/button'; import { Form, @@ -14,7 +15,9 @@ import { FormMessage, } from '@colanode/ui/components/ui/form'; import { Input } from '@colanode/ui/components/ui/input'; +import { Label } from '@colanode/ui/components/ui/label'; import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useServer } from '@colanode/ui/contexts/server'; import { useMutation } from '@colanode/ui/hooks/use-mutation'; const formSchema = z.object({ @@ -23,17 +26,19 @@ const formSchema = z.object({ }); interface EmailLoginProps { - server: string; onSuccess: (output: LoginOutput) => void; onForgotPassword: () => void; + onRegister: () => void; } export const EmailLogin = ({ - server, onSuccess, onForgotPassword, + onRegister, }: EmailLoginProps) => { + const server = useServer(); const { mutate, isPending } = useMutation(); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -48,7 +53,7 @@ export const EmailLogin = ({ type: 'email.login', email: values.email, password: values.password, - server, + server: server.domain, }, onSuccess(output) { onSuccess(output); @@ -61,14 +66,19 @@ export const EmailLogin = ({ return (
- + ( + - + @@ -79,10 +89,18 @@ export const EmailLogin = ({ name="password" render={({ field }) => ( +
+ +

+ Forgot password? +

+
@@ -91,12 +109,6 @@ export const EmailLogin = ({
)} /> -

- Forgot password? -

+ + ); diff --git a/packages/ui/src/components/accounts/email-password-reset-complete.tsx b/packages/ui/src/components/accounts/email-password-reset-complete.tsx index a35ac5e2..b3a4f036 100644 --- a/packages/ui/src/components/accounts/email-password-reset-complete.tsx +++ b/packages/ui/src/components/accounts/email-password-reset-complete.tsx @@ -14,7 +14,9 @@ import { FormMessage, } from '@colanode/ui/components/ui/form'; import { Input } from '@colanode/ui/components/ui/input'; +import { Label } from '@colanode/ui/components/ui/label'; import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useServer } from '@colanode/ui/contexts/server'; import { useCountdown } from '@colanode/ui/hooks/use-countdown'; import { useMutation } from '@colanode/ui/hooks/use-mutation'; @@ -38,17 +40,19 @@ const formSchema = z }); interface EmailPasswordResetCompleteProps { - server: string; id: string; expiresAt: Date; + onBack: () => void; } export const EmailPasswordResetComplete = ({ - server, id, expiresAt, + onBack, }: EmailPasswordResetCompleteProps) => { + const server = useServer(); const { mutate, isPending } = useMutation(); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -72,7 +76,7 @@ export const EmailPasswordResetComplete = ({ type: 'email.password.reset.complete', otp: values.otp, password: values.password, - server, + server: server.domain, id: id, }, onSuccess() { @@ -107,13 +111,9 @@ export const EmailPasswordResetComplete = ({ name="password" render={({ field }) => ( + - + @@ -124,13 +124,9 @@ export const EmailPasswordResetComplete = ({ name="confirmPassword" render={({ field }) => ( + - + @@ -141,8 +137,9 @@ export const EmailPasswordResetComplete = ({ name="otp" render={({ field }) => ( + - +

@@ -158,12 +155,20 @@ export const EmailPasswordResetComplete = ({ disabled={isPending} > {isPending ? ( - + ) : ( - + )} Reset password + ); diff --git a/packages/ui/src/components/accounts/email-password-reset-init.tsx b/packages/ui/src/components/accounts/email-password-reset-init.tsx index 7efd1abe..b74b7056 100644 --- a/packages/ui/src/components/accounts/email-password-reset-init.tsx +++ b/packages/ui/src/components/accounts/email-password-reset-init.tsx @@ -14,7 +14,9 @@ import { FormMessage, } from '@colanode/ui/components/ui/form'; import { Input } from '@colanode/ui/components/ui/input'; +import { Label } from '@colanode/ui/components/ui/label'; import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useServer } from '@colanode/ui/contexts/server'; import { useMutation } from '@colanode/ui/hooks/use-mutation'; const formSchema = z.object({ @@ -22,15 +24,17 @@ const formSchema = z.object({ }); interface EmailPasswordResetInitProps { - server: string; onSuccess: (output: EmailPasswordResetInitOutput) => void; + onBack: () => void; } export const EmailPasswordResetInit = ({ - server, onSuccess, + onBack, }: EmailPasswordResetInitProps) => { + const server = useServer(); const { mutate, isPending } = useMutation(); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -43,7 +47,7 @@ export const EmailPasswordResetInit = ({ input: { type: 'email.password.reset.init', email: values.email, - server, + server: server.domain, }, onSuccess(output) { onSuccess(output); @@ -62,8 +66,9 @@ export const EmailPasswordResetInit = ({ name="email" render={({ field }) => ( + - + @@ -76,12 +81,20 @@ export const EmailPasswordResetInit = ({ disabled={isPending} > {isPending ? ( - + ) : ( - + )} Reset password + ); diff --git a/packages/ui/src/components/accounts/email-register.tsx b/packages/ui/src/components/accounts/email-register.tsx index e3b921f6..6be6ebaf 100644 --- a/packages/ui/src/components/accounts/email-register.tsx +++ b/packages/ui/src/components/accounts/email-register.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { z } from 'zod/v4'; import { LoginOutput } from '@colanode/core'; +import { GoogleLogin } from '@colanode/ui/components/accounts/google-login'; import { Button } from '@colanode/ui/components/ui/button'; import { Form, @@ -14,7 +15,9 @@ import { FormMessage, } from '@colanode/ui/components/ui/form'; import { Input } from '@colanode/ui/components/ui/input'; +import { Label } from '@colanode/ui/components/ui/label'; import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useServer } from '@colanode/ui/contexts/server'; import { useMutation } from '@colanode/ui/hooks/use-mutation'; const formSchema = z @@ -38,12 +41,14 @@ const formSchema = z }); interface EmailRegisterProps { - server: string; onSuccess: (output: LoginOutput) => void; + onLogin: () => void; } -export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { +export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => { + const server = useServer(); const { mutate, isPending } = useMutation(); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -61,7 +66,7 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { name: values.name, email: values.email, password: values.password, - server, + server: server.domain, }, onSuccess(output) { onSuccess(output); @@ -74,14 +79,15 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { return (

- + ( + - + @@ -92,8 +98,13 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { name="email" render={({ field }) => ( + - + @@ -104,13 +115,9 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { name="password" render={({ field }) => ( + - + @@ -121,13 +128,9 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { name="confirmPassword" render={({ field }) => ( + - + @@ -140,12 +143,21 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { disabled={isPending} > {isPending ? ( - + ) : ( - + )} Register + + ); diff --git a/packages/ui/src/components/accounts/email-verify.tsx b/packages/ui/src/components/accounts/email-verify.tsx index 49e3a50c..778e25a6 100644 --- a/packages/ui/src/components/accounts/email-verify.tsx +++ b/packages/ui/src/components/accounts/email-verify.tsx @@ -14,7 +14,9 @@ import { FormMessage, } from '@colanode/ui/components/ui/form'; import { Input } from '@colanode/ui/components/ui/input'; +import { Label } from '@colanode/ui/components/ui/label'; import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useServer } from '@colanode/ui/contexts/server'; import { useCountdown } from '@colanode/ui/hooks/use-countdown'; import { useMutation } from '@colanode/ui/hooks/use-mutation'; @@ -23,19 +25,21 @@ const formSchema = z.object({ }); interface EmailVerifyProps { - server: string; id: string; expiresAt: Date; onSuccess: (output: LoginOutput) => void; + onBack: () => void; } export const EmailVerify = ({ - server, id, expiresAt, onSuccess, + onBack, }: EmailVerifyProps) => { + const server = useServer(); const { mutate, isPending } = useMutation(); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -55,7 +59,7 @@ export const EmailVerify = ({ input: { type: 'email.verify', otp: values.otp, - server, + server: server.domain, id, }, onSuccess(output) { @@ -69,22 +73,25 @@ export const EmailVerify = ({ return (
- + ( - -

- Write the code you received in your email -

+ + - + -

- {formattedTime} -

+
+

+ We sent a verification code to your email. +

+

+ {formattedTime} +

+
)} /> @@ -95,12 +102,20 @@ export const EmailVerify = ({ disabled={isPending || remainingSeconds <= 0} > {isPending ? ( - + ) : ( - + )} Confirm + ); diff --git a/packages/ui/src/components/accounts/google-login.tsx b/packages/ui/src/components/accounts/google-login.tsx new file mode 100644 index 00000000..ce95a8cb --- /dev/null +++ b/packages/ui/src/components/accounts/google-login.tsx @@ -0,0 +1,73 @@ +import { GoogleOAuthProvider, useGoogleLogin } from '@react-oauth/google'; +import { toast } from 'sonner'; + +import { LoginOutput } from '@colanode/core'; +import { Button } from '@colanode/ui/components/ui/button'; +import { GoogleIcon } from '@colanode/ui/components/ui/icons'; +import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useApp } from '@colanode/ui/contexts/app'; +import { useServer } from '@colanode/ui/contexts/server'; +import { useMutation } from '@colanode/ui/hooks/use-mutation'; + +interface GoogleLoginProps { + context: 'login' | 'register'; + onSuccess: (output: LoginOutput) => void; +} + +const GoogleLoginButton = ({ context, onSuccess }: GoogleLoginProps) => { + const server = useServer(); + const { mutate, isPending } = useMutation(); + + const login = useGoogleLogin({ + onSuccess: async (response) => { + console.log('response', response); + mutate({ + input: { + type: 'google.login', + code: response.code, + server: server.domain, + }, + onSuccess(output) { + onSuccess(output); + }, + onError(error) { + toast.error(error.message); + }, + }); + }, + flow: 'auth-code', + }); + + return ( + + ); +}; + +export const GoogleLogin = ({ context, onSuccess }: GoogleLoginProps) => { + const app = useApp(); + const server = useServer(); + const config = server.attributes.account?.google; + + if (app.type === 'web' && config && config.enabled && config.clientId) { + return ( + + + + ); + } + + return null; +}; diff --git a/packages/ui/src/components/accounts/login-form.tsx b/packages/ui/src/components/accounts/login-form.tsx index c4ef237c..8350bbdb 100644 --- a/packages/ui/src/components/accounts/login-form.tsx +++ b/packages/ui/src/components/accounts/login-form.tsx @@ -1,4 +1,6 @@ +import { HouseIcon } from 'lucide-react'; import { useState, Fragment, useEffect } from 'react'; +import { match } from 'ts-pattern'; import { Account, Server } from '@colanode/client/types'; import { EmailLogin } from '@colanode/ui/components/accounts/email-login'; @@ -7,8 +9,11 @@ import { EmailPasswordResetInit } from '@colanode/ui/components/accounts/email-p import { EmailRegister } from '@colanode/ui/components/accounts/email-register'; import { EmailVerify } from '@colanode/ui/components/accounts/email-verify'; import { ServerDropdown } from '@colanode/ui/components/servers/server-dropdown'; +import { Button } from '@colanode/ui/components/ui/button'; import { Separator } from '@colanode/ui/components/ui/separator'; import { useApp } from '@colanode/ui/contexts/app'; +import { ServerContext } from '@colanode/ui/contexts/server'; +import { isFeatureSupported } from '@colanode/ui/lib/features'; interface LoginFormProps { accounts: Account[]; @@ -48,18 +53,16 @@ type PanelState = export const LoginForm = ({ accounts, servers }: LoginFormProps) => { const app = useApp(); - const [server, setServer] = useState( - servers[0]?.domain ?? null - ); + const [server, setServer] = useState(servers[0] ?? null); const [panel, setPanel] = useState({ type: 'login', }); useEffect(() => { const serverExists = - server !== null && servers.some((s) => s.domain === server); + server !== null && servers.some((s) => s.domain === server.domain); if (!serverExists && servers.length > 0) { - setServer(servers[0]!.domain); + setServer(servers[0]!); } }, [server, servers]); @@ -67,153 +70,130 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
{ + setServer(server); + }} servers={servers} readonly={panel.type === 'verify'} /> - {server && panel.type === 'login' && ( - - { - if (output.type === 'success') { - app.openAccount(output.account.id); - } else if (output.type === 'verify') { - setPanel({ - type: 'verify', - id: output.id, - expiresAt: new Date(output.expiresAt), - }); - } - }} - onForgotPassword={() => { - setPanel({ - type: 'password_reset_init', - }); - }} - /> -

{ - setPanel({ - type: 'register', - }); - }} - > - No account yet? Register -

-
- )} - {server && panel.type === 'register' && ( - - { - if (output.type === 'success') { - app.openAccount(output.account.id); - } else if (output.type === 'verify') { - setPanel({ - type: 'verify', - id: output.id, - expiresAt: new Date(output.expiresAt), - }); - } - }} - /> -

{ - setPanel({ - type: 'login', - }); - }} - > - Already have an account? Login -

-
- )} - - {server && panel.type === 'verify' && ( - - { - if (output.type === 'success') { - app.openAccount(output.account.id); - } - }} - /> -

{ - setPanel({ - type: 'login', - }); - }} - > - Back to login -

-
- )} - - {server && panel.type === 'password_reset_init' && ( - - { - setPanel({ - type: 'password_reset_complete', - id: output.id, - expiresAt: new Date(output.expiresAt), - }); - }} - /> -

{ - setPanel({ - type: 'login', - }); - }} - > - Back to login -

-
- )} - - {server && panel.type === 'password_reset_complete' && ( - - -

{ - setPanel({ - type: 'login', - }); - }} - > - Back to login -

-
+ {server && ( + { + return isFeatureSupported(feature, server.version); + }, + }} + > +
+ {match(panel) + .with({ type: 'login' }, () => ( + { + if (output.type === 'success') { + app.openAccount(output.account.id); + } else if (output.type === 'verify') { + setPanel({ + type: 'verify', + id: output.id, + expiresAt: new Date(output.expiresAt), + }); + } + }} + onForgotPassword={() => { + setPanel({ + type: 'password_reset_init', + }); + }} + onRegister={() => { + setPanel({ + type: 'register', + }); + }} + /> + )) + .with({ type: 'register' }, () => ( + { + if (output.type === 'success') { + app.openAccount(output.account.id); + } else if (output.type === 'verify') { + setPanel({ + type: 'verify', + id: output.id, + expiresAt: new Date(output.expiresAt), + }); + } + }} + onLogin={() => { + setPanel({ + type: 'login', + }); + }} + /> + )) + .with({ type: 'verify' }, (p) => ( + { + if (output.type === 'success') { + app.openAccount(output.account.id); + } + }} + onBack={() => { + setPanel({ + type: 'login', + }); + }} + /> + )) + .with({ type: 'password_reset_init' }, () => ( + { + setPanel({ + type: 'password_reset_complete', + id: output.id, + expiresAt: new Date(output.expiresAt), + }); + }} + onBack={() => { + setPanel({ + type: 'login', + }); + }} + /> + )) + .with({ type: 'password_reset_complete' }, (p) => ( + { + setPanel({ + type: 'login', + }); + }} + /> + )) + .exhaustive()} +
+
)} {accounts.length > 0 && ( -

{ app.closeLogin(); }} > - Cancel -

+ + Back to workspace +
)}
diff --git a/packages/ui/src/components/avatars/avatar-image.tsx b/packages/ui/src/components/avatars/avatar-image.tsx index 416fbffc..991a448e 100644 --- a/packages/ui/src/components/avatars/avatar-image.tsx +++ b/packages/ui/src/components/avatars/avatar-image.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { AvatarFallback } from '@colanode/ui/components/avatars/avatar-fallback'; -import { useAccount } from '@colanode/ui/contexts'; +import { useAccount } from '@colanode/ui/contexts/account'; import { useQuery } from '@colanode/ui/hooks/use-query'; import { AvatarProps, getAvatarSizeClasses } from '@colanode/ui/lib/avatars'; import { cn } from '@colanode/ui/lib/utils'; diff --git a/packages/ui/src/components/emojis/emoji-element.tsx b/packages/ui/src/components/emojis/emoji-element.tsx index e2ed92e3..cb414ba7 100644 --- a/packages/ui/src/components/emojis/emoji-element.tsx +++ b/packages/ui/src/components/emojis/emoji-element.tsx @@ -1,4 +1,4 @@ -import { useApp } from '@colanode/ui/contexts'; +import { useApp } from '@colanode/ui/contexts/app'; interface EmojiElementProps { id: string; diff --git a/packages/ui/src/components/icons/icon-element.tsx b/packages/ui/src/components/icons/icon-element.tsx index 0a60f467..d9c3ec34 100644 --- a/packages/ui/src/components/icons/icon-element.tsx +++ b/packages/ui/src/components/icons/icon-element.tsx @@ -1,4 +1,4 @@ -import { useApp } from '@colanode/ui/contexts'; +import { useApp } from '@colanode/ui/contexts/app'; interface IconElementProps { id: string; diff --git a/packages/ui/src/components/servers/server-dropdown.tsx b/packages/ui/src/components/servers/server-dropdown.tsx index 3039d30c..94c9397e 100644 --- a/packages/ui/src/components/servers/server-dropdown.tsx +++ b/packages/ui/src/components/servers/server-dropdown.tsx @@ -15,8 +15,8 @@ import { } from '@colanode/ui/components/ui/dropdown-menu'; interface ServerDropdownProps { - value: string | null; - onChange: (server: string) => void; + value: Server | null; + onChange: (server: Server) => void; servers: Server[]; readonly?: boolean; } @@ -31,8 +31,6 @@ export const ServerDropdown = ({ const [openCreate, setOpenCreate] = useState(false); const [deleteDomain, setDeleteDomain] = useState(null); - const server = servers.find((server) => server.domain === value); - return (
- {server ? ( + {value ? ( ) : ( )}
- {server ? ( + {value ? ( -

{server.name}

+

{value.name}

- {server.domain} + {value.domain}

) : ( @@ -78,8 +76,8 @@ export const ServerDropdown = ({ { - if (value !== server.domain) { - onChange(server.domain); + if (value?.domain !== server.domain) { + onChange(server); } }} className="group/server flex w-full flex-grow flex-row items-center gap-3 rounded-md border-b border-input p-2 cursor-pointer hover:bg-gray-100" diff --git a/packages/ui/src/components/servers/server-upgrade-required.tsx b/packages/ui/src/components/servers/server-upgrade-required.tsx index 740184ee..63528f67 100644 --- a/packages/ui/src/components/servers/server-upgrade-required.tsx +++ b/packages/ui/src/components/servers/server-upgrade-required.tsx @@ -1,6 +1,6 @@ import { CircleFadingArrowUp } from 'lucide-react'; -import { useServer } from '@colanode/ui/contexts'; +import { useServer } from '@colanode/ui/contexts/server'; export const ServerUpgradeRequired = () => { const server = useServer(); diff --git a/packages/ui/src/contexts/index.ts b/packages/ui/src/contexts/index.ts deleted file mode 100644 index c606ab2c..00000000 --- a/packages/ui/src/contexts/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * from './account'; -export * from './app'; -export * from './conversation'; -export * from './database-view'; -export * from './database-views'; -export * from './database'; -export * from './emoji-picker'; -export * from './folder'; -export * from './icon-picker'; -export * from './layout'; -export * from './radar'; -export * from './record'; -export * from './server'; -export * from './workspace';