mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Implement login with google (#57)
This commit is contained in:
@@ -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<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 = (
|
||||
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<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({
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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<string, Migration> = {
|
||||
'00001_create_accounts_table': createAccountsTable,
|
||||
@@ -42,4 +43,5 @@ export const databaseMigrations: Record<string, Migration> = {
|
||||
'00018_create_node_embeddings_table': createNodeEmbeddingsTable,
|
||||
'00019_create_document_embeddings_table': createDocumentEmbeddingsTable,
|
||||
'00020_alter_devices_platform_column': alterDevicesPlatformColumn,
|
||||
'00021_rename_account_attributes_column': renameAccountAttributesColumn,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DocumentContent,
|
||||
UpdateMergeMetadata,
|
||||
} from '@colanode/core';
|
||||
import { AccountAttributes } from '@colanode/server/types/accounts';
|
||||
|
||||
interface AccountTable {
|
||||
id: ColumnType<string, string, never>;
|
||||
@@ -23,7 +24,11 @@ interface AccountTable {
|
||||
email: ColumnType<string, string, never>;
|
||||
avatar: 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>;
|
||||
updated_at: ColumnType<Date | null, Date | null, Date>;
|
||||
status: ColumnType<number, number, number>;
|
||||
|
||||
@@ -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<typeof accountConfigSchema>;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
3
apps/server/src/types/accounts.ts
Normal file
3
apps/server/src/types/accounts.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type AccountAttributes = {
|
||||
googleId?: string | null;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
16
packages/client/src/mutations/accounts/google-login.ts
Normal file
16
packages/client/src/mutations/accounts/google-login.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<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>;
|
||||
|
||||
@@ -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<typeof serverConfigSchema>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<z.infer<typeof formSchema>>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-3">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Email" {...field} autoComplete="email" />
|
||||
<Input
|
||||
placeholder="hi@example.com"
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -79,10 +89,18 @@ export const EmailLogin = ({
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<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>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
@@ -91,12 +109,6 @@ export const EmailLogin = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className="text-xs text-muted-foreground cursor-pointer hover:underline w-full text-right"
|
||||
onClick={onForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -104,12 +116,21 @@ export const EmailLogin = ({
|
||||
disabled={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
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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<z.infer<typeof formSchema>>({
|
||||
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 }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input type="password" {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -124,13 +124,9 @@ export const EmailPasswordResetComplete = ({
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input type="password" {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -141,8 +137,9 @@ export const EmailPasswordResetComplete = ({
|
||||
name="otp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="otp">Code</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Code" {...field} />
|
||||
<Input placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-muted-foreground w-full text-center">
|
||||
@@ -158,12 +155,20 @@ export const EmailPasswordResetComplete = ({
|
||||
disabled={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
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="w-full text-muted-foreground"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -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<z.infer<typeof formSchema>>({
|
||||
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 }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Email" {...field} />
|
||||
<Input placeholder="hi@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -76,12 +81,20 @@ export const EmailPasswordResetInit = ({
|
||||
disabled={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
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="w-full text-muted-foreground"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -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<z.infer<typeof formSchema>>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Name" {...field} autoComplete="name" />
|
||||
<Input placeholder="John Doe" {...field} autoComplete="name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -92,8 +98,13 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Email" {...field} autoComplete="email" />
|
||||
<Input
|
||||
placeholder="hi@example.com"
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -104,13 +115,9 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input type="password" {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -121,13 +128,9 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input type="password" {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -140,12 +143,21 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => {
|
||||
disabled={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
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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<z.infer<typeof formSchema>>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-3">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="otp"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground w-full text-center">
|
||||
Write the code you received in your email
|
||||
</p>
|
||||
<FormItem>
|
||||
<Label htmlFor="otp">Code</Label>
|
||||
<FormControl>
|
||||
<Input placeholder="Code" {...field} />
|
||||
<Input placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<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">
|
||||
{formattedTime}
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -95,12 +102,20 @@ export const EmailVerify = ({
|
||||
disabled={isPending || remainingSeconds <= 0}
|
||||
>
|
||||
{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
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="w-full text-muted-foreground"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
73
packages/ui/src/components/accounts/google-login.tsx
Normal file
73
packages/ui/src/components/accounts/google-login.tsx
Normal 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;
|
||||
};
|
||||
@@ -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<string | null>(
|
||||
servers[0]?.domain ?? null
|
||||
);
|
||||
const [server, setServer] = useState<Server | null>(servers[0] ?? null);
|
||||
const [panel, setPanel] = useState<PanelState>({
|
||||
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,14 +70,25 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
|
||||
<div className="flex flex-col gap-4">
|
||||
<ServerDropdown
|
||||
value={server}
|
||||
onChange={setServer}
|
||||
onChange={(server) => {
|
||||
setServer(server);
|
||||
}}
|
||||
servers={servers}
|
||||
readonly={panel.type === 'verify'}
|
||||
/>
|
||||
{server && panel.type === 'login' && (
|
||||
<Fragment>
|
||||
{server && (
|
||||
<ServerContext.Provider
|
||||
value={{
|
||||
...server,
|
||||
supports: (feature) => {
|
||||
return isFeatureSupported(feature, server.version);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{match(panel)
|
||||
.with({ type: 'login' }, () => (
|
||||
<EmailLogin
|
||||
server={server}
|
||||
onSuccess={(output) => {
|
||||
if (output.type === 'success') {
|
||||
app.openAccount(output.account.id);
|
||||
@@ -91,23 +105,15 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
|
||||
type: 'password_reset_init',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
onRegister={() => {
|
||||
setPanel({
|
||||
type: 'register',
|
||||
});
|
||||
}}
|
||||
>
|
||||
No account yet? Register
|
||||
</p>
|
||||
</Fragment>
|
||||
)}
|
||||
{server && panel.type === 'register' && (
|
||||
<Fragment>
|
||||
/>
|
||||
))
|
||||
.with({ type: 'register' }, () => (
|
||||
<EmailRegister
|
||||
server={server}
|
||||
onSuccess={(output) => {
|
||||
if (output.type === 'success') {
|
||||
app.openAccount(output.account.id);
|
||||
@@ -119,49 +125,31 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
onLogin={() => {
|
||||
setPanel({
|
||||
type: 'login',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Already have an account? Login
|
||||
</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{server && panel.type === 'verify' && (
|
||||
<Fragment>
|
||||
/>
|
||||
))
|
||||
.with({ type: 'verify' }, (p) => (
|
||||
<EmailVerify
|
||||
server={server}
|
||||
id={panel.id}
|
||||
expiresAt={panel.expiresAt}
|
||||
id={p.id}
|
||||
expiresAt={p.expiresAt}
|
||||
onSuccess={(output) => {
|
||||
if (output.type === 'success') {
|
||||
app.openAccount(output.account.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
onBack={() => {
|
||||
setPanel({
|
||||
type: 'login',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Back to login
|
||||
</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{server && panel.type === 'password_reset_init' && (
|
||||
<Fragment>
|
||||
/>
|
||||
))
|
||||
.with({ type: 'password_reset_init' }, () => (
|
||||
<EmailPasswordResetInit
|
||||
server={server}
|
||||
onSuccess={(output) => {
|
||||
setPanel({
|
||||
type: 'password_reset_complete',
|
||||
@@ -169,51 +157,43 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
|
||||
expiresAt: new Date(output.expiresAt),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
onBack={() => {
|
||||
setPanel({
|
||||
type: 'login',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Back to login
|
||||
</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{server && panel.type === 'password_reset_complete' && (
|
||||
<Fragment>
|
||||
/>
|
||||
))
|
||||
.with({ type: 'password_reset_complete' }, (p) => (
|
||||
<EmailPasswordResetComplete
|
||||
server={server}
|
||||
id={panel.id}
|
||||
expiresAt={panel.expiresAt}
|
||||
/>
|
||||
<p
|
||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
id={p.id}
|
||||
expiresAt={p.expiresAt}
|
||||
onBack={() => {
|
||||
setPanel({
|
||||
type: 'login',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Back to login
|
||||
</p>
|
||||
</Fragment>
|
||||
/>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</ServerContext.Provider>
|
||||
)}
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<Fragment>
|
||||
<Separator className="w-full" />
|
||||
<p
|
||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
||||
<Button
|
||||
variant="link"
|
||||
className="w-full text-muted-foreground"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
app.closeLogin();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</p>
|
||||
<HouseIcon className="mr-1 size-4" />
|
||||
Back to workspace
|
||||
</Button>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useApp } from '@colanode/ui/contexts';
|
||||
import { useApp } from '@colanode/ui/contexts/app';
|
||||
|
||||
interface EmojiElementProps {
|
||||
id: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useApp } from '@colanode/ui/contexts';
|
||||
import { useApp } from '@colanode/ui/contexts/app';
|
||||
|
||||
interface IconElementProps {
|
||||
id: string;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const server = servers.find((server) => server.domain === value);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DropdownMenu
|
||||
@@ -45,21 +43,21 @@ export const ServerDropdown = ({
|
||||
>
|
||||
<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">
|
||||
{server ? (
|
||||
{value ? (
|
||||
<ServerAvatar
|
||||
url={server.avatar}
|
||||
name={server.name}
|
||||
url={value.avatar}
|
||||
name={value.name}
|
||||
className="size-8 rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<ServerOffIcon className="size-8 text-muted-foreground rounded-md" />
|
||||
)}
|
||||
<div className="flex-grow">
|
||||
{server ? (
|
||||
{value ? (
|
||||
<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">
|
||||
{server.domain}
|
||||
{value.domain}
|
||||
</p>
|
||||
</Fragment>
|
||||
) : (
|
||||
@@ -78,8 +76,8 @@ export const ServerDropdown = ({
|
||||
<DropdownMenuItem
|
||||
key={server.domain}
|
||||
onSelect={() => {
|
||||
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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user