Implement login with google (#57)

This commit is contained in:
Hakan Shehu
2025-06-12 22:08:54 +02:00
committed by GitHub
parent 574f617f90
commit 756af021d1
33 changed files with 751 additions and 280 deletions

View File

@@ -1,10 +1,11 @@
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod'; import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import ky from 'ky'; import ky from 'ky';
import sharp from 'sharp';
import { import {
AccountStatus, AccountStatus,
generateId, generateId,
GoogleUserInfo,
IdType, IdType,
ApiErrorCode, ApiErrorCode,
apiErrorOutputSchema, apiErrorOutputSchema,
@@ -12,10 +13,120 @@ import {
googleLoginInputSchema, googleLoginInputSchema,
} from '@colanode/core'; } from '@colanode/core';
import { database } from '@colanode/server/data/database'; 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 { config } from '@colanode/server/lib/config';
import { AccountAttributes } from '@colanode/server/types/accounts';
const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo'; 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<GoogleTokenResponse | null> => {
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<GoogleTokenResponse>();
return token;
} catch {
return null;
}
};
const fetchGoogleUser = async (
accessToken: string
): Promise<GoogleUserResponse | null> => {
try {
const user = await ky
.get(GoogleUserInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
timeout: GoogleRequestTimeout,
})
.json<GoogleUserResponse>();
return user;
} catch {
return null;
}
};
const uploadGooglePictureAsAvatar = async (
pictureUrl: string
): Promise<string | null> => {
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 = ( export const googleLoginRoute: FastifyPluginCallbackZod = (
instance, instance,
@@ -34,7 +145,7 @@ export const googleLoginRoute: FastifyPluginCallbackZod = (
}, },
}, },
handler: async (request, reply) => { handler: async (request, reply) => {
if (!config.account.allowGoogleLogin) { if (!config.account.google.enabled) {
return reply.code(400).send({ return reply.code(400).send({
code: ApiErrorCode.GoogleAuthFailed, code: ApiErrorCode.GoogleAuthFailed,
message: 'Google login is not allowed.', message: 'Google login is not allowed.',
@@ -42,52 +153,115 @@ export const googleLoginRoute: FastifyPluginCallbackZod = (
} }
const input = request.body; const input = request.body;
const url = `${GoogleUserInfoUrl}?access_token=${input.access_token}`;
const response = await ky.get(url).json<GoogleUserInfo>();
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({ return reply.code(400).send({
code: ApiErrorCode.GoogleAuthFailed, code: ApiErrorCode.GoogleAuthFailed,
message: 'Failed to authenticate with Google.', message: 'Failed to authenticate with Google.',
}); });
} }
const existingAccount = await database let existingAccount = await database
.selectFrom('accounts') .selectFrom('accounts')
.where('email', '=', response.email) .where('email', '=', googleUser.email)
.selectAll() .selectAll()
.executeTakeFirst(); .executeTakeFirst();
if (existingAccount) { if (existingAccount) {
if (existingAccount.status !== AccountStatus.Active) { const existingGoogleId = existingAccount.attributes?.googleId;
await database 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') .updateTable('accounts')
.set({ .returningAll()
attrs: JSON.stringify({ googleId: response.id }), .set(updateAccount)
updated_at: new Date(),
status: AccountStatus.Active,
})
.where('id', '=', existingAccount.id) .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( const output = await buildLoginSuccessOutput(
existingAccount, existingAccount,
request.client request.client
); );
return output; 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 const newAccount = await database
.insertInto('accounts') .insertInto('accounts')
.values({ .values({
id: generateId(IdType.Account), id: generateId(IdType.Account),
name: response.name, name: googleUser.name,
email: response.email, email: googleUser.email,
status: AccountStatus.Active, avatar,
status,
created_at: new Date(), created_at: new Date(),
password: null, password: null,
attrs: JSON.stringify({ googleId: response.id }), attributes: JSON.stringify({ googleId: googleUser.id }),
}) })
.returningAll() .returningAll()
.executeTakeFirst(); .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); const output = await buildLoginSuccessOutput(newAccount, request.client);
return output; return output;
}, },

View File

@@ -174,7 +174,7 @@ const getOrCreateAccount = async (
name: getNameFromEmail(email), name: getNameFromEmail(email),
email: email, email: email,
avatar: null, avatar: null,
attrs: null, attributes: null,
password: null, password: null,
status: AccountStatus.Pending, status: AccountStatus.Pending,
created_at: new Date(), created_at: new Date(),

View File

@@ -20,6 +20,16 @@ export const configGetRoute: FastifyPluginCallbackZod = (instance, _, done) => {
sha: config.server.sha, sha: config.server.sha,
ip: request.client.ip, ip: request.client.ip,
pathPrefix: config.server.pathPrefix, pathPrefix: config.server.pathPrefix,
account: {
google: config.account.google.enabled
? {
enabled: config.account.google.enabled,
clientId: config.account.google.clientId,
}
: {
enabled: false,
},
},
}; };
return output; return output;

View File

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

View File

@@ -20,6 +20,7 @@ import { createVectorExtension } from './00017-create-vector-extension';
import { createNodeEmbeddingsTable } from './00018-create-node-embeddings-table'; import { createNodeEmbeddingsTable } from './00018-create-node-embeddings-table';
import { createDocumentEmbeddingsTable } from './00019-create-document-embeddings-table'; import { createDocumentEmbeddingsTable } from './00019-create-document-embeddings-table';
import { alterDevicesPlatformColumn } from './00020-alter-devices-platform-column'; import { alterDevicesPlatformColumn } from './00020-alter-devices-platform-column';
import { renameAccountAttributesColumn } from './00021-rename-account-attributes-column';
export const databaseMigrations: Record<string, Migration> = { export const databaseMigrations: Record<string, Migration> = {
'00001_create_accounts_table': createAccountsTable, '00001_create_accounts_table': createAccountsTable,
@@ -42,4 +43,5 @@ export const databaseMigrations: Record<string, Migration> = {
'00018_create_node_embeddings_table': createNodeEmbeddingsTable, '00018_create_node_embeddings_table': createNodeEmbeddingsTable,
'00019_create_document_embeddings_table': createDocumentEmbeddingsTable, '00019_create_document_embeddings_table': createDocumentEmbeddingsTable,
'00020_alter_devices_platform_column': alterDevicesPlatformColumn, '00020_alter_devices_platform_column': alterDevicesPlatformColumn,
'00021_rename_account_attributes_column': renameAccountAttributesColumn,
}; };

View File

@@ -16,6 +16,7 @@ import {
DocumentContent, DocumentContent,
UpdateMergeMetadata, UpdateMergeMetadata,
} from '@colanode/core'; } from '@colanode/core';
import { AccountAttributes } from '@colanode/server/types/accounts';
interface AccountTable { interface AccountTable {
id: ColumnType<string, string, never>; id: ColumnType<string, string, never>;
@@ -23,7 +24,11 @@ interface AccountTable {
email: ColumnType<string, string, never>; email: ColumnType<string, string, never>;
avatar: ColumnType<string | null, string | null, string | null>; avatar: ColumnType<string | null, string | null, string | null>;
password: ColumnType<string | null, string | null, string | null>; password: ColumnType<string | null, string | null, string | null>;
attrs: ColumnType<string | null, string | null, string | null>; attributes: JSONColumnType<
AccountAttributes | null,
string | null,
string | null
>;
created_at: ColumnType<Date, Date, never>; created_at: ColumnType<Date, Date, never>;
updated_at: ColumnType<Date | null, Date | null, Date>; updated_at: ColumnType<Date | null, Date | null, Date>;
status: ColumnType<number, number, number>; status: ColumnType<number, number, number>;

View File

@@ -5,14 +5,32 @@ export const accountVerificationTypeSchema = z.enum([
'manual', 'manual',
'email', 'email',
]); ]);
export type AccountVerificationType = z.infer< export type AccountVerificationType = z.infer<
typeof accountVerificationTypeSchema 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({ export const accountConfigSchema = z.object({
verificationType: accountVerificationTypeSchema.default('manual'), verificationType: accountVerificationTypeSchema.default('manual'),
otpTimeout: z.coerce.number().default(600), otpTimeout: z.coerce.number().default(600),
allowGoogleLogin: z.boolean().default(false), google: googleConfigSchema.default({
enabled: false,
}),
}); });
export type AccountConfig = z.infer<typeof accountConfigSchema>; export type AccountConfig = z.infer<typeof accountConfigSchema>;
@@ -21,6 +39,10 @@ export const readAccountConfigVariables = () => {
return { return {
verificationType: process.env.ACCOUNT_VERIFICATION_TYPE, verificationType: process.env.ACCOUNT_VERIFICATION_TYPE,
otpTimeout: process.env.ACCOUNT_OTP_TIMEOUT, 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,
},
}; };
}; };

View File

@@ -0,0 +1,3 @@
export type AccountAttributes = {
googleId?: string | null;
};

View File

@@ -107,7 +107,11 @@ services:
# Possible values for ACCOUNT_VERIFICATION_TYPE: 'automatic', 'manual', 'email' # Possible values for ACCOUNT_VERIFICATION_TYPE: 'automatic', 'manual', 'email'
ACCOUNT_VERIFICATION_TYPE: 'automatic' ACCOUNT_VERIFICATION_TYPE: 'automatic'
ACCOUNT_OTP_TIMEOUT: '600' # in seconds 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 # User Configuration

11
package-lock.json generated
View File

@@ -7253,6 +7253,16 @@
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT" "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": { "node_modules/@redis/bloom": {
"version": "5.5.6", "version": "5.5.6",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.5.6.tgz", "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-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@react-oauth/google": "^0.12.2",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tanstack/react-virtual": "^3.13.10", "@tanstack/react-virtual": "^3.13.10",
"@tiptap/extension-blockquote": "^2.14.0", "@tiptap/extension-blockquote": "^2.14.0",

View File

@@ -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<GoogleLoginMutationInput>
{
constructor(appService: AppService) {
super(appService);
}
async handleMutation(input: GoogleLoginMutationInput): Promise<LoginOutput> {
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<LoginOutput>();
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);
}
}
}

View File

@@ -11,6 +11,7 @@ import { EmailPasswordResetCompleteMutationHandler } from './accounts/email-pass
import { EmailPasswordResetInitMutationHandler } from './accounts/email-password-reset-init'; import { EmailPasswordResetInitMutationHandler } from './accounts/email-password-reset-init';
import { EmailRegisterMutationHandler } from './accounts/email-register'; import { EmailRegisterMutationHandler } from './accounts/email-register';
import { EmailVerifyMutationHandler } from './accounts/email-verify'; import { EmailVerifyMutationHandler } from './accounts/email-verify';
import { GoogleLoginMutationHandler } from './accounts/google-login';
import { AppMetadataDeleteMutationHandler } from './apps/app-metadata-delete'; import { AppMetadataDeleteMutationHandler } from './apps/app-metadata-delete';
import { AppMetadataUpdateMutationHandler } from './apps/app-metadata-update'; import { AppMetadataUpdateMutationHandler } from './apps/app-metadata-update';
import { AvatarUploadMutationHandler } from './avatars/avatar-upload'; import { AvatarUploadMutationHandler } from './avatars/avatar-upload';
@@ -83,6 +84,7 @@ export const buildMutationHandlerMap = (
'email.login': new EmailLoginMutationHandler(app), 'email.login': new EmailLoginMutationHandler(app),
'email.register': new EmailRegisterMutationHandler(app), 'email.register': new EmailRegisterMutationHandler(app),
'email.verify': new EmailVerifyMutationHandler(app), 'email.verify': new EmailVerifyMutationHandler(app),
'google.login': new GoogleLoginMutationHandler(app),
'view.create': new ViewCreateMutationHandler(app), 'view.create': new ViewCreateMutationHandler(app),
'channel.create': new ChannelCreateMutationHandler(app), 'channel.create': new ChannelCreateMutationHandler(app),
'channel.delete': new ChannelDeleteMutationHandler(app), 'channel.delete': new ChannelDeleteMutationHandler(app),

View File

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

View File

@@ -7,6 +7,7 @@ export * from './accounts/email-password-reset-complete';
export * from './accounts/email-password-reset-init'; export * from './accounts/email-password-reset-init';
export * from './accounts/email-register'; export * from './accounts/email-register';
export * from './accounts/email-verify'; export * from './accounts/email-verify';
export * from './accounts/google-login';
export * from './apps/app-metadata-delete'; export * from './apps/app-metadata-delete';
export * from './apps/app-metadata-update'; export * from './apps/app-metadata-update';
export * from './avatars/avatar-upload'; export * from './avatars/avatar-upload';

View File

@@ -193,6 +193,14 @@ export class AppService {
const attributes: ServerAttributes = { const attributes: ServerAttributes = {
pathPrefix: config.pathPrefix, pathPrefix: config.pathPrefix,
insecure: url.protocol === 'http:', 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 const createdServer = await this.database

View File

@@ -6,7 +6,7 @@ import { EventLoop } from '@colanode/client/lib/event-loop';
import { mapServer } from '@colanode/client/lib/mappers'; import { mapServer } from '@colanode/client/lib/mappers';
import { isServerOutdated } from '@colanode/client/lib/servers'; import { isServerOutdated } from '@colanode/client/lib/servers';
import { AppService } from '@colanode/client/services/app-service'; 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'; import { createDebugger, ServerConfig } from '@colanode/core';
type ServerState = { type ServerState = {
@@ -86,6 +86,18 @@ export class ServerService {
); );
if (config) { 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 const updatedServer = await this.app.database
.updateTable('servers') .updateTable('servers')
.returningAll() .returningAll()
@@ -94,6 +106,7 @@ export class ServerService {
avatar: config.avatar, avatar: config.avatar,
name: config.name, name: config.name,
version: config.version, version: config.version,
attributes: JSON.stringify(attributes),
}) })
.where('domain', '=', this.server.domain) .where('domain', '=', this.server.domain)
.executeTakeFirst(); .executeTakeFirst();
@@ -101,6 +114,7 @@ export class ServerService {
this.server.avatar = config.avatar; this.server.avatar = config.avatar;
this.server.name = config.name; this.server.name = config.name;
this.server.version = config.version; this.server.version = config.version;
this.server.attributes = attributes;
this.isOutdated = isServerOutdated(config.version); this.isOutdated = isServerOutdated(config.version);
if (updatedServer) { if (updatedServer) {

View File

@@ -1,6 +1,14 @@
export type ServerAccountAttributes = {
google: {
enabled: boolean;
clientId: string;
};
};
export type ServerAttributes = { export type ServerAttributes = {
pathPrefix?: string | null; pathPrefix?: string | null;
insecure?: boolean; insecure?: boolean;
account?: ServerAccountAttributes;
}; };
export type Server = { export type Server = {

View File

@@ -127,18 +127,7 @@ export type EmailPasswordResetCompleteOutput = z.infer<
>; >;
export const googleLoginInputSchema = z.object({ export const googleLoginInputSchema = z.object({
access_token: z.string(), code: z.string(),
token_type: z.string(),
expires_in: z.number(),
}); });
export type GoogleLoginInput = z.infer<typeof googleLoginInputSchema>; export type GoogleLoginInput = z.infer<typeof googleLoginInputSchema>;
export const googleUserInfoSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
picture: z.string(),
});
export type GoogleUserInfo = z.infer<typeof googleUserInfoSchema>;

View File

@@ -1,5 +1,19 @@
import { z } from 'zod/v4'; 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({ export const serverConfigSchema = z.object({
name: z.string(), name: z.string(),
avatar: z.string(), avatar: z.string(),
@@ -7,6 +21,7 @@ export const serverConfigSchema = z.object({
sha: z.string(), sha: z.string(),
ip: z.string().nullable().optional(), ip: z.string().nullable().optional(),
pathPrefix: z.string().nullable().optional(), pathPrefix: z.string().nullable().optional(),
account: serverAccountConfigSchema.nullable().optional(),
}); });
export type ServerConfig = z.infer<typeof serverConfigSchema>; export type ServerConfig = z.infer<typeof serverConfigSchema>;

View File

@@ -43,6 +43,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@react-oauth/google": "^0.12.2",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tanstack/react-virtual": "^3.13.10", "@tanstack/react-virtual": "^3.13.10",
"@tiptap/extension-blockquote": "^2.14.0", "@tiptap/extension-blockquote": "^2.14.0",

View File

@@ -5,6 +5,7 @@ import { toast } from 'sonner';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { LoginOutput } from '@colanode/core'; import { LoginOutput } from '@colanode/core';
import { GoogleLogin } from '@colanode/ui/components/accounts/google-login';
import { Button } from '@colanode/ui/components/ui/button'; import { Button } from '@colanode/ui/components/ui/button';
import { import {
Form, Form,
@@ -14,7 +15,9 @@ import {
FormMessage, FormMessage,
} from '@colanode/ui/components/ui/form'; } from '@colanode/ui/components/ui/form';
import { Input } from '@colanode/ui/components/ui/input'; 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 { Spinner } from '@colanode/ui/components/ui/spinner';
import { useServer } from '@colanode/ui/contexts/server';
import { useMutation } from '@colanode/ui/hooks/use-mutation'; import { useMutation } from '@colanode/ui/hooks/use-mutation';
const formSchema = z.object({ const formSchema = z.object({
@@ -23,17 +26,19 @@ const formSchema = z.object({
}); });
interface EmailLoginProps { interface EmailLoginProps {
server: string;
onSuccess: (output: LoginOutput) => void; onSuccess: (output: LoginOutput) => void;
onForgotPassword: () => void; onForgotPassword: () => void;
onRegister: () => void;
} }
export const EmailLogin = ({ export const EmailLogin = ({
server,
onSuccess, onSuccess,
onForgotPassword, onForgotPassword,
onRegister,
}: EmailLoginProps) => { }: EmailLoginProps) => {
const server = useServer();
const { mutate, isPending } = useMutation(); const { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -48,7 +53,7 @@ export const EmailLogin = ({
type: 'email.login', type: 'email.login',
email: values.email, email: values.email,
password: values.password, password: values.password,
server, server: server.domain,
}, },
onSuccess(output) { onSuccess(output) {
onSuccess(output); onSuccess(output);
@@ -61,14 +66,19 @@ export const EmailLogin = ({
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-3"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="email">Email</Label>
<FormControl> <FormControl>
<Input placeholder="Email" {...field} autoComplete="email" /> <Input
placeholder="hi@example.com"
{...field}
autoComplete="email"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -79,10 +89,18 @@ export const EmailLogin = ({
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex flex-row gap-2 items-center">
<Label htmlFor="password">Password</Label>
<p
className="text-xs text-muted-foreground cursor-pointer hover:underline w-full text-right"
onClick={onForgotPassword}
>
Forgot password?
</p>
</div>
<FormControl> <FormControl>
<Input <Input
type="password" type="password"
placeholder="Password"
{...field} {...field}
autoComplete="current-password" autoComplete="current-password"
/> />
@@ -91,12 +109,6 @@ export const EmailLogin = ({
</FormItem> </FormItem>
)} )}
/> />
<p
className="text-xs text-muted-foreground cursor-pointer hover:underline w-full text-right"
onClick={onForgotPassword}
>
Forgot password?
</p>
<Button <Button
type="submit" type="submit"
variant="outline" variant="outline"
@@ -104,12 +116,21 @@ export const EmailLogin = ({
disabled={isPending} disabled={isPending}
> >
{isPending ? ( {isPending ? (
<Spinner className="mr-2 size-4" /> <Spinner className="mr-1 size-4" />
) : ( ) : (
<Mail className="mr-2 size-4" /> <Mail className="mr-1 size-4" />
)} )}
Login Login
</Button> </Button>
<GoogleLogin context="login" onSuccess={onSuccess} />
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onRegister}
type="button"
>
No account yet? Register
</Button>
</form> </form>
</Form> </Form>
); );

View File

@@ -14,7 +14,9 @@ import {
FormMessage, FormMessage,
} from '@colanode/ui/components/ui/form'; } from '@colanode/ui/components/ui/form';
import { Input } from '@colanode/ui/components/ui/input'; 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 { Spinner } from '@colanode/ui/components/ui/spinner';
import { useServer } from '@colanode/ui/contexts/server';
import { useCountdown } from '@colanode/ui/hooks/use-countdown'; import { useCountdown } from '@colanode/ui/hooks/use-countdown';
import { useMutation } from '@colanode/ui/hooks/use-mutation'; import { useMutation } from '@colanode/ui/hooks/use-mutation';
@@ -38,17 +40,19 @@ const formSchema = z
}); });
interface EmailPasswordResetCompleteProps { interface EmailPasswordResetCompleteProps {
server: string;
id: string; id: string;
expiresAt: Date; expiresAt: Date;
onBack: () => void;
} }
export const EmailPasswordResetComplete = ({ export const EmailPasswordResetComplete = ({
server,
id, id,
expiresAt, expiresAt,
onBack,
}: EmailPasswordResetCompleteProps) => { }: EmailPasswordResetCompleteProps) => {
const server = useServer();
const { mutate, isPending } = useMutation(); const { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -72,7 +76,7 @@ export const EmailPasswordResetComplete = ({
type: 'email.password.reset.complete', type: 'email.password.reset.complete',
otp: values.otp, otp: values.otp,
password: values.password, password: values.password,
server, server: server.domain,
id: id, id: id,
}, },
onSuccess() { onSuccess() {
@@ -107,13 +111,9 @@ export const EmailPasswordResetComplete = ({
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="password">New Password</Label>
<FormControl> <FormControl>
<Input <Input type="password" {...field} autoComplete="new-password" />
type="password"
placeholder="New Password"
{...field}
autoComplete="new-password"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -124,13 +124,9 @@ export const EmailPasswordResetComplete = ({
name="confirmPassword" name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<FormControl> <FormControl>
<Input <Input type="password" {...field} autoComplete="new-password" />
type="password"
placeholder="Confirm Password"
{...field}
autoComplete="new-password"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -141,8 +137,9 @@ export const EmailPasswordResetComplete = ({
name="otp" name="otp"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="otp">Code</Label>
<FormControl> <FormControl>
<Input placeholder="Code" {...field} /> <Input placeholder="123456" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<p className="text-xs text-muted-foreground w-full text-center"> <p className="text-xs text-muted-foreground w-full text-center">
@@ -158,12 +155,20 @@ export const EmailPasswordResetComplete = ({
disabled={isPending} disabled={isPending}
> >
{isPending ? ( {isPending ? (
<Spinner className="mr-2 size-4" /> <Spinner className="mr-1 size-4" />
) : ( ) : (
<Lock className="mr-2 size-4" /> <Lock className="mr-1 size-4" />
)} )}
Reset password Reset password
</Button> </Button>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onBack}
type="button"
>
Back to login
</Button>
</form> </form>
</Form> </Form>
); );

View File

@@ -14,7 +14,9 @@ import {
FormMessage, FormMessage,
} from '@colanode/ui/components/ui/form'; } from '@colanode/ui/components/ui/form';
import { Input } from '@colanode/ui/components/ui/input'; 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 { Spinner } from '@colanode/ui/components/ui/spinner';
import { useServer } from '@colanode/ui/contexts/server';
import { useMutation } from '@colanode/ui/hooks/use-mutation'; import { useMutation } from '@colanode/ui/hooks/use-mutation';
const formSchema = z.object({ const formSchema = z.object({
@@ -22,15 +24,17 @@ const formSchema = z.object({
}); });
interface EmailPasswordResetInitProps { interface EmailPasswordResetInitProps {
server: string;
onSuccess: (output: EmailPasswordResetInitOutput) => void; onSuccess: (output: EmailPasswordResetInitOutput) => void;
onBack: () => void;
} }
export const EmailPasswordResetInit = ({ export const EmailPasswordResetInit = ({
server,
onSuccess, onSuccess,
onBack,
}: EmailPasswordResetInitProps) => { }: EmailPasswordResetInitProps) => {
const server = useServer();
const { mutate, isPending } = useMutation(); const { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -43,7 +47,7 @@ export const EmailPasswordResetInit = ({
input: { input: {
type: 'email.password.reset.init', type: 'email.password.reset.init',
email: values.email, email: values.email,
server, server: server.domain,
}, },
onSuccess(output) { onSuccess(output) {
onSuccess(output); onSuccess(output);
@@ -62,8 +66,9 @@ export const EmailPasswordResetInit = ({
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="email">Email</Label>
<FormControl> <FormControl>
<Input placeholder="Email" {...field} /> <Input placeholder="hi@example.com" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -76,12 +81,20 @@ export const EmailPasswordResetInit = ({
disabled={isPending} disabled={isPending}
> >
{isPending ? ( {isPending ? (
<Spinner className="mr-2 size-4" /> <Spinner className="mr-1 size-4" />
) : ( ) : (
<Mail className="mr-2 size-4" /> <Mail className="mr-1 size-4" />
)} )}
Reset password Reset password
</Button> </Button>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onBack}
type="button"
>
Back to login
</Button>
</form> </form>
</Form> </Form>
); );

View File

@@ -5,6 +5,7 @@ import { toast } from 'sonner';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { LoginOutput } from '@colanode/core'; import { LoginOutput } from '@colanode/core';
import { GoogleLogin } from '@colanode/ui/components/accounts/google-login';
import { Button } from '@colanode/ui/components/ui/button'; import { Button } from '@colanode/ui/components/ui/button';
import { import {
Form, Form,
@@ -14,7 +15,9 @@ import {
FormMessage, FormMessage,
} from '@colanode/ui/components/ui/form'; } from '@colanode/ui/components/ui/form';
import { Input } from '@colanode/ui/components/ui/input'; 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 { Spinner } from '@colanode/ui/components/ui/spinner';
import { useServer } from '@colanode/ui/contexts/server';
import { useMutation } from '@colanode/ui/hooks/use-mutation'; import { useMutation } from '@colanode/ui/hooks/use-mutation';
const formSchema = z const formSchema = z
@@ -38,12 +41,14 @@ const formSchema = z
}); });
interface EmailRegisterProps { interface EmailRegisterProps {
server: string;
onSuccess: (output: LoginOutput) => void; 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 { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -61,7 +66,7 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
name: values.name, name: values.name,
email: values.email, email: values.email,
password: values.password, password: values.password,
server, server: server.domain,
}, },
onSuccess(output) { onSuccess(output) {
onSuccess(output); onSuccess(output);
@@ -74,14 +79,15 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="name">Name</Label>
<FormControl> <FormControl>
<Input placeholder="Name" {...field} autoComplete="name" /> <Input placeholder="John Doe" {...field} autoComplete="name" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -92,8 +98,13 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="email">Email</Label>
<FormControl> <FormControl>
<Input placeholder="Email" {...field} autoComplete="email" /> <Input
placeholder="hi@example.com"
{...field}
autoComplete="email"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -104,13 +115,9 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="password">Password</Label>
<FormControl> <FormControl>
<Input <Input type="password" {...field} autoComplete="new-password" />
type="password"
placeholder="Password"
{...field}
autoComplete="new-password"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -121,13 +128,9 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
name="confirmPassword" name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label htmlFor="confirmPassword">Confirm Password</Label>
<FormControl> <FormControl>
<Input <Input type="password" {...field} autoComplete="new-password" />
type="password"
placeholder="Confirm Password"
{...field}
autoComplete="new-password"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -140,12 +143,21 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
disabled={isPending} disabled={isPending}
> >
{isPending ? ( {isPending ? (
<Spinner className="mr-2 size-4" /> <Spinner className="mr-1 size-4" />
) : ( ) : (
<Mail className="mr-2 size-4" /> <Mail className="mr-1 size-4" />
)} )}
Register Register
</Button> </Button>
<GoogleLogin context="register" onSuccess={onSuccess} />
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onLogin}
type="button"
>
Already have an account? Login
</Button>
</form> </form>
</Form> </Form>
); );

View File

@@ -14,7 +14,9 @@ import {
FormMessage, FormMessage,
} from '@colanode/ui/components/ui/form'; } from '@colanode/ui/components/ui/form';
import { Input } from '@colanode/ui/components/ui/input'; 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 { Spinner } from '@colanode/ui/components/ui/spinner';
import { useServer } from '@colanode/ui/contexts/server';
import { useCountdown } from '@colanode/ui/hooks/use-countdown'; import { useCountdown } from '@colanode/ui/hooks/use-countdown';
import { useMutation } from '@colanode/ui/hooks/use-mutation'; import { useMutation } from '@colanode/ui/hooks/use-mutation';
@@ -23,19 +25,21 @@ const formSchema = z.object({
}); });
interface EmailVerifyProps { interface EmailVerifyProps {
server: string;
id: string; id: string;
expiresAt: Date; expiresAt: Date;
onSuccess: (output: LoginOutput) => void; onSuccess: (output: LoginOutput) => void;
onBack: () => void;
} }
export const EmailVerify = ({ export const EmailVerify = ({
server,
id, id,
expiresAt, expiresAt,
onSuccess, onSuccess,
onBack,
}: EmailVerifyProps) => { }: EmailVerifyProps) => {
const server = useServer();
const { mutate, isPending } = useMutation(); const { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -55,7 +59,7 @@ export const EmailVerify = ({
input: { input: {
type: 'email.verify', type: 'email.verify',
otp: values.otp, otp: values.otp,
server, server: server.domain,
id, id,
}, },
onSuccess(output) { onSuccess(output) {
@@ -69,22 +73,25 @@ export const EmailVerify = ({
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-3"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="otp" name="otp"
render={({ field }) => ( render={({ field }) => (
<FormItem className="space-y-3"> <FormItem>
<p className="text-sm text-muted-foreground w-full text-center"> <Label htmlFor="otp">Code</Label>
Write the code you received in your email
</p>
<FormControl> <FormControl>
<Input placeholder="Code" {...field} /> <Input placeholder="123456" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<div className="flex flex-col items-center gap-1">
<p className="text-sm text-muted-foreground w-full text-center">
We sent a verification code to your email.
</p>
<p className="text-xs text-muted-foreground w-full text-center"> <p className="text-xs text-muted-foreground w-full text-center">
{formattedTime} {formattedTime}
</p> </p>
</div>
</FormItem> </FormItem>
)} )}
/> />
@@ -95,12 +102,20 @@ export const EmailVerify = ({
disabled={isPending || remainingSeconds <= 0} disabled={isPending || remainingSeconds <= 0}
> >
{isPending ? ( {isPending ? (
<Spinner className="mr-2 size-4" /> <Spinner className="mr-1 size-4" />
) : ( ) : (
<Mail className="mr-2 size-4" /> <Mail className="mr-1 size-4" />
)} )}
Confirm Confirm
</Button> </Button>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onBack}
type="button"
>
Back to login
</Button>
</form> </form>
</Form> </Form>
); );

View File

@@ -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 (
<Button
variant="outline"
className="w-full"
onClick={() => login()}
disabled={isPending}
type="button"
>
{isPending ? (
<Spinner className="mr-1 size-4" />
) : (
<GoogleIcon className="mr-1 size-4" />
)}
{context === 'login' ? 'Login' : 'Register'} with Google
</Button>
);
};
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 (
<GoogleOAuthProvider clientId={config.clientId}>
<GoogleLoginButton onSuccess={onSuccess} context={context} />
</GoogleOAuthProvider>
);
}
return null;
};

View File

@@ -1,4 +1,6 @@
import { HouseIcon } from 'lucide-react';
import { useState, Fragment, useEffect } from 'react'; import { useState, Fragment, useEffect } from 'react';
import { match } from 'ts-pattern';
import { Account, Server } from '@colanode/client/types'; import { Account, Server } from '@colanode/client/types';
import { EmailLogin } from '@colanode/ui/components/accounts/email-login'; 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 { EmailRegister } from '@colanode/ui/components/accounts/email-register';
import { EmailVerify } from '@colanode/ui/components/accounts/email-verify'; import { EmailVerify } from '@colanode/ui/components/accounts/email-verify';
import { ServerDropdown } from '@colanode/ui/components/servers/server-dropdown'; 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 { Separator } from '@colanode/ui/components/ui/separator';
import { useApp } from '@colanode/ui/contexts/app'; import { useApp } from '@colanode/ui/contexts/app';
import { ServerContext } from '@colanode/ui/contexts/server';
import { isFeatureSupported } from '@colanode/ui/lib/features';
interface LoginFormProps { interface LoginFormProps {
accounts: Account[]; accounts: Account[];
@@ -48,18 +53,16 @@ type PanelState =
export const LoginForm = ({ accounts, servers }: LoginFormProps) => { export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
const app = useApp(); const app = useApp();
const [server, setServer] = useState<string | null>( const [server, setServer] = useState<Server | null>(servers[0] ?? null);
servers[0]?.domain ?? null
);
const [panel, setPanel] = useState<PanelState>({ const [panel, setPanel] = useState<PanelState>({
type: 'login', type: 'login',
}); });
useEffect(() => { useEffect(() => {
const serverExists = const serverExists =
server !== null && servers.some((s) => s.domain === server); server !== null && servers.some((s) => s.domain === server.domain);
if (!serverExists && servers.length > 0) { if (!serverExists && servers.length > 0) {
setServer(servers[0]!.domain); setServer(servers[0]!);
} }
}, [server, servers]); }, [server, servers]);
@@ -67,14 +70,25 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<ServerDropdown <ServerDropdown
value={server} value={server}
onChange={setServer} onChange={(server) => {
setServer(server);
}}
servers={servers} servers={servers}
readonly={panel.type === 'verify'} readonly={panel.type === 'verify'}
/> />
{server && panel.type === 'login' && ( {server && (
<Fragment> <ServerContext.Provider
value={{
...server,
supports: (feature) => {
return isFeatureSupported(feature, server.version);
},
}}
>
<div>
{match(panel)
.with({ type: 'login' }, () => (
<EmailLogin <EmailLogin
server={server}
onSuccess={(output) => { onSuccess={(output) => {
if (output.type === 'success') { if (output.type === 'success') {
app.openAccount(output.account.id); app.openAccount(output.account.id);
@@ -91,23 +105,15 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
type: 'password_reset_init', type: 'password_reset_init',
}); });
}} }}
/> onRegister={() => {
<p
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
onClick={() => {
setPanel({ setPanel({
type: 'register', type: 'register',
}); });
}} }}
> />
No account yet? Register ))
</p> .with({ type: 'register' }, () => (
</Fragment>
)}
{server && panel.type === 'register' && (
<Fragment>
<EmailRegister <EmailRegister
server={server}
onSuccess={(output) => { onSuccess={(output) => {
if (output.type === 'success') { if (output.type === 'success') {
app.openAccount(output.account.id); app.openAccount(output.account.id);
@@ -119,49 +125,31 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
}); });
} }
}} }}
/> onLogin={() => {
<p
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
onClick={() => {
setPanel({ setPanel({
type: 'login', type: 'login',
}); });
}} }}
> />
Already have an account? Login ))
</p> .with({ type: 'verify' }, (p) => (
</Fragment>
)}
{server && panel.type === 'verify' && (
<Fragment>
<EmailVerify <EmailVerify
server={server} id={p.id}
id={panel.id} expiresAt={p.expiresAt}
expiresAt={panel.expiresAt}
onSuccess={(output) => { onSuccess={(output) => {
if (output.type === 'success') { if (output.type === 'success') {
app.openAccount(output.account.id); app.openAccount(output.account.id);
} }
}} }}
/> onBack={() => {
<p
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
onClick={() => {
setPanel({ setPanel({
type: 'login', type: 'login',
}); });
}} }}
> />
Back to login ))
</p> .with({ type: 'password_reset_init' }, () => (
</Fragment>
)}
{server && panel.type === 'password_reset_init' && (
<Fragment>
<EmailPasswordResetInit <EmailPasswordResetInit
server={server}
onSuccess={(output) => { onSuccess={(output) => {
setPanel({ setPanel({
type: 'password_reset_complete', type: 'password_reset_complete',
@@ -169,51 +157,43 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
expiresAt: new Date(output.expiresAt), expiresAt: new Date(output.expiresAt),
}); });
}} }}
/> onBack={() => {
<p
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
onClick={() => {
setPanel({ setPanel({
type: 'login', type: 'login',
}); });
}} }}
> />
Back to login ))
</p> .with({ type: 'password_reset_complete' }, (p) => (
</Fragment>
)}
{server && panel.type === 'password_reset_complete' && (
<Fragment>
<EmailPasswordResetComplete <EmailPasswordResetComplete
server={server} id={p.id}
id={panel.id} expiresAt={p.expiresAt}
expiresAt={panel.expiresAt} onBack={() => {
/>
<p
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
onClick={() => {
setPanel({ setPanel({
type: 'login', type: 'login',
}); });
}} }}
> />
Back to login ))
</p> .exhaustive()}
</Fragment> </div>
</ServerContext.Provider>
)} )}
{accounts.length > 0 && ( {accounts.length > 0 && (
<Fragment> <Fragment>
<Separator className="w-full" /> <Separator className="w-full" />
<p <Button
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline" variant="link"
className="w-full text-muted-foreground"
type="button"
onClick={() => { onClick={() => {
app.closeLogin(); app.closeLogin();
}} }}
> >
Cancel <HouseIcon className="mr-1 size-4" />
</p> Back to workspace
</Button>
</Fragment> </Fragment>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { AvatarFallback } from '@colanode/ui/components/avatars/avatar-fallback'; 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 { useQuery } from '@colanode/ui/hooks/use-query';
import { AvatarProps, getAvatarSizeClasses } from '@colanode/ui/lib/avatars'; import { AvatarProps, getAvatarSizeClasses } from '@colanode/ui/lib/avatars';
import { cn } from '@colanode/ui/lib/utils'; import { cn } from '@colanode/ui/lib/utils';

View File

@@ -1,4 +1,4 @@
import { useApp } from '@colanode/ui/contexts'; import { useApp } from '@colanode/ui/contexts/app';
interface EmojiElementProps { interface EmojiElementProps {
id: string; id: string;

View File

@@ -1,4 +1,4 @@
import { useApp } from '@colanode/ui/contexts'; import { useApp } from '@colanode/ui/contexts/app';
interface IconElementProps { interface IconElementProps {
id: string; id: string;

View File

@@ -15,8 +15,8 @@ import {
} from '@colanode/ui/components/ui/dropdown-menu'; } from '@colanode/ui/components/ui/dropdown-menu';
interface ServerDropdownProps { interface ServerDropdownProps {
value: string | null; value: Server | null;
onChange: (server: string) => void; onChange: (server: Server) => void;
servers: Server[]; servers: Server[];
readonly?: boolean; readonly?: boolean;
} }
@@ -31,8 +31,6 @@ export const ServerDropdown = ({
const [openCreate, setOpenCreate] = useState(false); const [openCreate, setOpenCreate] = useState(false);
const [deleteDomain, setDeleteDomain] = useState<string | null>(null); const [deleteDomain, setDeleteDomain] = useState<string | null>(null);
const server = servers.find((server) => server.domain === value);
return ( return (
<Fragment> <Fragment>
<DropdownMenu <DropdownMenu
@@ -45,21 +43,21 @@ export const ServerDropdown = ({
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="flex w-full flex-grow flex-row items-center gap-3 rounded-md border border-input p-2 cursor-pointer hover:bg-gray-100"> <div className="flex w-full flex-grow flex-row items-center gap-3 rounded-md border border-input p-2 cursor-pointer hover:bg-gray-100">
{server ? ( {value ? (
<ServerAvatar <ServerAvatar
url={server.avatar} url={value.avatar}
name={server.name} name={value.name}
className="size-8 rounded-md" className="size-8 rounded-md"
/> />
) : ( ) : (
<ServerOffIcon className="size-8 text-muted-foreground rounded-md" /> <ServerOffIcon className="size-8 text-muted-foreground rounded-md" />
)} )}
<div className="flex-grow"> <div className="flex-grow">
{server ? ( {value ? (
<Fragment> <Fragment>
<p className="flex-grow font-semibold">{server.name}</p> <p className="flex-grow font-semibold">{value.name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{server.domain} {value.domain}
</p> </p>
</Fragment> </Fragment>
) : ( ) : (
@@ -78,8 +76,8 @@ export const ServerDropdown = ({
<DropdownMenuItem <DropdownMenuItem
key={server.domain} key={server.domain}
onSelect={() => { onSelect={() => {
if (value !== server.domain) { if (value?.domain !== server.domain) {
onChange(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" 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"

View File

@@ -1,6 +1,6 @@
import { CircleFadingArrowUp } from 'lucide-react'; import { CircleFadingArrowUp } from 'lucide-react';
import { useServer } from '@colanode/ui/contexts'; import { useServer } from '@colanode/ui/contexts/server';
export const ServerUpgradeRequired = () => { export const ServerUpgradeRequired = () => {
const server = useServer(); const server = useServer();

View File

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