From 9e6d53d1473db264c2c92090e61844abec0f7a37 Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Wed, 11 Jun 2025 16:13:48 +0200 Subject: [PATCH] Allow users to delete custom servers (#48) --- apps/server/src/app.ts | 4 +- apps/web/src/services/kysely-service.ts | 6 +- .../client/src/handlers/mutations/index.ts | 2 + .../mutations/servers/server-delete.ts | 35 ++++++ .../handlers/queries/servers/server-list.ts | 8 ++ packages/client/src/mutations/index.ts | 3 +- .../src/mutations/servers/server-delete.ts | 17 +++ packages/client/src/services/app-service.ts | 44 ++++++- packages/client/src/types/events.ts | 8 +- packages/core/src/index.ts | 1 + packages/core/src/lib/servers.ts | 3 + .../src/components/accounts/email-login.tsx | 5 +- .../email-password-reset-complete.tsx | 5 +- .../accounts/email-password-reset-init.tsx | 5 +- .../components/accounts/email-register.tsx | 5 +- .../src/components/accounts/email-verify.tsx | 5 +- .../ui/src/components/accounts/login-form.tsx | 24 ++-- .../servers/server-create-dialog.tsx | 1 + .../servers/server-delete-dialog.tsx | 72 +++++++++++ .../components/servers/server-dropdown.tsx | 117 +++++++++++++----- 20 files changed, 309 insertions(+), 61 deletions(-) create mode 100644 packages/client/src/handlers/mutations/servers/server-delete.ts create mode 100644 packages/client/src/mutations/servers/server-delete.ts create mode 100644 packages/core/src/lib/servers.ts create mode 100644 packages/ui/src/components/servers/server-delete-dialog.tsx diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6c327398..6cdbba8b 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -10,6 +10,7 @@ import { apiRoutes } from '@colanode/server/api'; import { clientDecorator } from '@colanode/server/api/client/plugins/client'; import { corsPlugin } from '@colanode/server/api/client/plugins/cors'; import { errorHandler } from '@colanode/server/api/client/plugins/error-handler'; +import { config } from '@colanode/server/lib/config'; const debug = createDebugger('server:app'); @@ -34,6 +35,7 @@ export const initApp = () => { process.exit(1); } - debug(`Server is running at ${address}`); + const path = config.server.pathPrefix ? `/${config.server.pathPrefix}` : ''; + debug(`Server is running at ${address}${path}`); }); }; diff --git a/apps/web/src/services/kysely-service.ts b/apps/web/src/services/kysely-service.ts index cc9d2b87..98098ceb 100644 --- a/apps/web/src/services/kysely-service.ts +++ b/apps/web/src/services/kysely-service.ts @@ -139,7 +139,11 @@ class SqliteWasmDriver implements Driver { } async destroy(): Promise { - this.database?.close(); + try { + this.database?.close(); + } catch { + // Ignore errors + } } private buildImportDbPath(): string { diff --git a/packages/client/src/handlers/mutations/index.ts b/packages/client/src/handlers/mutations/index.ts index 753ee84a..29cb59ba 100644 --- a/packages/client/src/handlers/mutations/index.ts +++ b/packages/client/src/handlers/mutations/index.ts @@ -58,6 +58,7 @@ import { RecordFieldValueDeleteMutationHandler } from './records/record-field-va import { RecordFieldValueSetMutationHandler } from './records/record-field-value-set'; import { RecordNameUpdateMutationHandler } from './records/record-name-update'; import { ServerCreateMutationHandler } from './servers/server-create'; +import { ServerDeleteMutationHandler } from './servers/server-delete'; import { SpaceAvatarUpdateMutationHandler } from './spaces/space-avatar-update'; import { SpaceCreateMutationHandler } from './spaces/space-create'; import { SpaceDeleteMutationHandler } from './spaces/space-delete'; @@ -114,6 +115,7 @@ export const buildMutationHandlerMap = ( 'select.option.delete': new SelectOptionDeleteMutationHandler(app), 'select.option.update': new SelectOptionUpdateMutationHandler(app), 'server.create': new ServerCreateMutationHandler(app), + 'server.delete': new ServerDeleteMutationHandler(app), 'space.create': new SpaceCreateMutationHandler(app), 'space.delete': new SpaceDeleteMutationHandler(app), 'user.role.update': new UserRoleUpdateMutationHandler(app), diff --git a/packages/client/src/handlers/mutations/servers/server-delete.ts b/packages/client/src/handlers/mutations/servers/server-delete.ts new file mode 100644 index 00000000..54829481 --- /dev/null +++ b/packages/client/src/handlers/mutations/servers/server-delete.ts @@ -0,0 +1,35 @@ +import { MutationHandler } from '@colanode/client/lib/types'; +import { MutationError, MutationErrorCode } from '@colanode/client/mutations'; +import { + ServerDeleteMutationInput, + ServerDeleteMutationOutput, +} from '@colanode/client/mutations/servers/server-delete'; +import { AppService } from '@colanode/client/services/app-service'; +import { isColanodeServer } from '@colanode/core'; + +export class ServerDeleteMutationHandler + implements MutationHandler +{ + private readonly app: AppService; + + constructor(app: AppService) { + this.app = app; + } + + async handleMutation( + input: ServerDeleteMutationInput + ): Promise { + if (isColanodeServer(input.domain)) { + throw new MutationError( + MutationErrorCode.ServerDeleteForbidden, + 'Cannot delete Colanode server' + ); + } + + await this.app.deleteServer(input.domain); + + return { + success: true, + }; + } +} diff --git a/packages/client/src/handlers/queries/servers/server-list.ts b/packages/client/src/handlers/queries/servers/server-list.ts index f09f5335..d194a13a 100644 --- a/packages/client/src/handlers/queries/servers/server-list.ts +++ b/packages/client/src/handlers/queries/servers/server-list.ts @@ -38,6 +38,14 @@ export class ServerListQueryHandler hasChanges: true, result: newServers, }; + } else if (event.type === 'server.deleted') { + const newServers = output.filter( + (server) => server.domain !== event.server.domain + ); + return { + hasChanges: true, + result: newServers, + }; } return { diff --git a/packages/client/src/mutations/index.ts b/packages/client/src/mutations/index.ts index df7cc3ec..e06b5e2e 100644 --- a/packages/client/src/mutations/index.ts +++ b/packages/client/src/mutations/index.ts @@ -54,6 +54,7 @@ export * from './records/record-field-value-delete'; export * from './records/record-field-value-set'; export * from './records/record-name-update'; export * from './servers/server-create'; +export * from './servers/server-delete'; export * from './spaces/space-avatar-update'; export * from './spaces/space-create'; export * from './spaces/space-delete'; @@ -116,7 +117,7 @@ export enum MutationErrorCode { SpaceUpdateFailed = 'space_update_failed', SpaceCreateForbidden = 'space_create_forbidden', SpaceCreateFailed = 'space_create_failed', - ServerAlreadyExists = 'server_already_exists', + ServerDeleteForbidden = 'server_delete_forbidden', ServerUrlInvalid = 'server_url_invalid', ServerInitFailed = 'server_init_failed', ChannelNotFound = 'channel_not_found', diff --git a/packages/client/src/mutations/servers/server-delete.ts b/packages/client/src/mutations/servers/server-delete.ts new file mode 100644 index 00000000..515662c2 --- /dev/null +++ b/packages/client/src/mutations/servers/server-delete.ts @@ -0,0 +1,17 @@ +export type ServerDeleteMutationInput = { + type: 'server.delete'; + domain: string; +}; + +export type ServerDeleteMutationOutput = { + success: boolean; +}; + +declare module '@colanode/client/mutations' { + interface MutationMap { + 'server.delete': { + input: ServerDeleteMutationInput; + output: ServerDeleteMutationOutput; + }; + } +} diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts index fbd048c9..f145b9d0 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -223,6 +223,34 @@ export class AppService { return serverService; } + public async deleteServer(domain: string): Promise { + const server = this.servers.get(domain); + if (!server) { + return; + } + + for (const account of this.accounts.values()) { + if (account.server.domain === domain) { + await account.logout(); + } + } + + const deletedServer = await this.database + .deleteFrom('servers') + .returningAll() + .where('domain', '=', domain) + .executeTakeFirst(); + + this.servers.delete(domain); + + if (deletedServer) { + eventBus.publish({ + type: 'server.deleted', + server: mapServer(deletedServer), + }); + } + } + public triggerCleanup(): void { this.cleanupEventLoop.trigger(); } @@ -253,7 +281,21 @@ export class AppService { for (const deletedToken of deletedTokens) { const server = this.servers.get(deletedToken.domain); - if (!server || !server.isAvailable) { + if (!server) { + debug( + `Server ${deletedToken.domain} not found, skipping token ${deletedToken.token} for account ${deletedToken.account_id}` + ); + + await this.database + .deleteFrom('deleted_tokens') + .where('token', '=', deletedToken.token) + .where('account_id', '=', deletedToken.account_id) + .execute(); + + continue; + } + + if (!server.isAvailable) { debug( `Server ${deletedToken.domain} is not available for logging out account ${deletedToken.account_id}` ); diff --git a/packages/client/src/types/events.ts b/packages/client/src/types/events.ts index d2318404..07b7c8c3 100644 --- a/packages/client/src/types/events.ts +++ b/packages/client/src/types/events.ts @@ -131,6 +131,11 @@ export type ServerUpdatedEvent = { server: Server; }; +export type ServerDeletedEvent = { + type: 'server.deleted'; + server: Server; +}; + export type QueryResultUpdatedEvent = { type: 'query.result.updated'; id: string; @@ -301,10 +306,11 @@ export type Event = | WorkspaceDeletedEvent | ServerCreatedEvent | ServerUpdatedEvent + | ServerDeletedEvent + | ServerAvailabilityChangedEvent | FileStateUpdatedEvent | QueryResultUpdatedEvent | RadarDataUpdatedEvent - | ServerAvailabilityChangedEvent | CollaborationCreatedEvent | CollaborationDeletedEvent | AccountConnectionOpenedEvent diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 398bca1d..4b0c9bf3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,3 +37,4 @@ export * from './lib/mentions'; export * from './types/mentions'; export * from './types/avatars'; export * from './types/build'; +export * from './lib/servers'; diff --git a/packages/core/src/lib/servers.ts b/packages/core/src/lib/servers.ts new file mode 100644 index 00000000..2174fcc8 --- /dev/null +++ b/packages/core/src/lib/servers.ts @@ -0,0 +1,3 @@ +export const isColanodeServer = (domain: string) => { + return domain.endsWith('.colanode.com'); +}; diff --git a/packages/ui/src/components/accounts/email-login.tsx b/packages/ui/src/components/accounts/email-login.tsx index d200f361..1e2c9e38 100644 --- a/packages/ui/src/components/accounts/email-login.tsx +++ b/packages/ui/src/components/accounts/email-login.tsx @@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v4'; -import { Server } from '@colanode/client/types'; import { LoginOutput } from '@colanode/core'; import { Button } from '@colanode/ui/components/ui/button'; import { @@ -24,7 +23,7 @@ const formSchema = z.object({ }); interface EmailLoginProps { - server: Server; + server: string; onSuccess: (output: LoginOutput) => void; onForgotPassword: () => void; } @@ -49,7 +48,7 @@ export const EmailLogin = ({ type: 'email.login', email: values.email, password: values.password, - server: server.domain, + server, }, onSuccess(output) { onSuccess(output); diff --git a/packages/ui/src/components/accounts/email-password-reset-complete.tsx b/packages/ui/src/components/accounts/email-password-reset-complete.tsx index 495a49ca..a35ac5e2 100644 --- a/packages/ui/src/components/accounts/email-password-reset-complete.tsx +++ b/packages/ui/src/components/accounts/email-password-reset-complete.tsx @@ -5,7 +5,6 @@ import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v4'; -import { Server } from '@colanode/client/types'; import { Button } from '@colanode/ui/components/ui/button'; import { Form, @@ -39,7 +38,7 @@ const formSchema = z }); interface EmailPasswordResetCompleteProps { - server: Server; + server: string; id: string; expiresAt: Date; } @@ -73,7 +72,7 @@ export const EmailPasswordResetComplete = ({ type: 'email.password.reset.complete', otp: values.otp, password: values.password, - server: server.domain, + server, id: id, }, onSuccess() { diff --git a/packages/ui/src/components/accounts/email-password-reset-init.tsx b/packages/ui/src/components/accounts/email-password-reset-init.tsx index 2f14cbd5..7efd1abe 100644 --- a/packages/ui/src/components/accounts/email-password-reset-init.tsx +++ b/packages/ui/src/components/accounts/email-password-reset-init.tsx @@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v4'; -import { Server } from '@colanode/client/types'; import { EmailPasswordResetInitOutput } from '@colanode/core'; import { Button } from '@colanode/ui/components/ui/button'; import { @@ -23,7 +22,7 @@ const formSchema = z.object({ }); interface EmailPasswordResetInitProps { - server: Server; + server: string; onSuccess: (output: EmailPasswordResetInitOutput) => void; } @@ -44,7 +43,7 @@ export const EmailPasswordResetInit = ({ input: { type: 'email.password.reset.init', email: values.email, - server: server.domain, + server, }, onSuccess(output) { onSuccess(output); diff --git a/packages/ui/src/components/accounts/email-register.tsx b/packages/ui/src/components/accounts/email-register.tsx index bacba00b..e3b921f6 100644 --- a/packages/ui/src/components/accounts/email-register.tsx +++ b/packages/ui/src/components/accounts/email-register.tsx @@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v4'; -import { Server } from '@colanode/client/types'; import { LoginOutput } from '@colanode/core'; import { Button } from '@colanode/ui/components/ui/button'; import { @@ -39,7 +38,7 @@ const formSchema = z }); interface EmailRegisterProps { - server: Server; + server: string; onSuccess: (output: LoginOutput) => void; } @@ -62,7 +61,7 @@ export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { name: values.name, email: values.email, password: values.password, - server: server.domain, + server, }, onSuccess(output) { onSuccess(output); diff --git a/packages/ui/src/components/accounts/email-verify.tsx b/packages/ui/src/components/accounts/email-verify.tsx index cb780e5f..49e3a50c 100644 --- a/packages/ui/src/components/accounts/email-verify.tsx +++ b/packages/ui/src/components/accounts/email-verify.tsx @@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v4'; -import { Server } from '@colanode/client/types'; import { LoginOutput } from '@colanode/core'; import { Button } from '@colanode/ui/components/ui/button'; import { @@ -24,7 +23,7 @@ const formSchema = z.object({ }); interface EmailVerifyProps { - server: Server; + server: string; id: string; expiresAt: Date; onSuccess: (output: LoginOutput) => void; @@ -56,7 +55,7 @@ export const EmailVerify = ({ input: { type: 'email.verify', otp: values.otp, - server: server.domain, + server, id, }, onSuccess(output) { diff --git a/packages/ui/src/components/accounts/login-form.tsx b/packages/ui/src/components/accounts/login-form.tsx index bc588cfb..c4ef237c 100644 --- a/packages/ui/src/components/accounts/login-form.tsx +++ b/packages/ui/src/components/accounts/login-form.tsx @@ -1,4 +1,4 @@ -import { useState, Fragment } from 'react'; +import { useState, Fragment, useEffect } from 'react'; import { Account, Server } from '@colanode/client/types'; import { EmailLogin } from '@colanode/ui/components/accounts/email-login'; @@ -48,11 +48,21 @@ type PanelState = export const LoginForm = ({ accounts, servers }: LoginFormProps) => { const app = useApp(); - const [server, setServer] = useState(servers[0]!); + const [server, setServer] = useState( + servers[0]?.domain ?? null + ); const [panel, setPanel] = useState({ type: 'login', }); + useEffect(() => { + const serverExists = + server !== null && servers.some((s) => s.domain === server); + if (!serverExists && servers.length > 0) { + setServer(servers[0]!.domain); + } + }, [server, servers]); + return (
{ servers={servers} readonly={panel.type === 'verify'} /> - {panel.type === 'login' && ( + {server && panel.type === 'login' && ( {

)} - {panel.type === 'register' && ( + {server && panel.type === 'register' && ( { )} - {panel.type === 'verify' && ( + {server && panel.type === 'verify' && ( { )} - {panel.type === 'password_reset_init' && ( + {server && panel.type === 'password_reset_init' && ( { )} - {panel.type === 'password_reset_complete' && ( + {server && panel.type === 'password_reset_complete' && ( void; + domain: string; +} + +export const ServerDeleteDialog = ({ + domain, + open, + onOpenChange, +}: ServerDeleteDialogProps) => { + const { mutate, isPending } = useMutation(); + + return ( + + + + + Are you sure you want delete the server{' '} + "{domain}"? + + + Deleting the server will remove all accounts connected to it. You + can re-add it later. + + + + Cancel + + + + + ); +}; diff --git a/packages/ui/src/components/servers/server-dropdown.tsx b/packages/ui/src/components/servers/server-dropdown.tsx index 2e289618..3039d30c 100644 --- a/packages/ui/src/components/servers/server-dropdown.tsx +++ b/packages/ui/src/components/servers/server-dropdown.tsx @@ -1,9 +1,11 @@ -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, PlusIcon, ServerOffIcon, TrashIcon } from 'lucide-react'; import { Fragment, useState } from 'react'; import { Server } from '@colanode/client/types'; +import { isColanodeServer } from '@colanode/core'; 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 { DropdownMenu, DropdownMenuContent, @@ -13,8 +15,8 @@ import { } from '@colanode/ui/components/ui/dropdown-menu'; interface ServerDropdownProps { - value: Server; - onChange: (server: Server) => void; + value: string | null; + onChange: (server: string) => void; servers: Server[]; readonly?: boolean; } @@ -27,6 +29,9 @@ export const ServerDropdown = ({ }: ServerDropdownProps) => { const [open, setOpen] = useState(false); const [openCreate, setOpenCreate] = useState(false); + const [deleteDomain, setDeleteDomain] = useState(null); + + const server = servers.find((server) => server.domain === value); return ( @@ -40,40 +45,73 @@ export const ServerDropdown = ({ >
- -
-

{value.name}

-

{value.domain}

-
- -
-
- - {servers.map((server) => ( - { - if (value.domain !== server.domain) { - onChange(server); - } - }} - className="flex w-full flex-grow flex-row items-center gap-3 rounded-md border-b border-input p-2 cursor-pointer hover:bg-gray-100" - > + {server ? ( -
-

{server.name}

-

{server.domain}

-
-
- ))} + ) : ( + + )} +
+ {server ? ( + +

{server.name}

+

+ {server.domain} +

+
+ ) : ( +

+ Select a server +

+ )} +
+ +
+ + + {servers.map((server) => { + const canDelete = !isColanodeServer(server.domain); + return ( + { + if (value !== server.domain) { + onChange(server.domain); + } + }} + className="group/server flex w-full flex-grow flex-row items-center gap-3 rounded-md border-b border-input p-2 cursor-pointer hover:bg-gray-100" + > +
+ +
+

{server.name}

+

+ {server.domain} +

+
+
+ {canDelete && ( + + )} +
+ ); + })} { @@ -81,6 +119,7 @@ export const ServerDropdown = ({ }} className="py-2" > + Add new server
@@ -88,9 +127,19 @@ export const ServerDropdown = ({ {openCreate && ( setOpenCreate(false)} - onCreate={(server) => { + onCreate={() => { setOpenCreate(false); - onChange(server); + }} + /> + )} + {deleteDomain && ( + { + if (!open) { + setDeleteDomain(null); + } }} /> )}