Improve authentication flow (#251)

This commit is contained in:
Hakan Shehu
2025-11-13 22:38:19 -08:00
committed by GitHub
parent 239b4d0632
commit b4cd046b53
80 changed files with 1281 additions and 987 deletions

12
.gitignore vendored
View File

@@ -155,7 +155,9 @@ src/scripts/icons/temp/
# Ignore desktop assets
apps/desktop/assets/emojis.db
apps/desktop/assets/icons.db
apps/desktop/assets/fonts/neotrax.otf
apps/desktop/assets/fonts/satoshi-variable.woff2
apps/desktop/assets/fonts/satoshi-variable-italic.woff2
apps/desktop/assets/fonts/antonio.ttf
apps/desktop/assets/colanode-logo.png
apps/desktop/assets/colanode-logo.ico
apps/desktop/assets/colanode-logo.icns
@@ -165,7 +167,9 @@ apps/web/public/assets/emojis.db
apps/web/public/assets/icons.db
apps/web/public/assets/emojis.svg
apps/web/public/assets/icons.svg
apps/web/public/assets/fonts/neotrax.otf
apps/web/public/assets/fonts/satoshi-variable.woff2
apps/web/public/assets/fonts/satoshi-variable-italic.woff2
apps/web/public/assets/fonts/antonio.ttf
apps/web/public/assets/colanode-logo-192.jpg
apps/web/public/assets/colanode-logo-512.jpg
@@ -173,7 +177,9 @@ apps/web/public/assets/colanode-logo-512.jpg
apps/mobile/assets/ui/index.html
apps/mobile/assets/emojis.db
apps/mobile/assets/icons.db
apps/mobile/assets/fonts/neotrax.otf
apps/mobile/assets/fonts/satoshi-variable.woff2
apps/mobile/assets/fonts/satoshi-variable-italic.woff2
apps/mobile/assets/fonts/antonio.ttf
.expo
web-build/

View File

@@ -17,7 +17,7 @@ export const accountUpdateRoute: FastifyPluginCallbackZod = (
) => {
instance.route({
method: 'PATCH',
url: '/',
url: '/me',
schema: {
body: accountUpdateInputSchema,
response: {

View File

@@ -1,36 +1,16 @@
import { FastifyPluginCallback } from 'fastify';
import { accountAuthenticator } from '@colanode/server/api/client/plugins/account-auth';
import { authIpRateLimiter } from '@colanode/server/api/client/plugins/auth-ip-rate-limit';
import { accountSyncRoute } from './account-sync';
import { accountUpdateRoute } from './account-update';
import { emailLoginRoute } from './email-login';
import { emailPasswordResetCompleteRoute } from './email-password-reset-complete';
import { emailPasswordResetInitRoute } from './email-password-reset-init';
import { emailRegisterRoute } from './email-register';
import { emailVerifyRoute } from './email-verify';
import { googleLoginRoute } from './google-login';
import { logoutRoute } from './logout';
export const accountRoutes: FastifyPluginCallback = (instance, _, done) => {
instance.register((subInstance) => {
subInstance.register(authIpRateLimiter);
subInstance.register(emailLoginRoute);
subInstance.register(emailRegisterRoute);
subInstance.register(emailVerifyRoute);
subInstance.register(emailPasswordResetInitRoute);
subInstance.register(emailPasswordResetCompleteRoute);
subInstance.register(googleLoginRoute);
});
instance.register((subInstance) => {
subInstance.register(accountAuthenticator);
subInstance.register(accountSyncRoute);
subInstance.register(accountUpdateRoute);
subInstance.register(logoutRoute);
});
done();

View File

@@ -23,7 +23,7 @@ export const emailLoginRoute: FastifyPluginCallbackZod = (
) => {
instance.route({
method: 'POST',
url: '/emails/login',
url: '/email/login',
schema: {
body: emailLoginInputSchema,
response: {

View File

@@ -21,7 +21,7 @@ export const emailPasswordResetCompleteRoute: FastifyPluginCallbackZod = (
) => {
instance.route({
method: 'POST',
url: '/emails/passwords/reset/complete',
url: '/email/password-reset/complete',
schema: {
body: emailPasswordResetCompleteInputSchema,
response: {

View File

@@ -27,7 +27,7 @@ export const emailPasswordResetInitRoute: FastifyPluginCallbackZod = (
) => {
instance.route({
method: 'POST',
url: '/emails/passwords/reset/init',
url: '/email/password-reset/init',
schema: {
body: emailPasswordResetInitInputSchema,
response: {

View File

@@ -26,7 +26,7 @@ export const emailRegisterRoute: FastifyPluginCallbackZod = (
) => {
instance.route({
method: 'POST',
url: '/emails/register',
url: '/email/register',
schema: {
body: emailRegisterInputSchema,
response: {

View File

@@ -20,7 +20,7 @@ export const emailVerifyRoute: FastifyPluginCallbackZod = (
) => {
instance.route({
method: 'POST',
url: '/emails/verify',
url: '/email/verify',
schema: {
body: emailVerifyInputSchema,
response: {

View File

@@ -0,0 +1,32 @@
import { FastifyPluginCallback } from 'fastify';
import { accountAuthenticator } from '@colanode/server/api/client/plugins/account-auth';
import { authIpRateLimiter } from '@colanode/server/api/client/plugins/auth-ip-rate-limit';
import { emailLoginRoute } from './email-login';
import { emailPasswordResetCompleteRoute } from './email-password-reset-complete';
import { emailPasswordResetInitRoute } from './email-password-reset-init';
import { emailRegisterRoute } from './email-register';
import { emailVerifyRoute } from './email-verify';
import { googleLoginRoute } from './google-login';
import { logoutRoute } from './logout';
export const authRoutes: FastifyPluginCallback = (instance, _, done) => {
instance.register((subInstance) => {
subInstance.register(authIpRateLimiter);
subInstance.register(emailLoginRoute);
subInstance.register(emailRegisterRoute);
subInstance.register(emailVerifyRoute);
subInstance.register(emailPasswordResetInitRoute);
subInstance.register(emailPasswordResetCompleteRoute);
subInstance.register(googleLoginRoute);
});
instance.register((subInstance) => {
subInstance.register(accountAuthenticator);
subInstance.register(logoutRoute);
});
done();
};

View File

@@ -1,6 +1,7 @@
import { FastifyPluginCallback } from 'fastify';
import { accountRoutes } from '@colanode/server/api/client/routes/accounts';
import { authRoutes } from '@colanode/server/api/client/routes/auth';
import { avatarRoutes } from '@colanode/server/api/client/routes/avatars';
import { socketRoutes } from '@colanode/server/api/client/routes/sockets';
import { workspaceRoutes } from '@colanode/server/api/client/routes/workspaces';
@@ -8,6 +9,7 @@ import { workspaceRoutes } from '@colanode/server/api/client/routes/workspaces';
export const clientRoutes: FastifyPluginCallback = (instance, _, done) => {
instance.register(socketRoutes, { prefix: '/sockets' });
instance.register(accountRoutes, { prefix: '/accounts' });
instance.register(authRoutes, { prefix: '/auth' });
instance.register(avatarRoutes, { prefix: '/avatars' });
instance.register(workspaceRoutes, { prefix: '/workspaces' });

BIN
assets/fonts/antonio.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -38,7 +38,7 @@ export class AccountUpdateMutationHandler
};
const response = await accountService.client
.patch(`v1/accounts`, {
.patch(`v1/accounts/me`, {
json: body,
})
.json<AccountUpdateOutput>();

View File

@@ -5,7 +5,7 @@ import { AppService } from '@colanode/client/services/app-service';
import { ServerService } from '@colanode/client/services/server-service';
import { LoginSuccessOutput } from '@colanode/core';
export abstract class AccountMutationHandlerBase {
export abstract class AuthMutationHandlerBase {
protected readonly app: AppService;
constructor(app: AppService) {

View File

@@ -1,13 +1,13 @@
import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base';
import { AuthMutationHandlerBase } from '@colanode/client/handlers/mutations/auth/base';
import { parseApiError } from '@colanode/client/lib/ky';
import { MutationHandler } from '@colanode/client/lib/types';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import { EmailLoginMutationInput } from '@colanode/client/mutations/accounts/email-login';
import { EmailLoginMutationInput } from '@colanode/client/mutations/auth/email-login';
import { AppService } from '@colanode/client/services/app-service';
import { EmailLoginInput, LoginOutput } from '@colanode/core';
export class EmailLoginMutationHandler
extends AccountMutationHandlerBase
extends AuthMutationHandlerBase
implements MutationHandler<EmailLoginMutationInput>
{
constructor(appService: AppService) {
@@ -31,7 +31,7 @@ export class EmailLoginMutationHandler
};
const response = await this.app.client
.post(`${server.httpBaseUrl}/v1/accounts/emails/login`, {
.post(`${server.httpBaseUrl}/v1/auth/email/login`, {
json: body,
})
.json<LoginOutput>();

View File

@@ -1,11 +1,11 @@
import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base';
import { AuthMutationHandlerBase } from '@colanode/client/handlers/mutations/auth/base';
import { MutationHandler } from '@colanode/client/lib';
import { parseApiError } from '@colanode/client/lib/ky';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import {
EmailPasswordResetCompleteMutationInput,
EmailPasswordResetCompleteMutationOutput,
} from '@colanode/client/mutations/accounts/email-password-reset-complete';
} from '@colanode/client/mutations/auth/email-password-reset-complete';
import { AppService } from '@colanode/client/services/app-service';
import {
EmailPasswordResetCompleteInput,
@@ -13,7 +13,7 @@ import {
} from '@colanode/core';
export class EmailPasswordResetCompleteMutationHandler
extends AccountMutationHandlerBase
extends AuthMutationHandlerBase
implements MutationHandler<EmailPasswordResetCompleteMutationInput>
{
constructor(appService: AppService) {
@@ -40,12 +40,9 @@ export class EmailPasswordResetCompleteMutationHandler
};
const response = await this.app.client
.post(
`${server.httpBaseUrl}/v1/accounts/emails/passwords/reset/complete`,
{
json: body,
}
)
.post(`${server.httpBaseUrl}/v1/auth/email/password-reset/complete`, {
json: body,
})
.json<EmailPasswordResetCompleteOutput>();
return response;

View File

@@ -1,11 +1,11 @@
import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base';
import { AuthMutationHandlerBase } from '@colanode/client/handlers/mutations/auth/base';
import { parseApiError } from '@colanode/client/lib/ky';
import { MutationHandler } from '@colanode/client/lib/types';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import {
EmailPasswordResetInitMutationInput,
EmailPasswordResetInitMutationOutput,
} from '@colanode/client/mutations/accounts/email-password-reset-init';
} from '@colanode/client/mutations/auth/email-password-reset-init';
import { AppService } from '@colanode/client/services/app-service';
import {
EmailPasswordResetInitInput,
@@ -13,7 +13,7 @@ import {
} from '@colanode/core';
export class EmailPasswordResetInitMutationHandler
extends AccountMutationHandlerBase
extends AuthMutationHandlerBase
implements MutationHandler<EmailPasswordResetInitMutationInput>
{
constructor(appService: AppService) {
@@ -38,7 +38,7 @@ export class EmailPasswordResetInitMutationHandler
};
const response = await this.app.client
.post(`${server.httpBaseUrl}/v1/accounts/emails/passwords/reset/init`, {
.post(`${server.httpBaseUrl}/v1/auth/email/password-reset/init`, {
json: body,
})
.json<EmailPasswordResetInitOutput>();

View File

@@ -1,13 +1,13 @@
import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base';
import { AuthMutationHandlerBase } from '@colanode/client/handlers/mutations/auth/base';
import { parseApiError } from '@colanode/client/lib/ky';
import { MutationHandler } from '@colanode/client/lib/types';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import { EmailRegisterMutationInput } from '@colanode/client/mutations/accounts/email-register';
import { EmailRegisterMutationInput } from '@colanode/client/mutations/auth/email-register';
import { AppService } from '@colanode/client/services/app-service';
import { EmailRegisterInput, LoginOutput } from '@colanode/core';
export class EmailRegisterMutationHandler
extends AccountMutationHandlerBase
extends AuthMutationHandlerBase
implements MutationHandler<EmailRegisterMutationInput>
{
constructor(appService: AppService) {
@@ -34,7 +34,7 @@ export class EmailRegisterMutationHandler
};
const response = await this.app.client
.post(`${server.httpBaseUrl}/v1/accounts/emails/register`, {
.post(`${server.httpBaseUrl}/v1/auth/email/register`, {
json: body,
})
.json<LoginOutput>();

View File

@@ -1,13 +1,13 @@
import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base';
import { AuthMutationHandlerBase } from '@colanode/client/handlers/mutations/auth/base';
import { parseApiError } from '@colanode/client/lib/ky';
import { MutationHandler } from '@colanode/client/lib/types';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import { EmailVerifyMutationInput } from '@colanode/client/mutations/accounts/email-verify';
import { EmailVerifyMutationInput } from '@colanode/client/mutations/auth/email-verify';
import { AppService } from '@colanode/client/services/app-service';
import { EmailVerifyInput, LoginOutput } from '@colanode/core';
export class EmailVerifyMutationHandler
extends AccountMutationHandlerBase
extends AuthMutationHandlerBase
implements MutationHandler<EmailVerifyMutationInput>
{
constructor(appService: AppService) {
@@ -31,7 +31,7 @@ export class EmailVerifyMutationHandler
};
const response = await this.app.client
.post(`${server.httpBaseUrl}/v1/accounts/emails/verify`, {
.post(`${server.httpBaseUrl}/v1/auth/email/verify`, {
json: body,
})
.json<LoginOutput>();

View File

@@ -1,4 +1,4 @@
import { AccountMutationHandlerBase } from '@colanode/client/handlers/mutations/accounts/base';
import { AuthMutationHandlerBase } from '@colanode/client/handlers/mutations/auth/base';
import { parseApiError } from '@colanode/client/lib/ky';
import { MutationHandler } from '@colanode/client/lib/types';
import {
@@ -10,7 +10,7 @@ import { AppService } from '@colanode/client/services/app-service';
import { GoogleLoginInput, LoginOutput } from '@colanode/core';
export class GoogleLoginMutationHandler
extends AccountMutationHandlerBase
extends AuthMutationHandlerBase
implements MutationHandler<GoogleLoginMutationInput>
{
constructor(appService: AppService) {
@@ -33,7 +33,7 @@ export class GoogleLoginMutationHandler
};
const response = await this.app.client
.post(`${server.httpBaseUrl}/v1/accounts/google/login`, {
.post(`${server.httpBaseUrl}/v1/auth/google/login`, {
json: body,
})
.json<LoginOutput>();

View File

@@ -4,17 +4,17 @@ import { AppService } from '@colanode/client/services';
import { AccountLogoutMutationHandler } from './accounts/account-logout';
import { AccountUpdateMutationHandler } from './accounts/account-update';
import { EmailLoginMutationHandler } from './accounts/email-login';
import { EmailPasswordResetCompleteMutationHandler } from './accounts/email-password-reset-complete';
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 { MetadataDeleteMutationHandler } from './apps/metadata-delete';
import { MetadataUpdateMutationHandler } from './apps/metadata-update';
import { TabCreateMutationHandler } from './apps/tab-create';
import { TabDeleteMutationHandler } from './apps/tab-delete';
import { TabUpdateMutationHandler } from './apps/tab-update';
import { EmailLoginMutationHandler } from './auth/email-login';
import { EmailPasswordResetCompleteMutationHandler } from './auth/email-password-reset-complete';
import { EmailPasswordResetInitMutationHandler } from './auth/email-password-reset-init';
import { EmailRegisterMutationHandler } from './auth/email-register';
import { EmailVerifyMutationHandler } from './auth/email-verify';
import { GoogleLoginMutationHandler } from './auth/google-login';
import { AvatarUploadMutationHandler } from './avatars/avatar-upload';
import { ChannelCreateMutationHandler } from './channels/channel-create';
import { ChannelDeleteMutationHandler } from './channels/channel-delete';

View File

@@ -52,7 +52,7 @@ export class TokenDeleteJobHandler implements JobHandler<TokenDeleteInput> {
}
try {
await this.app.client.delete(`${server.httpBaseUrl}/v1/accounts/logout`, {
await this.app.client.delete(`${server.httpBaseUrl}/v1/auth/logout`, {
headers: {
Authorization: `Bearer ${input.token}`,
},

View File

@@ -1,11 +1,11 @@
export * from './accounts/account-logout';
export * from './accounts/account-update';
export * from './accounts/email-login';
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 './auth/email-login';
export * from './auth/email-password-reset-complete';
export * from './auth/email-password-reset-init';
export * from './auth/email-register';
export * from './auth/email-verify';
export * from './auth/google-login';
export * from './apps/metadata-delete';
export * from './apps/metadata-update';
export * from './avatars/avatar-upload';

View File

@@ -38,3 +38,4 @@ export * from './types/avatars';
export * from './types/build';
export * from './lib/servers';
export * from './types/storage';
export * from './types/auth';

View File

@@ -1,7 +1,5 @@
import { z } from 'zod/v4';
import { workspaceOutputSchema } from '@colanode/core/types/workspaces';
export enum AccountStatus {
Pending = 0,
Active = 1,
@@ -31,103 +29,3 @@ export const accountUpdateOutputSchema = z.object({
});
export type AccountUpdateOutput = z.infer<typeof accountUpdateOutputSchema>;
export const emailRegisterInputSchema = z.object({
name: z.string({ error: 'Name is required' }),
email: z.string({ error: 'Email is required' }).email({
message: 'Invalid email address',
}),
password: z.string({ error: 'Password is required' }),
});
export type EmailRegisterInput = z.infer<typeof emailRegisterInputSchema>;
export const emailLoginInputSchema = z.object({
email: z.string({ error: 'Email is required' }).email({
message: 'Invalid email address',
}),
password: z.string({ error: 'Password is required' }),
});
export type EmailLoginInput = z.infer<typeof emailLoginInputSchema>;
export const loginSuccessOutputSchema = z.object({
type: z.literal('success'),
account: accountOutputSchema,
workspaces: z.array(workspaceOutputSchema),
deviceId: z.string(),
token: z.string(),
});
export type LoginSuccessOutput = z.infer<typeof loginSuccessOutputSchema>;
export const loginVerifyOutputSchema = z.object({
type: z.literal('verify'),
id: z.string(),
expiresAt: z.date(),
});
export type LoginVerifyOutput = z.infer<typeof loginVerifyOutputSchema>;
export const loginOutputSchema = z.discriminatedUnion('type', [
loginSuccessOutputSchema,
loginVerifyOutputSchema,
]);
export type LoginOutput = z.infer<typeof loginOutputSchema>;
export const accountSyncOutputSchema = z.object({
account: accountOutputSchema,
workspaces: z.array(workspaceOutputSchema),
token: z.string().optional(),
});
export type AccountSyncOutput = z.infer<typeof accountSyncOutputSchema>;
export const emailVerifyInputSchema = z.object({
id: z.string(),
otp: z.string(),
});
export type EmailVerifyInput = z.infer<typeof emailVerifyInputSchema>;
export const emailPasswordResetInitInputSchema = z.object({
email: z.email(),
});
export type EmailPasswordResetInitInput = z.infer<
typeof emailPasswordResetInitInputSchema
>;
export const emailPasswordResetCompleteInputSchema = z.object({
id: z.string(),
otp: z.string(),
password: z.string(),
});
export type EmailPasswordResetCompleteInput = z.infer<
typeof emailPasswordResetCompleteInputSchema
>;
export const emailPasswordResetInitOutputSchema = z.object({
id: z.string(),
expiresAt: z.date(),
});
export type EmailPasswordResetInitOutput = z.infer<
typeof emailPasswordResetInitOutputSchema
>;
export const emailPasswordResetCompleteOutputSchema = z.object({
success: z.boolean(),
});
export type EmailPasswordResetCompleteOutput = z.infer<
typeof emailPasswordResetCompleteOutputSchema
>;
export const googleLoginInputSchema = z.object({
code: z.string(),
});
export type GoogleLoginInput = z.infer<typeof googleLoginInputSchema>;

View File

@@ -0,0 +1,104 @@
import { z } from 'zod/v4';
import { accountOutputSchema } from '@colanode/core/types/accounts';
import { workspaceOutputSchema } from '@colanode/core/types/workspaces';
export const emailRegisterInputSchema = z.object({
name: z.string({ error: 'Name is required' }),
email: z.string({ error: 'Email is required' }).email({
message: 'Invalid email address',
}),
password: z.string({ error: 'Password is required' }),
});
export type EmailRegisterInput = z.infer<typeof emailRegisterInputSchema>;
export const emailLoginInputSchema = z.object({
email: z.string({ error: 'Email is required' }).email({
message: 'Invalid email address',
}),
password: z.string({ error: 'Password is required' }),
});
export type EmailLoginInput = z.infer<typeof emailLoginInputSchema>;
export const loginSuccessOutputSchema = z.object({
type: z.literal('success'),
account: accountOutputSchema,
workspaces: z.array(workspaceOutputSchema),
deviceId: z.string(),
token: z.string(),
});
export type LoginSuccessOutput = z.infer<typeof loginSuccessOutputSchema>;
export const loginVerifyOutputSchema = z.object({
type: z.literal('verify'),
id: z.string(),
expiresAt: z.date(),
});
export type LoginVerifyOutput = z.infer<typeof loginVerifyOutputSchema>;
export const loginOutputSchema = z.discriminatedUnion('type', [
loginSuccessOutputSchema,
loginVerifyOutputSchema,
]);
export type LoginOutput = z.infer<typeof loginOutputSchema>;
export const accountSyncOutputSchema = z.object({
account: accountOutputSchema,
workspaces: z.array(workspaceOutputSchema),
token: z.string().optional(),
});
export type AccountSyncOutput = z.infer<typeof accountSyncOutputSchema>;
export const emailVerifyInputSchema = z.object({
id: z.string(),
otp: z.string(),
});
export type EmailVerifyInput = z.infer<typeof emailVerifyInputSchema>;
export const emailPasswordResetInitInputSchema = z.object({
email: z.email(),
});
export type EmailPasswordResetInitInput = z.infer<
typeof emailPasswordResetInitInputSchema
>;
export const emailPasswordResetCompleteInputSchema = z.object({
id: z.string(),
otp: z.string(),
password: z.string(),
});
export type EmailPasswordResetCompleteInput = z.infer<
typeof emailPasswordResetCompleteInputSchema
>;
export const emailPasswordResetInitOutputSchema = z.object({
id: z.string(),
expiresAt: z.date(),
});
export type EmailPasswordResetInitOutput = z.infer<
typeof emailPasswordResetInitOutputSchema
>;
export const emailPasswordResetCompleteOutputSchema = z.object({
success: z.boolean(),
});
export type EmailPasswordResetCompleteOutput = z.infer<
typeof emailPasswordResetCompleteOutputSchema
>;
export const googleLoginInputSchema = z.object({
code: z.string(),
});
export type GoogleLoginInput = z.infer<typeof googleLoginInputSchema>;

View File

@@ -1,241 +0,0 @@
import { useLiveQuery } from '@tanstack/react-db';
import { useRouter } from '@tanstack/react-router';
import { HouseIcon } from 'lucide-react';
import { useState, Fragment, useEffect, useCallback } from 'react';
import { match } from 'ts-pattern';
import { isFeatureSupported } from '@colanode/client/lib';
import { LoginSuccessOutput } from '@colanode/core';
import { collections } from '@colanode/ui/collections';
import { EmailLogin } from '@colanode/ui/components/accounts/email-login';
import { EmailPasswordResetComplete } from '@colanode/ui/components/accounts/email-password-reset-complete';
import { EmailPasswordResetInit } from '@colanode/ui/components/accounts/email-password-reset-init';
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 { ServerContext } from '@colanode/ui/contexts/server';
type LoginPanelState = {
type: 'login';
};
type RegisterPanelState = {
type: 'register';
};
type VerifyPanelState = {
type: 'verify';
id: string;
expiresAt: Date;
};
type PasswordResetInitPanelState = {
type: 'password_reset_init';
};
type PasswordResetCompletePanelState = {
type: 'password_reset_complete';
id: string;
expiresAt: Date;
};
type PanelState =
| LoginPanelState
| RegisterPanelState
| VerifyPanelState
| PasswordResetInitPanelState
| PasswordResetCompletePanelState;
export const LoginForm = () => {
const router = useRouter();
const serversQuery = useLiveQuery((q) =>
q.from({ servers: collections.servers })
);
const servers = serversQuery.data;
const workspacesQuery = useLiveQuery((q) =>
q.from({ workspaces: collections.workspaces }).select(({ workspaces }) => ({
userId: workspaces.userId,
}))
);
const workspaces = workspacesQuery.data;
const [serverDomain, setServerDomain] = useState<string | null>(
servers[0]?.domain ?? null
);
const [panel, setPanel] = useState<PanelState>({
type: 'login',
});
useEffect(() => {
const serverExists =
serverDomain !== null && servers.some((s) => s.domain === serverDomain);
if (!serverExists && servers.length > 0) {
setServerDomain(servers[0]!.domain);
}
}, [serverDomain, servers]);
const server = serverDomain
? servers.find((s) => s.domain === serverDomain)
: null;
const handleLoginSuccess = useCallback(
(output: LoginSuccessOutput) => {
const workspace = output.workspaces[0];
if (workspace) {
router.navigate({
to: '/workspace/$userId',
params: { userId: workspace.user.id },
});
} else {
router.navigate({
to: '/create',
});
}
},
[router]
);
return (
<div className="flex flex-col gap-4">
<ServerDropdown
value={serverDomain}
onChange={(serverDomain) => {
setServerDomain(serverDomain);
}}
servers={servers}
readonly={panel.type === 'verify'}
/>
{server && (
<ServerContext.Provider
value={{
...server,
supports: (feature) => {
return isFeatureSupported(feature, server.version);
},
}}
>
<div>
{match(panel)
.with({ type: 'login' }, () => (
<EmailLogin
onSuccess={(output) => {
if (output.type === 'success') {
handleLoginSuccess(output);
} else if (output.type === 'verify') {
setPanel({
type: 'verify',
id: output.id,
expiresAt: new Date(output.expiresAt),
});
}
}}
onForgotPassword={() => {
setPanel({
type: 'password_reset_init',
});
}}
onRegister={() => {
setPanel({
type: 'register',
});
}}
/>
))
.with({ type: 'register' }, () => (
<EmailRegister
onSuccess={(output) => {
if (output.type === 'success') {
handleLoginSuccess(output);
} else if (output.type === 'verify') {
setPanel({
type: 'verify',
id: output.id,
expiresAt: new Date(output.expiresAt),
});
}
}}
onLogin={() => {
setPanel({
type: 'login',
});
}}
/>
))
.with({ type: 'verify' }, (p) => (
<EmailVerify
id={p.id}
expiresAt={p.expiresAt}
onSuccess={(output) => {
if (output.type === 'success') {
handleLoginSuccess(output);
}
}}
onBack={() => {
setPanel({
type: 'login',
});
}}
/>
))
.with({ type: 'password_reset_init' }, () => (
<EmailPasswordResetInit
onSuccess={(output) => {
setPanel({
type: 'password_reset_complete',
id: output.id,
expiresAt: new Date(output.expiresAt),
});
}}
onBack={() => {
setPanel({
type: 'login',
});
}}
/>
))
.with({ type: 'password_reset_complete' }, (p) => (
<EmailPasswordResetComplete
id={p.id}
expiresAt={p.expiresAt}
onBack={() => {
setPanel({
type: 'login',
});
}}
/>
))
.exhaustive()}
</div>
</ServerContext.Provider>
)}
{workspaces.length > 0 && (
<Fragment>
<Separator className="w-full" />
<Button
variant="link"
className="w-full text-muted-foreground"
type="button"
onClick={() => {
if (router.history.canGoBack()) {
router.history.back();
} else {
router.navigate({
to: '/workspace/$userId',
params: { userId: workspaces[0]!.userId },
});
}
}}
>
<HouseIcon className="mr-1 size-4" />
Back to workspace
</Button>
</Fragment>
)}
</div>
);
};

View File

@@ -1,5 +0,0 @@
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
export const LoginTab = () => {
return <Tab id="login" avatar="01jhzfk4ppvzdambh6hyw0mv91ic" name="Login" />;
};

View File

@@ -1,24 +0,0 @@
import { LoginForm } from '@colanode/ui/components/accounts/login-form';
export const Login = () => {
return (
<div className="grid h-screen min-h-screen w-full grid-cols-1 lg:grid-cols-5">
<div className="items-center justify-center bg-foreground hidden lg:flex lg:col-span-2">
<h1 className="font-neotrax text-6xl text-background">colanode</h1>
</div>
<div className="flex items-center justify-center py-12 lg:col-span-3">
<div className="mx-auto grid w-96 gap-6">
<div className="grid gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Login to Colanode
</h1>
<p className="text-sm text-muted-foreground">
Use one of the following methods to login
</p>
</div>
<LoginForm />
</div>
</div>
</div>
);
};

View File

@@ -2,22 +2,41 @@ import { useApp } from '@colanode/ui/contexts/app';
export const AppAssets = () => {
const app = useApp();
const fontUrl =
app.type === 'web'
? `/assets/fonts/neotrax.otf`
: `local://fonts/neotrax.otf`;
const fontPrefix = app.type === 'web' ? `/assets/fonts` : `local://fonts`;
return (
<style>{`
@font-face {
font-family: 'neotrax';
src: url('${fontUrl}') format('truetype');
font-family: "satoshi";
src: url('${fontPrefix}/satoshi-variable.woff2') format("woff2-variations"),
url('${fontPrefix}/satoshi-variable.woff2') format("woff2");
font-weight: 300 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "satoshi";
src: url('${fontPrefix}/satoshi-variable-italic.woff2') format("woff2-variations"),
url('${fontPrefix}/satoshi-variable-italic.woff2') format("woff2");
font-weight: 300 900;
font-style: italic;
font-display: swap;
}
.font-satoshi {
font-family: 'satoshi';
}
@font-face {
font-family: 'antonio';
src: url('${fontPrefix}/antonio.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
.font-neotrax {
font-family: 'neotrax', serif;
.font-antonio {
font-family: 'antonio';
}
`}</style>
);

View File

@@ -6,7 +6,7 @@ export const AppLoading = () => {
<div className="min-w-screen flex h-full min-h-screen w-full items-center justify-center">
<DelayedComponent>
<div className="flex flex-col items-center gap-8 text-center">
<h2 className="font-neotrax text-4xl">loading your workspaces</h2>
<h2 className="font-satoshi text-4xl">loading your workspaces</h2>
<div>
<Spinner />
</div>

View File

@@ -0,0 +1,44 @@
import { useLiveQuery } from '@tanstack/react-db';
import { useRouter } from '@tanstack/react-router';
import { Home } from 'lucide-react';
import { collections } from '@colanode/ui/collections';
import { Button } from '@colanode/ui/components/ui/button';
import { getDefaultWorkspaceUserId } from '@colanode/ui/routes/utils';
export const AuthCancel = () => {
const router = useRouter();
const workspacesQuery = useLiveQuery((q) =>
q.from({ workspaces: collections.workspaces }).select(({ workspaces }) => ({
userId: workspaces.userId,
}))
);
const workspaces = workspacesQuery.data;
if (workspaces.length === 0) {
return null;
}
return (
<Button
variant="ghost"
size="icon"
className="absolute left-5 top-5"
type="button"
onClick={() => {
const defaultWorkspaceUserId = getDefaultWorkspaceUserId();
if (!defaultWorkspaceUserId) {
return;
}
router.navigate({
to: '/workspace/$userId',
params: { userId: defaultWorkspaceUserId },
});
}}
>
<Home className="size-4" />
</Button>
);
};

View File

@@ -0,0 +1,38 @@
import { Outlet } from '@tanstack/react-router';
import { useState } from 'react';
import { Server } from '@colanode/client/types';
import { AuthCancel } from '@colanode/ui/components/auth/auth-cancel';
import { AuthServer } from '@colanode/ui/components/auth/auth-server';
import { ColanodeLogo } from '@colanode/ui/components/ui/logo';
import { AuthContext } from '@colanode/ui/contexts/auth';
export const AuthLayout = () => {
const [server, setServer] = useState<Server | null>(null);
return (
<div className="relative flex min-h-screen w-full items-center justify-center">
<AuthCancel />
<div className="w-full flex lg:flex-row flex-col items-center justify-center lg:gap-40 gap-20">
<div className="flex flex-col items-center justify-center bg-background px-6 py-12">
<div className="flex flex-row items-center">
<ColanodeLogo className="size-16 lg:size-50" />
<p className="font-antonio text-3xl">
Your all-in-one <br /> collaboration platform
</p>
</div>
</div>
<div className="w-96 max-w-xl flex flex-col items-center justify-center bg-background">
{server ? (
<AuthContext.Provider value={{ server }}>
<Outlet />
</AuthContext.Provider>
) : (
<AuthServer onSelect={setServer} />
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import { useLiveQuery } from '@tanstack/react-db';
import { PlusIcon, SettingsIcon } from 'lucide-react';
import { useState } from 'react';
import { Server } from '@colanode/client/types';
import { collections } from '@colanode/ui/collections';
import { ServerAvatar } from '@colanode/ui/components/servers/server-avatar';
import { ServerCreateDialog } from '@colanode/ui/components/servers/server-create-dialog';
import { ServerDeleteDialog } from '@colanode/ui/components/servers/server-delete-dialog';
import { ServerSettingsDialog } from '@colanode/ui/components/servers/server-settings-dialog';
interface AuthServerProps {
onSelect: (server: Server) => void;
}
export const AuthServer = ({ onSelect }: AuthServerProps) => {
const [openCreate, setOpenCreate] = useState(false);
const [settingsDomain, setSettingsDomain] = useState<string | null>(null);
const [deleteDomain, setDeleteDomain] = useState<string | null>(null);
const serversQuery = useLiveQuery((q) =>
q.from({ servers: collections.servers })
);
const servers = serversQuery.data ?? [];
const settingsServer = servers.find(
(server) => server.domain === settingsDomain
);
const deleteServer = servers.find((server) => server.domain === deleteDomain);
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight text-center">
{servers.length > 0 ? 'Select a server' : 'Add a server'}
</h1>
<p className="text-sm text-muted-foreground">
{servers.length > 0
? 'Choose the server you want to connect to'
: 'Add a server to get started'}
</p>
</div>
<div className="flex flex-col gap-4">
{servers.map((server) => (
<button
key={server.domain}
onClick={() => onSelect(server)}
className="group/server relative flex w-full flex-row items-center gap-3 rounded-lg border border-border/60 bg-background p-2 text-left transition-all hover:cursor-pointer hover:border-border hover:bg-accent hover:shadow-md"
>
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-8 rounded-md"
/>
<div className="grow">
<p className="grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">{server.domain}</p>
</div>
<button
className="text-muted-foreground opacity-0 group-hover/server:opacity-100 hover:bg-input size-8 flex items-center justify-center rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setSettingsDomain(server.domain);
}}
>
<SettingsIcon className="size-4" />
</button>
</button>
))}
<button
onClick={() => setOpenCreate(true)}
className="group/server relative flex w-full flex-row items-center gap-2 rounded-lg border border-dashed border-border/60 bg-background p-2 text-left transition-all hover:cursor-pointer hover:border-border hover:bg-accent hover:shadow-md"
>
<div className="flex size-8 items-center justify-center rounded-lg border border-border/60 bg-muted/50">
<PlusIcon className="size-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-normal text-foreground">
Add a new server
</p>
</div>
</button>
</div>
{openCreate && (
<ServerCreateDialog onCancel={() => setOpenCreate(false)} />
)}
{deleteServer && (
<ServerDeleteDialog
server={deleteServer}
open={!!deleteServer}
onOpenChange={(open) => {
if (!open) {
setDeleteDomain(null);
}
}}
/>
)}
{settingsServer && (
<ServerSettingsDialog
server={settingsServer}
open={!!settingsServer}
onOpenChange={(open) => {
if (!open) {
setSettingsDomain(null);
}
}}
onDelete={() => {
setSettingsDomain(null);
setDeleteDomain(settingsServer.domain);
}}
/>
)}
</div>
);
};

View File

@@ -1,11 +1,9 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from '@tanstack/react-router';
import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
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,
@@ -17,28 +15,19 @@ import {
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({
email: z.string().min(2).email(),
password: z.string().min(8),
});
interface EmailLoginProps {
onSuccess: (output: LoginOutput) => void;
onForgotPassword: () => void;
onRegister: () => void;
interface LoginFormProps {
isPending: boolean;
onSubmit: (values: z.infer<typeof formSchema>) => void;
}
export const EmailLogin = ({
onSuccess,
onForgotPassword,
onRegister,
}: EmailLoginProps) => {
const server = useServer();
const { mutate, isPending } = useMutation();
export const LoginForm = ({ isPending, onSubmit }: LoginFormProps) => {
const navigate = useNavigate();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -47,26 +36,9 @@ export const EmailLogin = ({
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
mutate({
input: {
type: 'email.login',
email: values.email,
password: values.password,
server: server.domain,
},
onSuccess(output) {
onSuccess(output);
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
@@ -93,7 +65,9 @@ export const EmailLogin = ({
<Label htmlFor="password">Password</Label>
<p
className="text-xs text-muted-foreground cursor-pointer hover:underline w-full text-right"
onClick={onForgotPassword}
onClick={() => {
navigate({ to: '/auth/reset' });
}}
>
Forgot password?
</p>
@@ -123,15 +97,6 @@ export const EmailLogin = ({
)}
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

@@ -1,8 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckCircle, Lock } from 'lucide-react';
import { useState } from 'react';
import { Lock } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { Button } from '@colanode/ui/components/ui/button';
@@ -16,9 +14,7 @@ import {
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';
const formSchema = z
.object({
@@ -39,20 +35,17 @@ const formSchema = z
path: ['confirmPassword'], // path of error
});
interface EmailPasswordResetCompleteProps {
id: string;
interface PasswordResetCompleteFormProps {
expiresAt: Date;
onBack: () => void;
isPending: boolean;
onSubmit: (values: z.infer<typeof formSchema>) => void;
}
export const EmailPasswordResetComplete = ({
id,
export const PasswordResetCompleteForm = ({
expiresAt,
onBack,
}: EmailPasswordResetCompleteProps) => {
const server = useServer();
const { mutate, isPending } = useMutation();
isPending,
onSubmit,
}: PasswordResetCompleteFormProps) => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -62,57 +55,8 @@ export const EmailPasswordResetComplete = ({
},
});
const [showSuccess, setShowSuccess] = useState(false);
const [remainingSeconds, formattedTime] = useCountdown(expiresAt);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (remainingSeconds <= 0) {
toast.error('Code has expired');
return;
}
mutate({
input: {
type: 'email.password.reset.complete',
otp: values.otp,
password: values.password,
server: server.domain,
id: id,
},
onSuccess() {
setShowSuccess(true);
},
onError(error) {
toast.error(error.message);
},
});
};
if (showSuccess) {
return (
<div className="space-y-3">
<div className="flex flex-col items-center justify-center border border-border rounded-md p-4 gap-3 text-center">
<CheckCircle className="size-7 text-green-600" />
<p className="text-sm text-muted-foreground">
Your password has been reset. You can now login with your new
password.
</p>
<p className="text-sm font-semibold text-muted-foreground">
You have been logged out of all devices.
</p>
</div>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onBack}
type="button"
>
Back to login
</Button>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
@@ -172,7 +116,7 @@ export const EmailPasswordResetComplete = ({
type="submit"
variant="outline"
className="w-full"
disabled={isPending}
disabled={isPending || remainingSeconds <= 0}
>
{isPending ? (
<Spinner className="mr-1 size-4" />
@@ -181,14 +125,6 @@ export const EmailPasswordResetComplete = ({
)}
Reset password
</Button>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onBack}
type="button"
>
Back to login
</Button>
</form>
</Form>
);

View File

@@ -1,10 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { EmailPasswordResetInitOutput } from '@colanode/core';
import { Button } from '@colanode/ui/components/ui/button';
import {
Form,
@@ -16,25 +14,20 @@ import {
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({
email: z.string().min(2).email(),
});
interface EmailPasswordResetInitProps {
onSuccess: (output: EmailPasswordResetInitOutput) => void;
onBack: () => void;
interface PasswordResetInitFormProps {
isPending: boolean;
onSubmit: (values: z.infer<typeof formSchema>) => void;
}
export const EmailPasswordResetInit = ({
onSuccess,
onBack,
}: EmailPasswordResetInitProps) => {
const server = useServer();
const { mutate, isPending } = useMutation();
export const PasswordResetInitForm = ({
isPending,
onSubmit,
}: PasswordResetInitFormProps) => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -42,25 +35,9 @@ export const EmailPasswordResetInit = ({
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
mutate({
input: {
type: 'email.password.reset.init',
email: values.email,
server: server.domain,
},
onSuccess(output) {
onSuccess(output);
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-3">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="email"
@@ -87,14 +64,6 @@ export const EmailPasswordResetInit = ({
)}
Reset password
</Button>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onBack}
type="button"
>
Back to login
</Button>
</form>
</Form>
);

View File

@@ -1,11 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
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,
@@ -17,8 +14,6 @@ import {
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({
@@ -40,15 +35,12 @@ const formSchema = z
path: ['confirmPassword'], // path of error
});
interface EmailRegisterProps {
onSuccess: (output: LoginOutput) => void;
onLogin: () => void;
interface RegisterFormProps {
isPending: boolean;
onSubmit: (values: z.infer<typeof formSchema>) => void;
}
export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => {
const server = useServer();
const { mutate, isPending } = useMutation();
export const RegisterForm = ({ isPending, onSubmit }: RegisterFormProps) => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -59,24 +51,6 @@ export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => {
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
mutate({
input: {
type: 'email.register',
name: values.name,
email: values.email,
password: values.password,
server: server.domain,
},
onSuccess(output) {
onSuccess(output);
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
@@ -159,15 +133,6 @@ export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => {
)}
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

@@ -1,10 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { LoginOutput } from '@colanode/core';
import { Button } from '@colanode/ui/components/ui/button';
import {
Form,
@@ -16,30 +14,23 @@ import {
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';
const formSchema = z.object({
otp: z.string().min(2),
});
interface EmailVerifyProps {
id: string;
interface EmailVerifyFormProps {
expiresAt: Date;
onSuccess: (output: LoginOutput) => void;
onBack: () => void;
isPending: boolean;
onSubmit: (values: z.infer<typeof formSchema>) => void;
}
export const EmailVerify = ({
id,
export const EmailVerifyForm = ({
expiresAt,
onSuccess,
onBack,
}: EmailVerifyProps) => {
const server = useServer();
const { mutate, isPending } = useMutation();
isPending,
onSubmit,
}: EmailVerifyFormProps) => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -49,31 +40,9 @@ export const EmailVerify = ({
const [remainingSeconds, formattedTime] = useCountdown(expiresAt);
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
if (remainingSeconds <= 0) {
toast.error('Code has expired');
return;
}
mutate({
input: {
type: 'email.verify',
otp: values.otp,
server: server.domain,
id,
},
onSuccess(output) {
onSuccess(output);
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="otp"
@@ -108,14 +77,6 @@ export const EmailVerify = ({
)}
Confirm
</Button>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={onBack}
type="button"
>
Back to login
</Button>
</form>
</Form>
);

View File

@@ -1,38 +1,25 @@
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';
import { useAuth } from '@colanode/ui/contexts/auth';
interface GoogleLoginProps {
context: 'login' | 'register';
onSuccess: (output: LoginOutput) => void;
onLogin: (code: string) => void;
isPending: boolean;
}
const GoogleLoginButton = ({ context, onSuccess }: GoogleLoginProps) => {
const server = useServer();
const { mutate, isPending } = useMutation();
const GoogleLoginButton = ({
context,
onLogin,
isPending,
}: GoogleLoginProps) => {
const login = useGoogleLogin({
onSuccess: async (response) => {
mutate({
input: {
type: 'google.login',
code: response.code,
server: server.domain,
},
onSuccess(output) {
onSuccess(output);
},
onError(error) {
toast.error(error.message);
},
});
onLogin(response.code);
},
flow: 'auth-code',
});
@@ -55,15 +42,23 @@ const GoogleLoginButton = ({ context, onSuccess }: GoogleLoginProps) => {
);
};
export const GoogleLogin = ({ context, onSuccess }: GoogleLoginProps) => {
export const GoogleLogin = ({
context,
onLogin,
isPending,
}: GoogleLoginProps) => {
const app = useApp();
const server = useServer();
const config = server.attributes.account?.google;
const auth = useAuth();
const config = auth.server.attributes.account?.google;
if (app.type === 'web' && config && config.enabled && config.clientId) {
return (
<GoogleOAuthProvider clientId={config.clientId}>
<GoogleLoginButton onSuccess={onSuccess} context={context} />
<GoogleLoginButton
context={context}
onLogin={onLogin}
isPending={isPending}
/>
</GoogleOAuthProvider>
);
}

View File

@@ -0,0 +1,6 @@
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
import { defaultIcons } from '@colanode/ui/lib/assets';
export const LoginTab = () => {
return <Tab id="login" avatar={defaultIcons.login} name="Login" />;
};

View File

@@ -0,0 +1,198 @@
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { toast } from 'sonner';
import { LoginOutput } from '@colanode/core';
import { LoginForm } from '@colanode/ui/components/auth/email-login-form';
import { EmailVerifyForm } from '@colanode/ui/components/auth/email-verify-form';
import { GoogleLogin } from '@colanode/ui/components/auth/google-login';
import { Button } from '@colanode/ui/components/ui/button';
import { useAuth } from '@colanode/ui/contexts/auth';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
type LoginState =
| {
type: 'login';
}
| {
type: 'verify';
id: string;
expiresAt: Date;
};
export const Login = () => {
const navigate = useNavigate();
const auth = useAuth();
const [state, setState] = useState<LoginState>({ type: 'login' });
const { mutate: mutateEmailLogin, isPending: isEmailLoginPending } =
useMutation();
const { mutate: mutateEmailVerify, isPending: isEmailVerifyPending } =
useMutation();
const { mutate: mutateGoogleLogin, isPending: isGoogleLoginPending } =
useMutation();
const handleLoginSubmit = async (values: {
email: string;
password: string;
}) => {
if (isEmailLoginPending) return;
if (isEmailVerifyPending || isGoogleLoginPending) return;
if (state.type !== 'login') return;
mutateEmailLogin({
input: {
type: 'email.login',
email: values.email,
password: values.password,
server: auth.server.domain,
},
onSuccess(output) {
if (output.type === 'success') {
if (output.workspaces.length > 0) {
navigate({
to: '/workspace/$userId',
params: { userId: output.workspaces[0]!.user.id },
});
} else {
navigate({ to: '/create' });
}
} else if (output.type === 'verify') {
setState({
type: 'verify',
id: output.id,
expiresAt: output.expiresAt,
});
}
},
onError(error) {
toast.error(error.message);
},
});
};
const handleVerifySubmit = async (values: { otp: string }) => {
if (isEmailVerifyPending) return;
if (isEmailLoginPending || isGoogleLoginPending) return;
if (state.type !== 'verify') return;
mutateEmailVerify({
input: {
type: 'email.verify',
otp: values.otp,
server: auth.server.domain,
id: state.id,
},
onSuccess(output: LoginOutput) {
if (output.type === 'success') {
navigate({
to: '/workspace/$userId',
params: { userId: output.workspaces[0]!.user.id },
});
} else if (output.type === 'verify') {
setState({
type: 'verify',
id: output.id,
expiresAt: output.expiresAt,
});
}
},
onError(error) {
toast.error(error.message);
},
});
};
const handleGoogleLogin = async (code: string) => {
if (isGoogleLoginPending) return;
if (isEmailLoginPending || isEmailVerifyPending) return;
if (state.type !== 'login') return;
mutateGoogleLogin({
input: { type: 'google.login', code, server: auth.server.domain },
onSuccess(output) {
if (output.type === 'success') {
if (output.workspaces.length > 0) {
navigate({
to: '/workspace/$userId',
params: { userId: output.workspaces[0]!.user.id },
});
} else {
navigate({ to: '/create' });
}
} else if (output.type === 'verify') {
setState({
type: 'verify',
id: output.id,
expiresAt: output.expiresAt,
});
}
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{state.type === 'login'
? 'Login to your account'
: 'Verify your email'}
</h1>
<p className="text-sm text-muted-foreground">
{state.type === 'login'
? 'Enter your email and password to login to your account'
: 'Enter the code sent to your email'}
</p>
</div>
<div className="flex flex-col gap-6">
{state.type === 'login' && (
<>
<LoginForm
onSubmit={handleLoginSubmit}
isPending={isEmailLoginPending}
/>
<GoogleLogin
context="login"
onLogin={handleGoogleLogin}
isPending={isGoogleLoginPending}
/>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={() => {
navigate({ to: '/auth/register' });
}}
type="button"
>
No account yet? Register
</Button>
</>
)}
{state.type === 'verify' && (
<>
<EmailVerifyForm
onSubmit={handleVerifySubmit}
isPending={isEmailVerifyPending}
expiresAt={state.expiresAt}
/>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={() => {
setState({ type: 'login' });
}}
type="button"
>
Back to login
</Button>
</>
)}
</div>
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
export const AccountLogoutContainer = () => {
export const LogoutContainer = () => {
const workspace = useWorkspace();
const navigate = useNavigate();
const { mutate, isPending } = useMutation();

View File

@@ -1,7 +1,7 @@
import { BreadcrumbItem } from '@colanode/ui/components/layouts/containers/breadcrumb-item';
import { defaultIcons } from '@colanode/ui/lib/assets';
export const AccountLogoutHeader = () => {
export const LogoutHeader = () => {
return (
<BreadcrumbItem id="logout" avatar={defaultIcons.logout} name="Logout" />
);

View File

@@ -1,6 +1,6 @@
import { TabItem } from '@colanode/ui/components/layouts/tabs/tab-item';
import { defaultIcons } from '@colanode/ui/lib/assets';
export const AccountLogoutTab = () => {
export const LogoutTab = () => {
return <TabItem id="logout" avatar={defaultIcons.logout} name="Logout" />;
};

View File

@@ -0,0 +1,6 @@
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
import { defaultIcons } from '@colanode/ui/lib/assets';
export const RegisterTab = () => {
return <Tab id="register" avatar={defaultIcons.login} name="Register" />;
};

View File

@@ -0,0 +1,201 @@
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { toast } from 'sonner';
import { LoginOutput } from '@colanode/core';
import { RegisterForm } from '@colanode/ui/components/auth/email-register-form';
import { EmailVerifyForm } from '@colanode/ui/components/auth/email-verify-form';
import { GoogleLogin } from '@colanode/ui/components/auth/google-login';
import { Button } from '@colanode/ui/components/ui/button';
import { useAuth } from '@colanode/ui/contexts/auth';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
type RegisterState =
| {
type: 'register';
}
| {
type: 'verify';
id: string;
expiresAt: Date;
};
export const Register = () => {
const navigate = useNavigate();
const auth = useAuth();
const [state, setState] = useState<RegisterState>({ type: 'register' });
const { mutate: mutateEmailRegister, isPending: isEmailRegisterPending } =
useMutation();
const { mutate: mutateEmailVerify, isPending: isEmailVerifyPending } =
useMutation();
const { mutate: mutateGoogleRegister, isPending: isGoogleRegisterPending } =
useMutation();
const handleRegisterSubmit = async (values: {
name: string;
email: string;
password: string;
confirmPassword: string;
}) => {
if (isEmailRegisterPending) return;
if (isEmailVerifyPending || isGoogleRegisterPending) return;
if (state.type !== 'register') return;
mutateEmailRegister({
input: {
type: 'email.register',
name: values.name,
email: values.email,
password: values.password,
server: auth.server.domain,
},
onSuccess(output) {
if (output.type === 'success') {
if (output.workspaces.length > 0) {
navigate({
to: '/workspace/$userId',
params: { userId: output.workspaces[0]!.user.id },
});
} else {
navigate({ to: '/create' });
}
} else if (output.type === 'verify') {
setState({
type: 'verify',
id: output.id,
expiresAt: output.expiresAt,
});
}
},
onError(error) {
toast.error(error.message);
},
});
};
const handleVerifySubmit = async (values: { otp: string }) => {
if (isEmailVerifyPending) return;
if (isEmailRegisterPending || isGoogleRegisterPending) return;
if (state.type !== 'verify') return;
mutateEmailVerify({
input: {
type: 'email.verify',
otp: values.otp,
server: auth.server.domain,
id: state.id,
},
onSuccess(output: LoginOutput) {
if (output.type === 'success') {
navigate({
to: '/workspace/$userId',
params: { userId: output.workspaces[0]!.user.id },
});
} else if (output.type === 'verify') {
setState({
type: 'verify',
id: output.id,
expiresAt: output.expiresAt,
});
}
},
onError(error) {
toast.error(error.message);
},
});
};
const handleGoogleRegister = async (code: string) => {
if (isGoogleRegisterPending) return;
if (isEmailRegisterPending || isEmailVerifyPending) return;
if (state.type !== 'register') return;
mutateGoogleRegister({
input: { type: 'google.login', code, server: auth.server.domain },
onSuccess(output) {
if (output.type === 'success') {
if (output.workspaces.length > 0) {
navigate({
to: '/workspace/$userId',
params: { userId: output.workspaces[0]!.user.id },
});
} else {
navigate({ to: '/create' });
}
} else if (output.type === 'verify') {
setState({
type: 'verify',
id: output.id,
expiresAt: output.expiresAt,
});
}
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{state.type === 'register'
? 'Create an account'
: 'Verify your email'}
</h1>
<p className="text-sm text-muted-foreground">
{state.type === 'register'
? 'Sign up to get started with Colanode'
: 'Enter the code sent to your email'}
</p>
</div>
<div className="flex flex-col gap-4">
{state.type === 'register' && (
<>
<RegisterForm
onSubmit={handleRegisterSubmit}
isPending={isEmailRegisterPending}
/>
<GoogleLogin
context="register"
onLogin={handleGoogleRegister}
isPending={isGoogleRegisterPending}
/>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={() => {
navigate({ to: '/auth/login' });
}}
type="button"
>
Already have an account? Login
</Button>
</>
)}
{state.type === 'verify' && (
<>
<EmailVerifyForm
onSubmit={handleVerifySubmit}
isPending={isEmailVerifyPending}
expiresAt={state.expiresAt}
/>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={() => {
setState({ type: 'register' });
}}
type="button"
>
Back to register
</Button>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,6 @@
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
import { defaultIcons } from '@colanode/ui/lib/assets';
export const ResetTab = () => {
return <Tab id="reset" avatar={defaultIcons.login} name="Reset Password" />;
};

View File

@@ -0,0 +1,174 @@
import { useNavigate } from '@tanstack/react-router';
import { CheckCircle } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { EmailPasswordResetInitOutput } from '@colanode/core';
import { PasswordResetCompleteForm } from '@colanode/ui/components/auth/email-password-reset-complete-form';
import { PasswordResetInitForm } from '@colanode/ui/components/auth/email-password-reset-init-form';
import { Button } from '@colanode/ui/components/ui/button';
import { useAuth } from '@colanode/ui/contexts/auth';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
type ResetState =
| {
type: 'init';
}
| {
type: 'complete';
id: string;
expiresAt: Date;
}
| {
type: 'success';
};
export const Reset = () => {
const navigate = useNavigate();
const auth = useAuth();
const [state, setState] = useState<ResetState>({ type: 'init' });
const {
mutate: mutatePasswordResetInit,
isPending: isPasswordResetInitPending,
} = useMutation();
const {
mutate: mutatePasswordResetComplete,
isPending: isPasswordResetCompletePending,
} = useMutation();
const handleInitSubmit = async (values: { email: string }) => {
if (isPasswordResetInitPending) return;
if (isPasswordResetCompletePending) return;
if (state.type !== 'init') return;
mutatePasswordResetInit({
input: {
type: 'email.password.reset.init',
email: values.email,
server: auth.server.domain,
},
onSuccess(output: EmailPasswordResetInitOutput) {
setState({
type: 'complete',
id: output.id,
expiresAt: output.expiresAt,
});
},
onError(error) {
toast.error(error.message);
},
});
};
const handleCompleteSubmit = async (values: {
otp: string;
password: string;
confirmPassword: string;
}) => {
if (isPasswordResetCompletePending) return;
if (isPasswordResetInitPending) return;
if (state.type !== 'complete') return;
mutatePasswordResetComplete({
input: {
type: 'email.password.reset.complete',
otp: values.otp,
password: values.password,
server: auth.server.domain,
id: state.id,
},
onSuccess() {
setState({ type: 'success' });
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<div className="flex flex-col gap-6 w-full">
<div className="grid gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{state.type === 'init'
? 'Reset your password'
: state.type === 'complete'
? 'Reset your password'
: 'Password reset successful'}
</h1>
<p className="text-sm text-muted-foreground">
{state.type === 'init'
? 'Enter your email to receive a password reset code'
: state.type === 'complete'
? 'Enter the code sent to your email and your new password'
: 'Your password has been reset. You can now login with your new password.'}
</p>
</div>
<div className="flex flex-col gap-4">
{state.type === 'init' && (
<>
<PasswordResetInitForm
onSubmit={handleInitSubmit}
isPending={isPasswordResetInitPending}
/>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={() => {
navigate({ to: '/auth/login' });
}}
type="button"
>
Back to login
</Button>
</>
)}
{state.type === 'complete' && (
<>
<PasswordResetCompleteForm
onSubmit={handleCompleteSubmit}
isPending={isPasswordResetCompletePending}
expiresAt={state.expiresAt}
/>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={() => {
setState({ type: 'init' });
}}
type="button"
>
Back to email input
</Button>
</>
)}
{state.type === 'success' && (
<>
<div className="flex flex-col items-center justify-center border border-border rounded-md p-4 gap-3 text-center">
<CheckCircle className="size-7 text-green-600" />
<p className="text-sm text-muted-foreground">
Your password has been reset. You can now login with your new
password.
</p>
<p className="text-sm font-semibold text-muted-foreground">
You have been logged out of all devices.
</p>
</div>
<Button
variant="link"
className="w-full text-muted-foreground"
onClick={() => {
navigate({ to: '/auth/login' });
}}
type="button"
>
Back to login
</Button>
</>
)}
</div>
</div>
);
};

View File

@@ -120,7 +120,7 @@ export function SidebarMenuFooter() {
<DropdownMenuItem
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
onClick={() => {
navigate({ to: '/login' });
navigate({ to: '/auth/login' });
}}
>
<Plus className="size-4" />

View File

@@ -121,7 +121,7 @@ export const SidebarSettings = () => {
</div>
<div className="flex w-full min-w-0 flex-col gap-1">
<Separator className="my-2" />
<Link from="/workspace/$userId" to="account/logout">
<Link from="/workspace/$userId" to="logout">
{({ isActive }) => (
<SidebarSettingsItem
title="Logout"

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { Server } from '@colanode/client/types';
import { Button } from '@colanode/ui/components/ui/button';
import {
Dialog,
@@ -18,13 +17,9 @@ import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface ServerCreateDialogProps {
onCancel: () => void;
onCreate: (server: Server) => void;
}
export const ServerCreateDialog = ({
onCancel,
onCreate,
}: ServerCreateDialogProps) => {
export const ServerCreateDialog = ({ onCancel }: ServerCreateDialogProps) => {
const [open, setOpen] = useState(true);
const { mutate, isPending } = useMutation();
const [url, setUrl] = useState('');
@@ -63,9 +58,8 @@ export const ServerCreateDialog = ({
type: 'server.create',
url,
},
onSuccess(output) {
onCreate(output.server);
toast.success('Server added successfully');
onSuccess() {
setOpen(false);
},
onError(error) {
toast.error(error.message);

View File

@@ -1,168 +0,0 @@
import {
ChevronDown,
PlusIcon,
ServerOffIcon,
SettingsIcon,
} from 'lucide-react';
import { Fragment, useState } from 'react';
import { Server } from '@colanode/client/types';
import { ServerAvatar } from '@colanode/ui/components/servers/server-avatar';
import { ServerCreateDialog } from '@colanode/ui/components/servers/server-create-dialog';
import { ServerDeleteDialog } from '@colanode/ui/components/servers/server-delete-dialog';
import { ServerSettingsDialog } from '@colanode/ui/components/servers/server-settings-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@colanode/ui/components/ui/dropdown-menu';
interface ServerDropdownProps {
value: string | null;
onChange: (server: string) => void;
servers: Server[];
readonly?: boolean;
}
export const ServerDropdown = ({
value,
onChange,
servers,
readonly = false,
}: ServerDropdownProps) => {
const [open, setOpen] = useState(false);
const [openCreate, setOpenCreate] = useState(false);
const [settingsDomain, setSettingsDomain] = useState<string | null>(null);
const [deleteDomain, setDeleteDomain] = useState<string | null>(null);
const server = value
? servers.find((server) => server.domain === value)
: null;
const settingsServer = servers.find(
(server) => server.domain === settingsDomain
);
const deleteServer = servers.find((server) => server.domain === deleteDomain);
return (
<Fragment>
<DropdownMenu
open={open}
onOpenChange={(openValue) => {
if (!readonly) {
setOpen(openValue);
}
}}
>
<DropdownMenuTrigger asChild>
<div className="flex w-full grow flex-row items-center gap-3 rounded-md border border-input p-2 cursor-pointer hover:bg-accent">
{server ? (
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-8 rounded-md"
/>
) : (
<ServerOffIcon className="size-8 text-muted-foreground rounded-md" />
)}
<div className="grow">
{server ? (
<Fragment>
<p className="grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">
{server.domain}
</p>
</Fragment>
) : (
<p className="grow text-muted-foreground">Select a server</p>
)}
</div>
<ChevronDown className="size-4 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-96">
{servers.map((server) => (
<DropdownMenuItem
key={server.domain}
onSelect={() => {
if (value !== server.domain) {
onChange(server.domain);
}
}}
className="group/server flex w-full grow flex-row items-center gap-3 p-2 cursor-pointer hover:bg-accent"
>
<div className="flex grow items-center gap-3">
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-8 rounded-md"
/>
<div className="grow">
<p className="grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">
{server.domain}
</p>
</div>
</div>
<button
className="text-muted-foreground opacity-0 group-hover/server:opacity-100 hover:bg-input size-8 flex items-center justify-center rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setSettingsDomain(server.domain);
}}
>
<SettingsIcon className="size-4" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setOpenCreate(true);
}}
className="py-2"
>
<PlusIcon className="size-4" />
Add new server
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{openCreate && (
<ServerCreateDialog
onCancel={() => setOpenCreate(false)}
onCreate={() => {
setOpenCreate(false);
}}
/>
)}
{deleteServer && (
<ServerDeleteDialog
server={deleteServer}
open={!!deleteServer}
onOpenChange={(open) => {
if (!open) {
setDeleteDomain(null);
}
}}
/>
)}
{settingsServer && (
<ServerSettingsDialog
server={settingsServer}
open={!!settingsServer}
onOpenChange={(open) => {
if (!open) {
setSettingsDomain(null);
}
}}
onDelete={() => {
setSettingsDomain(null);
setDeleteDomain(settingsServer.domain);
}}
/>
)}
</Fragment>
);
};

View File

@@ -175,16 +175,23 @@ export const ServerSettingsDialog = ({
{canDelete && (
<div className="border rounded-lg p-4">
<h3 className="text-sm mb-3">Delete server from this device</h3>
<Button
variant="destructive"
onClick={() => {
onDelete();
}}
>
<TrashIcon className="size-4 mr-1" />
Delete
</Button>
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<h3 className="text-sm font-semibold">
Delete server from this device
</h3>
<div className="w-full md:w-auto md:shrink-0">
<Button
variant="destructive"
className="w-full md:w-auto cursor-pointer"
onClick={() => {
onDelete();
}}
>
<TrashIcon className="size-4 mr-1" />
Delete
</Button>
</div>
</div>
</div>
)}
</DialogContent>

View File

@@ -0,0 +1,25 @@
type ColanodeLogoProps = React.HTMLAttributes<SVGElement>;
export const ColanodeLogo = (props: ColanodeLogoProps) => {
return (
<svg
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 180 180"
width="100"
height="100"
fill="currentColor"
{...props}
>
<path
fill="currentColor"
d="M97.68,141.49v13.74c0,1.85,2.12,2.9,3.59,1.78l44.14-33.58c1.99-1.52,3.17-3.88,3.17-6.39v-40.96c0-2.51-1.18-4.88-3.18-6.4l-26.49-20.05,27.27-9.61c1.44-.53,2.4-1.9,2.4-3.43v-11.69c0-1.67-1.67-2.82-3.23-2.22l-43.8,16.81c-2.34.95-3.86,3.22-3.86,5.75v50.63c0,1.91,2.19,3,3.71,1.84l9.32-7.1c1.47-1.12,2.33-2.86,2.33-4.71v-19.61l15.42,11.73c2.02,1.54,3.21,3.94,3.21,6.48v24.09c0,2.54-1.19,4.94-3.21,6.48l-28.41,21.62c-1.5,1.14-2.37,2.91-2.37,4.79Z"
/>
<path
fill="currentColor"
d="M82.32,141.49v13.74c0,1.85-2.12,2.9-3.59,1.78l-44.14-33.58c-1.99-1.52-3.17-3.88-3.17-6.39v-40.96c0-2.51,1.18-4.88,3.18-6.4l26.49-20.05-27.27-9.61c-1.44-.53-2.4-1.9-2.4-3.43v-11.69c0-1.67,1.67-2.82,3.23-2.22l43.8,16.81c2.34.95,3.86,3.22,3.86,5.75v50.63c0,1.91-2.19,3-3.71,1.84l-9.32-7.1c-1.47-1.12-2.33-2.86-2.33-4.71v-19.61l-15.42,11.73c-2.02,1.54-3.21,3.94-3.21,6.48v24.09c0,2.54,1.19,4.94,3.21,6.48l28.41,21.62c1.5,1.14,2.37,2.91,2.37,4.79Z"
/>
</svg>
);
};

View File

@@ -0,0 +1,17 @@
import { createContext, useContext } from 'react';
import { Server } from '@colanode/client/types';
export interface AuthContextValue {
server: Server;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthLayout');
}
return context;
};

View File

@@ -12,6 +12,7 @@ export const defaultIcons = {
bookmark: '01jhzfk3g4q40x7927qcm0hrjdic',
folder: '01jhzfk3jrgc276z2gdabm4cwmic',
apps: '01jhzfk4m7djqd1pw0e1671cmric',
login: '01jhzfk4ppvzdambh6hyw0mv91ic',
logout: '01jhzfk4pv13qxjprqgqfeqp73ic',
settings: '01jhzfk4ra4fvcay6qgrydgsf5ic',
appearance: '01jhzfk39qxa7xtr7z69fyrb2pic',

View File

@@ -0,0 +1,10 @@
import { createRoute } from '@tanstack/react-router';
import { AuthLayout } from '@colanode/ui/components/auth/auth-layout';
import { rootRoute } from '@colanode/ui/routes/root';
export const authRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth',
component: AuthLayout,
});

View File

@@ -0,0 +1,16 @@
import { createRoute } from '@tanstack/react-router';
import { Login } from '@colanode/ui/components/auth/login';
import { LoginTab } from '@colanode/ui/components/auth/login-tab';
import { authRoute } from '@colanode/ui/routes/auth';
export const loginRoute = createRoute({
getParentRoute: () => authRoute,
path: '/login',
component: Login,
context: () => {
return {
tab: <LoginTab />,
};
},
});

View File

@@ -0,0 +1,16 @@
import { createRoute } from '@tanstack/react-router';
import { Register } from '@colanode/ui/components/auth/register';
import { RegisterTab } from '@colanode/ui/components/auth/register-tab';
import { authRoute } from '@colanode/ui/routes/auth';
export const registerRoute = createRoute({
getParentRoute: () => authRoute,
path: '/register',
component: Register,
context: () => {
return {
tab: <RegisterTab />,
};
},
});

View File

@@ -0,0 +1,23 @@
import { createRoute } from '@tanstack/react-router';
import { z } from 'zod/v4';
import { Reset } from '@colanode/ui/components/auth/reset';
import { ResetTab } from '@colanode/ui/components/auth/reset-tab';
import { authRoute } from '@colanode/ui/routes/auth';
const resetSearchSchema = z.object({
id: z.string().optional(),
expiresAt: z.string().optional(),
});
export const resetRoute = createRoute({
getParentRoute: () => authRoute,
path: '/reset',
component: Reset,
validateSearch: resetSearchSchema,
context: () => {
return {
tab: <ResetTab />,
};
},
});

View File

@@ -17,6 +17,6 @@ export const homeRoute = createRoute({
});
}
throw redirect({ to: '/login', replace: true });
throw redirect({ to: '/auth/login', replace: true });
},
});

View File

@@ -1,17 +1,16 @@
import { createRouter } from '@tanstack/react-router';
import { authRoute } from '@colanode/ui/routes/auth';
import { loginRoute } from '@colanode/ui/routes/auth/login';
import { registerRoute } from '@colanode/ui/routes/auth/register';
import { resetRoute } from '@colanode/ui/routes/auth/reset';
import { workspaceCreateRoute } from '@colanode/ui/routes/create';
import { homeRoute } from '@colanode/ui/routes/home';
import { loginRoute } from '@colanode/ui/routes/login';
import { rootRoute } from '@colanode/ui/routes/root';
import {
workspaceRoute,
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
import {
accountLogoutMaskRoute,
accountLogoutRoute,
} from '@colanode/ui/routes/workspace/account-logout';
import {
accountSettingsMaskRoute,
accountSettingsRoute,
@@ -28,6 +27,10 @@ import {
workspaceHomeMaskRoute,
workspaceHomeRoute,
} from '@colanode/ui/routes/workspace/home';
import {
logoutMaskRoute,
logoutRoute,
} from '@colanode/ui/routes/workspace/logout';
import { nodeMaskRoute, nodeRoute } from '@colanode/ui/routes/workspace/node';
import {
workspaceRedirectMaskRoute,
@@ -52,7 +55,7 @@ import {
export const routeTree = rootRoute.addChildren([
homeRoute,
loginRoute,
authRoute.addChildren([loginRoute, registerRoute, resetRoute]),
workspaceCreateRoute,
workspaceRoute.addChildren([
workspaceRedirectRoute,
@@ -64,7 +67,7 @@ export const routeTree = rootRoute.addChildren([
workspaceUsersRoute,
workspaceSettingsRoute,
accountSettingsRoute,
accountLogoutRoute,
logoutRoute,
appAppearanceRoute,
]),
workspaceMaskRoute.addChildren([
@@ -77,7 +80,7 @@ export const routeTree = rootRoute.addChildren([
workspaceUploadsMaskRoute,
workspaceDownloadsMaskRoute,
accountSettingsMaskRoute,
accountLogoutMaskRoute,
logoutMaskRoute,
appAppearanceMaskRoute,
]),
]);

View File

@@ -1,16 +0,0 @@
import { createRoute } from '@tanstack/react-router';
import { Login } from '@colanode/ui/components/accounts/login';
import { LoginTab } from '@colanode/ui/components/accounts/login-tab';
import { rootRoute } from '@colanode/ui/routes/root';
export const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: Login,
context: () => {
return {
tab: <LoginTab />,
};
},
});

View File

@@ -113,8 +113,8 @@ export const accountSettingsRouteMask = createRouteMask({
export const accountLogoutRouteMask = createRouteMask({
routeTree: routeTree,
from: '/workspace/$userId/account/logout',
to: '/$workspaceId/account/logout',
from: '/workspace/$userId/logout',
to: '/$workspaceId/logout',
params: (ctx) => {
const workspace = collections.workspaces.get(ctx.userId);
return {

View File

@@ -1,38 +0,0 @@
import { createRoute, redirect } from '@tanstack/react-router';
import { AccountLogoutContainer } from '@colanode/ui/components/accounts/account-logout-container';
import { AccountLogoutHeader } from '@colanode/ui/components/accounts/account-logout-header';
import { AccountLogoutTab } from '@colanode/ui/components/accounts/account-logout-tab';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
workspaceRoute,
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
export const accountLogoutRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/account/logout',
component: AccountLogoutContainer,
context: () => {
return {
tab: <AccountLogoutTab />,
header: <AccountLogoutHeader />,
};
},
});
export const accountLogoutMaskRoute = createRoute({
getParentRoute: () => workspaceMaskRoute,
path: '/account/logout',
component: () => null,
beforeLoad: (ctx) => {
const userId = getWorkspaceUserId(ctx.params.workspaceId);
if (userId) {
throw redirect({
to: '/workspace/$userId/account/logout',
params: { userId },
replace: true,
});
}
},
});

View File

@@ -0,0 +1,38 @@
import { createRoute, redirect } from '@tanstack/react-router';
import { LogoutContainer } from '@colanode/ui/components/auth/logout-container';
import { LogoutHeader } from '@colanode/ui/components/auth/logout-header';
import { LogoutTab } from '@colanode/ui/components/auth/logout-tab';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
workspaceRoute,
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
export const logoutRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/logout',
component: LogoutContainer,
context: () => {
return {
tab: <LogoutTab />,
header: <LogoutHeader />,
};
},
});
export const logoutMaskRoute = createRoute({
getParentRoute: () => workspaceMaskRoute,
path: '/logout',
component: () => null,
beforeLoad: (ctx) => {
const userId = getWorkspaceUserId(ctx.params.workspaceId);
if (userId) {
throw redirect({
to: '/workspace/$userId/logout',
params: { userId },
replace: true,
});
}
},
});

View File

@@ -10,14 +10,14 @@ The script copies different types of assets to different Colanode apps based on
- **Full databases**: `emojis.db` and `icons.db` (with SVG BLOBs) for offline capability
- **SVG sprites**: `emojis.svg` and `icons.svg` for efficient rendering
- **Fonts**: Custom fonts like `neotrax.otf`
- **Fonts**: Custom fonts like `satoshi-variable.woff2`
- **Images**: Application logos and icons in various formats (.png, .ico, .icns)
**Web Application (`apps/web/public/assets/`)**:
- **Minimal databases**: `emojis.min.db` and `icons.min.db` (renamed to `emojis.db` and `icons.db`) without SVG BLOBs for faster loading
- **SVG sprites**: `emojis.svg` and `icons.svg` for rendering (since SVG data isn't in the minimal databases)
- **Fonts**: Custom fonts like `neotrax.otf`
- **Fonts**: Custom fonts like `satoshi-variable.woff2`
- **Images**: Web-specific logo files in PNG format
## Why This Approach?

View File

@@ -14,9 +14,16 @@ const ICONS_DB_PATH = path.resolve(ICONS_DIR, 'icons.db');
const ICONS_MIN_DB_PATH = path.resolve(ICONS_DIR, 'icons.min.db');
const ICONS_SVG_PATH = path.resolve(ICONS_DIR, 'icons.svg');
const NEOTRAX_FONT_NAME = 'neotrax.otf';
const SATOSHI_FONT_NAME = 'satoshi-variable.woff2';
const SATOSHI_ITALIC_FONT_NAME = 'satoshi-variable-italic.woff2';
const ANTONIO_FONT_NAME = 'antonio.ttf';
const FONTS_DIR = path.resolve(ASSETS_DIR, 'fonts');
const FONTS_OTF_PATH = path.resolve(FONTS_DIR, NEOTRAX_FONT_NAME);
const FONTS_SATOSHI_PATH = path.resolve(FONTS_DIR, SATOSHI_FONT_NAME);
const FONTS_SATOSHI_ITALIC_PATH = path.resolve(
FONTS_DIR,
SATOSHI_ITALIC_FONT_NAME
);
const FONTS_ANTONIO_PATH = path.resolve(FONTS_DIR, ANTONIO_FONT_NAME);
const DESKTOP_ASSETS_DIR = path.resolve('apps', 'desktop', 'assets');
const WEB_PUBLIC_DIR = path.resolve('apps', 'web', 'public');
@@ -51,10 +58,22 @@ const execute = () => {
copyFile(ICONS_MIN_DB_PATH, path.resolve(WEB_ASSETS_DIR, 'icons.db'));
copyFile(ICONS_SVG_PATH, path.resolve(WEB_ASSETS_DIR, 'icons.svg'));
copyFile(FONTS_OTF_PATH, [
path.resolve(DESKTOP_ASSETS_DIR, 'fonts', NEOTRAX_FONT_NAME),
path.resolve(WEB_ASSETS_DIR, 'fonts', NEOTRAX_FONT_NAME),
path.resolve(MOBILE_ASSETS_DIR, 'fonts', NEOTRAX_FONT_NAME),
copyFile(FONTS_SATOSHI_PATH, [
path.resolve(DESKTOP_ASSETS_DIR, 'fonts', SATOSHI_FONT_NAME),
path.resolve(WEB_ASSETS_DIR, 'fonts', SATOSHI_FONT_NAME),
path.resolve(MOBILE_ASSETS_DIR, 'fonts', SATOSHI_FONT_NAME),
]);
copyFile(FONTS_SATOSHI_ITALIC_PATH, [
path.resolve(DESKTOP_ASSETS_DIR, 'fonts', SATOSHI_ITALIC_FONT_NAME),
path.resolve(WEB_ASSETS_DIR, 'fonts', SATOSHI_ITALIC_FONT_NAME),
path.resolve(MOBILE_ASSETS_DIR, 'fonts', SATOSHI_ITALIC_FONT_NAME),
]);
copyFile(FONTS_ANTONIO_PATH, [
path.resolve(DESKTOP_ASSETS_DIR, 'fonts', ANTONIO_FONT_NAME),
path.resolve(WEB_ASSETS_DIR, 'fonts', ANTONIO_FONT_NAME),
path.resolve(MOBILE_ASSETS_DIR, 'fonts', ANTONIO_FONT_NAME),
]);
copyFile(