Allow users to delete custom servers (#48)

This commit is contained in:
Hakan Shehu
2025-06-11 16:13:48 +02:00
committed by GitHub
parent 08ffd69229
commit 9e6d53d147
20 changed files with 309 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Server>(servers[0]!);
const [server, setServer] = useState<string | null>(
servers[0]?.domain ?? null
);
const [panel, setPanel] = useState<PanelState>({
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 (
<div className="flex flex-col gap-4">
<ServerDropdown
@@ -61,7 +71,7 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
servers={servers}
readonly={panel.type === 'verify'}
/>
{panel.type === 'login' && (
{server && panel.type === 'login' && (
<Fragment>
<EmailLogin
server={server}
@@ -94,7 +104,7 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
</p>
</Fragment>
)}
{panel.type === 'register' && (
{server && panel.type === 'register' && (
<Fragment>
<EmailRegister
server={server}
@@ -123,7 +133,7 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
</Fragment>
)}
{panel.type === 'verify' && (
{server && panel.type === 'verify' && (
<Fragment>
<EmailVerify
server={server}
@@ -148,7 +158,7 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
</Fragment>
)}
{panel.type === 'password_reset_init' && (
{server && panel.type === 'password_reset_init' && (
<Fragment>
<EmailPasswordResetInit
server={server}
@@ -173,7 +183,7 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
</Fragment>
)}
{panel.type === 'password_reset_complete' && (
{server && panel.type === 'password_reset_complete' && (
<Fragment>
<EmailPasswordResetComplete
server={server}

View File

@@ -65,6 +65,7 @@ export const ServerCreateDialog = ({
},
onSuccess(output) {
onCreate(output.server);
toast.success('Server added successfully');
},
onError(error) {
toast.error(error.message);

View File

@@ -0,0 +1,72 @@
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@colanode/ui/components/ui/alert-dialog';
import { Button } from '@colanode/ui/components/ui/button';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface ServerDeleteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
domain: string;
}
export const ServerDeleteDialog = ({
domain,
open,
onOpenChange,
}: ServerDeleteDialogProps) => {
const { mutate, isPending } = useMutation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want delete the server{' '}
<span className="font-bold">&quot;{domain}&quot;</span>?
</AlertDialogTitle>
<AlertDialogDescription>
Deleting the server will remove all accounts connected to it. You
can re-add it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
variant="destructive"
disabled={isPending}
onClick={() => {
mutate({
input: {
type: 'server.delete',
domain,
},
onSuccess() {
onOpenChange(false);
toast.success(
'Server and all associated accounts have been deleted'
);
},
onError(error) {
toast.error(error.message);
},
});
}}
>
{isPending && <Spinner className="mr-1" />}
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -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<string | null>(null);
const server = servers.find((server) => server.domain === value);
return (
<Fragment>
@@ -40,40 +45,73 @@ export const ServerDropdown = ({
>
<DropdownMenuTrigger asChild>
<div className="flex w-full flex-grow flex-row items-center gap-3 rounded-md border border-input p-2 cursor-pointer hover:bg-gray-100">
<ServerAvatar
url={value.avatar}
name={value.name}
className="size-8 rounded-md"
/>
<div className="flex-grow">
<p className="flex-grow font-semibold">{value.name}</p>
<p className="text-xs text-muted-foreground">{value.domain}</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.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 ? (
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-8 rounded-md"
/>
<div className="flex-grow">
<p className="flex-grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">{server.domain}</p>
</div>
</DropdownMenuItem>
))}
) : (
<ServerOffIcon className="size-8 text-muted-foreground rounded-md" />
)}
<div className="flex-grow">
{server ? (
<Fragment>
<p className="flex-grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">
{server.domain}
</p>
</Fragment>
) : (
<p className="flex-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) => {
const canDelete = !isColanodeServer(server.domain);
return (
<DropdownMenuItem
key={server.domain}
onSelect={() => {
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"
>
<div className="flex flex-grow items-center gap-3">
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-8 rounded-md"
/>
<div className="flex-grow">
<p className="flex-grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">
{server.domain}
</p>
</div>
</div>
{canDelete && (
<button
className="text-muted-foreground opacity-0 group-hover/server:opacity-100 hover:bg-gray-200 size-8 flex items-center justify-center rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDeleteDomain(server.domain);
}}
>
<TrashIcon className="size-4" />
</button>
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
@@ -81,6 +119,7 @@ export const ServerDropdown = ({
}}
className="py-2"
>
<PlusIcon className="size-4" />
Add new server
</DropdownMenuItem>
</DropdownMenuContent>
@@ -88,9 +127,19 @@ export const ServerDropdown = ({
{openCreate && (
<ServerCreateDialog
onCancel={() => setOpenCreate(false)}
onCreate={(server) => {
onCreate={() => {
setOpenCreate(false);
onChange(server);
}}
/>
)}
{deleteDomain && (
<ServerDeleteDialog
domain={deleteDomain}
open={!!deleteDomain}
onOpenChange={(open) => {
if (!open) {
setDeleteDomain(null);
}
}}
/>
)}