mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 19:57:46 +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 { 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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
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'
|
# 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
11
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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 { 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),
|
||||||
|
|||||||
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-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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 />
|
||||||
<p className="text-xs text-muted-foreground w-full text-center">
|
<div className="flex flex-col items-center gap-1">
|
||||||
{formattedTime}
|
<p className="text-sm text-muted-foreground w-full text-center">
|
||||||
</p>
|
We sent a verification code to your email.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground w-full text-center">
|
||||||
|
{formattedTime}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
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 { 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,153 +70,130 @@ 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
|
||||||
<EmailLogin
|
value={{
|
||||||
server={server}
|
...server,
|
||||||
onSuccess={(output) => {
|
supports: (feature) => {
|
||||||
if (output.type === 'success') {
|
return isFeatureSupported(feature, server.version);
|
||||||
app.openAccount(output.account.id);
|
},
|
||||||
} else if (output.type === 'verify') {
|
}}
|
||||||
setPanel({
|
>
|
||||||
type: 'verify',
|
<div>
|
||||||
id: output.id,
|
{match(panel)
|
||||||
expiresAt: new Date(output.expiresAt),
|
.with({ type: 'login' }, () => (
|
||||||
});
|
<EmailLogin
|
||||||
}
|
onSuccess={(output) => {
|
||||||
}}
|
if (output.type === 'success') {
|
||||||
onForgotPassword={() => {
|
app.openAccount(output.account.id);
|
||||||
setPanel({
|
} else if (output.type === 'verify') {
|
||||||
type: 'password_reset_init',
|
setPanel({
|
||||||
});
|
type: 'verify',
|
||||||
}}
|
id: output.id,
|
||||||
/>
|
expiresAt: new Date(output.expiresAt),
|
||||||
<p
|
});
|
||||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
}
|
||||||
onClick={() => {
|
}}
|
||||||
setPanel({
|
onForgotPassword={() => {
|
||||||
type: 'register',
|
setPanel({
|
||||||
});
|
type: 'password_reset_init',
|
||||||
}}
|
});
|
||||||
>
|
}}
|
||||||
No account yet? Register
|
onRegister={() => {
|
||||||
</p>
|
setPanel({
|
||||||
</Fragment>
|
type: 'register',
|
||||||
)}
|
});
|
||||||
{server && panel.type === 'register' && (
|
}}
|
||||||
<Fragment>
|
/>
|
||||||
<EmailRegister
|
))
|
||||||
server={server}
|
.with({ type: 'register' }, () => (
|
||||||
onSuccess={(output) => {
|
<EmailRegister
|
||||||
if (output.type === 'success') {
|
onSuccess={(output) => {
|
||||||
app.openAccount(output.account.id);
|
if (output.type === 'success') {
|
||||||
} else if (output.type === 'verify') {
|
app.openAccount(output.account.id);
|
||||||
setPanel({
|
} else if (output.type === 'verify') {
|
||||||
type: 'verify',
|
setPanel({
|
||||||
id: output.id,
|
type: 'verify',
|
||||||
expiresAt: new Date(output.expiresAt),
|
id: output.id,
|
||||||
});
|
expiresAt: new Date(output.expiresAt),
|
||||||
}
|
});
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
<p
|
onLogin={() => {
|
||||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
setPanel({
|
||||||
onClick={() => {
|
type: 'login',
|
||||||
setPanel({
|
});
|
||||||
type: 'login',
|
}}
|
||||||
});
|
/>
|
||||||
}}
|
))
|
||||||
>
|
.with({ type: 'verify' }, (p) => (
|
||||||
Already have an account? Login
|
<EmailVerify
|
||||||
</p>
|
id={p.id}
|
||||||
</Fragment>
|
expiresAt={p.expiresAt}
|
||||||
)}
|
onSuccess={(output) => {
|
||||||
|
if (output.type === 'success') {
|
||||||
{server && panel.type === 'verify' && (
|
app.openAccount(output.account.id);
|
||||||
<Fragment>
|
}
|
||||||
<EmailVerify
|
}}
|
||||||
server={server}
|
onBack={() => {
|
||||||
id={panel.id}
|
setPanel({
|
||||||
expiresAt={panel.expiresAt}
|
type: 'login',
|
||||||
onSuccess={(output) => {
|
});
|
||||||
if (output.type === 'success') {
|
}}
|
||||||
app.openAccount(output.account.id);
|
/>
|
||||||
}
|
))
|
||||||
}}
|
.with({ type: 'password_reset_init' }, () => (
|
||||||
/>
|
<EmailPasswordResetInit
|
||||||
<p
|
onSuccess={(output) => {
|
||||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
setPanel({
|
||||||
onClick={() => {
|
type: 'password_reset_complete',
|
||||||
setPanel({
|
id: output.id,
|
||||||
type: 'login',
|
expiresAt: new Date(output.expiresAt),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
onBack={() => {
|
||||||
Back to login
|
setPanel({
|
||||||
</p>
|
type: 'login',
|
||||||
</Fragment>
|
});
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
{server && panel.type === 'password_reset_init' && (
|
))
|
||||||
<Fragment>
|
.with({ type: 'password_reset_complete' }, (p) => (
|
||||||
<EmailPasswordResetInit
|
<EmailPasswordResetComplete
|
||||||
server={server}
|
id={p.id}
|
||||||
onSuccess={(output) => {
|
expiresAt={p.expiresAt}
|
||||||
setPanel({
|
onBack={() => {
|
||||||
type: 'password_reset_complete',
|
setPanel({
|
||||||
id: output.id,
|
type: 'login',
|
||||||
expiresAt: new Date(output.expiresAt),
|
});
|
||||||
});
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
))
|
||||||
<p
|
.exhaustive()}
|
||||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
</div>
|
||||||
onClick={() => {
|
</ServerContext.Provider>
|
||||||
setPanel({
|
|
||||||
type: 'login',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back to login
|
|
||||||
</p>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{server && panel.type === 'password_reset_complete' && (
|
|
||||||
<Fragment>
|
|
||||||
<EmailPasswordResetComplete
|
|
||||||
server={server}
|
|
||||||
id={panel.id}
|
|
||||||
expiresAt={panel.expiresAt}
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
className="text-center text-sm text-muted-foreground cursor-pointer hover:underline"
|
|
||||||
onClick={() => {
|
|
||||||
setPanel({
|
|
||||||
type: 'login',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back to login
|
|
||||||
</p>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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