mirror of
https://github.com/colanode/colanode.git
synced 2026-02-24 03:49:48 +01:00
Improve authentication flow (#251)
This commit is contained in:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -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/
|
||||
|
||||
Binary file not shown.
@@ -17,7 +17,7 @@ export const accountUpdateRoute: FastifyPluginCallbackZod = (
|
||||
) => {
|
||||
instance.route({
|
||||
method: 'PATCH',
|
||||
url: '/',
|
||||
url: '/me',
|
||||
schema: {
|
||||
body: accountUpdateInputSchema,
|
||||
response: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -23,7 +23,7 @@ export const emailLoginRoute: FastifyPluginCallbackZod = (
|
||||
) => {
|
||||
instance.route({
|
||||
method: 'POST',
|
||||
url: '/emails/login',
|
||||
url: '/email/login',
|
||||
schema: {
|
||||
body: emailLoginInputSchema,
|
||||
response: {
|
||||
@@ -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: {
|
||||
@@ -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: {
|
||||
@@ -26,7 +26,7 @@ export const emailRegisterRoute: FastifyPluginCallbackZod = (
|
||||
) => {
|
||||
instance.route({
|
||||
method: 'POST',
|
||||
url: '/emails/register',
|
||||
url: '/email/register',
|
||||
schema: {
|
||||
body: emailRegisterInputSchema,
|
||||
response: {
|
||||
@@ -20,7 +20,7 @@ export const emailVerifyRoute: FastifyPluginCallbackZod = (
|
||||
) => {
|
||||
instance.route({
|
||||
method: 'POST',
|
||||
url: '/emails/verify',
|
||||
url: '/email/verify',
|
||||
schema: {
|
||||
body: emailVerifyInputSchema,
|
||||
response: {
|
||||
32
apps/server/src/api/client/routes/auth/index.ts
Normal file
32
apps/server/src/api/client/routes/auth/index.ts
Normal 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();
|
||||
};
|
||||
@@ -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
BIN
assets/fonts/antonio.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/fonts/satoshi-variable-italic.woff2
Normal file
BIN
assets/fonts/satoshi-variable-italic.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/satoshi-variable.woff2
Normal file
BIN
assets/fonts/satoshi-variable.woff2
Normal file
Binary file not shown.
@@ -38,7 +38,7 @@ export class AccountUpdateMutationHandler
|
||||
};
|
||||
|
||||
const response = await accountService.client
|
||||
.patch(`v1/accounts`, {
|
||||
.patch(`v1/accounts/me`, {
|
||||
json: body,
|
||||
})
|
||||
.json<AccountUpdateOutput>();
|
||||
|
||||
@@ -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) {
|
||||
@@ -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>();
|
||||
@@ -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;
|
||||
@@ -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>();
|
||||
@@ -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>();
|
||||
@@ -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>();
|
||||
@@ -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>();
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -38,3 +38,4 @@ export * from './types/avatars';
|
||||
export * from './types/build';
|
||||
export * from './lib/servers';
|
||||
export * from './types/storage';
|
||||
export * from './types/auth';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
104
packages/core/src/types/auth.ts
Normal file
104
packages/core/src/types/auth.ts
Normal 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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
|
||||
|
||||
export const LoginTab = () => {
|
||||
return <Tab id="login" avatar="01jhzfk4ppvzdambh6hyw0mv91ic" name="Login" />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
44
packages/ui/src/components/auth/auth-cancel.tsx
Normal file
44
packages/ui/src/components/auth/auth-cancel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
packages/ui/src/components/auth/auth-layout.tsx
Normal file
38
packages/ui/src/components/auth/auth-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
114
packages/ui/src/components/auth/auth-server.tsx
Normal file
114
packages/ui/src/components/auth/auth-server.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
6
packages/ui/src/components/auth/login-tab.tsx
Normal file
6
packages/ui/src/components/auth/login-tab.tsx
Normal 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" />;
|
||||
};
|
||||
198
packages/ui/src/components/auth/login.tsx
Normal file
198
packages/ui/src/components/auth/login.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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" />
|
||||
);
|
||||
@@ -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" />;
|
||||
};
|
||||
6
packages/ui/src/components/auth/register-tab.tsx
Normal file
6
packages/ui/src/components/auth/register-tab.tsx
Normal 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" />;
|
||||
};
|
||||
201
packages/ui/src/components/auth/register.tsx
Normal file
201
packages/ui/src/components/auth/register.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
6
packages/ui/src/components/auth/reset-tab.tsx
Normal file
6
packages/ui/src/components/auth/reset-tab.tsx
Normal 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" />;
|
||||
};
|
||||
174
packages/ui/src/components/auth/reset.tsx
Normal file
174
packages/ui/src/components/auth/reset.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
25
packages/ui/src/components/ui/logo.tsx
Normal file
25
packages/ui/src/components/ui/logo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
packages/ui/src/contexts/auth.ts
Normal file
17
packages/ui/src/contexts/auth.ts
Normal 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;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export const defaultIcons = {
|
||||
bookmark: '01jhzfk3g4q40x7927qcm0hrjdic',
|
||||
folder: '01jhzfk3jrgc276z2gdabm4cwmic',
|
||||
apps: '01jhzfk4m7djqd1pw0e1671cmric',
|
||||
login: '01jhzfk4ppvzdambh6hyw0mv91ic',
|
||||
logout: '01jhzfk4pv13qxjprqgqfeqp73ic',
|
||||
settings: '01jhzfk4ra4fvcay6qgrydgsf5ic',
|
||||
appearance: '01jhzfk39qxa7xtr7z69fyrb2pic',
|
||||
|
||||
10
packages/ui/src/routes/auth/index.tsx
Normal file
10
packages/ui/src/routes/auth/index.tsx
Normal 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,
|
||||
});
|
||||
16
packages/ui/src/routes/auth/login.tsx
Normal file
16
packages/ui/src/routes/auth/login.tsx
Normal 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 />,
|
||||
};
|
||||
},
|
||||
});
|
||||
16
packages/ui/src/routes/auth/register.tsx
Normal file
16
packages/ui/src/routes/auth/register.tsx
Normal 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 />,
|
||||
};
|
||||
},
|
||||
});
|
||||
23
packages/ui/src/routes/auth/reset.tsx
Normal file
23
packages/ui/src/routes/auth/reset.tsx
Normal 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 />,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -17,6 +17,6 @@ export const homeRoute = createRoute({
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect({ to: '/login', replace: true });
|
||||
throw redirect({ to: '/auth/login', replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -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 />,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
38
packages/ui/src/routes/workspace/logout.tsx
Normal file
38
packages/ui/src/routes/workspace/logout.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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?
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user