Implement login with google (#57)

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

View File

@@ -1,10 +1,11 @@
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import 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;
},

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
import { Migration } from 'kysely';
// This migration is being done for the sake of consistency. We use the full name 'attributes' in other tables,
export const renameAccountAttributesColumn: Migration = {
up: async (db) => {
await db.schema
.alterTable('accounts')
.renameColumn('attrs', 'attributes')
.execute();
},
down: async (db) => {
await db.schema
.alterTable('accounts')
.renameColumn('attributes', 'attrs')
.execute();
},
};

View File

@@ -20,6 +20,7 @@ import { createVectorExtension } from './00017-create-vector-extension';
import { createNodeEmbeddingsTable } from './00018-create-node-embeddings-table';
import { 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,
};

View File

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

View File

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

View File

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

View File

@@ -107,7 +107,11 @@ services:
# Possible values for ACCOUNT_VERIFICATION_TYPE: 'automatic', 'manual', 'email'
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
View File

@@ -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",

View File

@@ -0,0 +1,53 @@
import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base';
import { parseApiError } from '@colanode/client/lib/ky';
import { MutationHandler } from '@colanode/client/lib/types';
import {
GoogleLoginMutationInput,
MutationError,
MutationErrorCode,
} from '@colanode/client/mutations';
import { AppService } from '@colanode/client/services/app-service';
import { GoogleLoginInput, LoginOutput } from '@colanode/core';
export class GoogleLoginMutationHandler
extends AccountMutationHandlerBase
implements MutationHandler<GoogleLoginMutationInput>
{
constructor(appService: AppService) {
super(appService);
}
async handleMutation(input: GoogleLoginMutationInput): Promise<LoginOutput> {
const server = this.app.getServer(input.server);
if (!server) {
throw new MutationError(
MutationErrorCode.ServerNotFound,
`Server ${input.server} was not found! Try using a different server.`
);
}
try {
const body: GoogleLoginInput = {
code: input.code,
};
const response = await this.app.client
.post(`${server.httpBaseUrl}/v1/accounts/google/login`, {
json: body,
})
.json<LoginOutput>();
if (response.type === 'verify') {
return response;
}
await this.handleLoginSuccess(response, server);
return response;
} catch (error) {
const apiError = await parseApiError(error);
throw new MutationError(MutationErrorCode.ApiError, apiError.message);
}
}
}

View File

@@ -11,6 +11,7 @@ import { EmailPasswordResetCompleteMutationHandler } from './accounts/email-pass
import { EmailPasswordResetInitMutationHandler } from './accounts/email-password-reset-init';
import { 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),

View File

@@ -0,0 +1,16 @@
import { LoginOutput } from '@colanode/core';
export type GoogleLoginMutationInput = {
type: 'google.login';
server: string;
code: string;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'google.login': {
input: GoogleLoginMutationInput;
output: LoginOutput;
};
}
}

View File

@@ -7,6 +7,7 @@ export * from './accounts/email-password-reset-complete';
export * from './accounts/email-password-reset-init';
export * from './accounts/email-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';

View File

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

View File

@@ -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) {

View File

@@ -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 = {

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
import { GoogleOAuthProvider, useGoogleLogin } from '@react-oauth/google';
import { toast } from 'sonner';
import { LoginOutput } from '@colanode/core';
import { Button } from '@colanode/ui/components/ui/button';
import { GoogleIcon } from '@colanode/ui/components/ui/icons';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useApp } from '@colanode/ui/contexts/app';
import { useServer } from '@colanode/ui/contexts/server';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface GoogleLoginProps {
context: 'login' | 'register';
onSuccess: (output: LoginOutput) => void;
}
const GoogleLoginButton = ({ context, onSuccess }: GoogleLoginProps) => {
const server = useServer();
const { mutate, isPending } = useMutation();
const login = useGoogleLogin({
onSuccess: async (response) => {
console.log('response', response);
mutate({
input: {
type: 'google.login',
code: response.code,
server: server.domain,
},
onSuccess(output) {
onSuccess(output);
},
onError(error) {
toast.error(error.message);
},
});
},
flow: 'auth-code',
});
return (
<Button
variant="outline"
className="w-full"
onClick={() => login()}
disabled={isPending}
type="button"
>
{isPending ? (
<Spinner className="mr-1 size-4" />
) : (
<GoogleIcon className="mr-1 size-4" />
)}
{context === 'login' ? 'Login' : 'Register'} with Google
</Button>
);
};
export const GoogleLogin = ({ context, onSuccess }: GoogleLoginProps) => {
const app = useApp();
const server = useServer();
const config = server.attributes.account?.google;
if (app.type === 'web' && config && config.enabled && config.clientId) {
return (
<GoogleOAuthProvider clientId={config.clientId}>
<GoogleLoginButton onSuccess={onSuccess} context={context} />
</GoogleOAuthProvider>
);
}
return null;
};

View File

@@ -1,4 +1,6 @@
import { HouseIcon } from 'lucide-react';
import { useState, Fragment, useEffect } from 'react';
import { 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>

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -1,14 +0,0 @@
export * from './account';
export * from './app';
export * from './conversation';
export * from './database-view';
export * from './database-views';
export * from './database';
export * from './emoji-picker';
export * from './folder';
export * from './icon-picker';
export * from './layout';
export * from './radar';
export * from './record';
export * from './server';
export * from './workspace';