settings.tsx (16.89 KB)
import { useForm } from "@tanstack/react-form";
import {
  useMutation,
  useQuery,
  useQueryClient,
  useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { CheckIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import z from "zod";
import { listPersonalAccessTokens } from "@/api/pat";
import { getSessionOptions } from "@/api/session";
import { NotFoundComponent } from "@/components/404-components";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { authClient } from "@/lib/auth-client";

export const Route = createFileRoute("/_layout/settings")({
  component: RouteComponent,
  notFoundComponent: NotFoundComponent,
  loader: async ({ context: { queryClient } }) => {
    const data = await queryClient.ensureQueryData(getSessionOptions);
    if (!data) {
      throw redirect({ to: "/" });
    }
  },
  pendingComponent: PendingComponent,
});

function PendingComponent() {
  return (
    <div className="mx-auto w-full max-w-4xl p-6">
      <h1 className="mb-6 font-bold text-3xl">Settings</h1>

      <Tabs defaultValue="profile" onChange={() => {}} value="profile">
        <TabsList>
          <TabsTrigger value="profile">Profile</TabsTrigger>
          <TabsTrigger value="tokens">Personal Access Tokens</TabsTrigger>
        </TabsList>

        <TabsContent value="profile">
          <div className="rounded-lg border p-6">
            <Skeleton className="mb-6 h-6 w-40" />

            <div className="mb-6 flex items-center gap-4">
              <Skeleton className="h-20 w-20 rounded-full" />
              <div className="space-y-2">
                <Skeleton className="h-5 w-32" />
                <Skeleton className="h-4 w-24" />
              </div>
            </div>

            <div className="space-y-4">
              <div className="space-y-2">
                <Skeleton className="h-4 w-12" />
                <Skeleton className="h-10 w-full" />
              </div>
              <div className="space-y-2">
                <Skeleton className="h-4 w-20" />
                <Skeleton className="h-10 w-full" />
                <Skeleton className="h-3 w-48" />
              </div>
              <div className="space-y-2">
                <Skeleton className="h-4 w-12" />
                <Skeleton className="h-10 w-full" />
                <Skeleton className="h-3 w-40" />
              </div>
              <Skeleton className="h-10 w-28" />
            </div>
          </div>
        </TabsContent>
      </Tabs>
    </div>
  );
}

function RouteComponent() {
  const { data: session, isLoading } = useSuspenseQuery(getSessionOptions);

  if (isLoading) {
    return <PendingComponent />;
  }

  const user = session?.user;

  return (
    <div className="mx-auto w-full max-w-4xl p-6">
      <h1 className="mb-6 font-bold text-3xl">Settings</h1>

      <Tabs defaultValue="profile">
        <TabsList>
          <TabsTrigger value="profile">Profile</TabsTrigger>
          <TabsTrigger value="tokens">Personal Access Tokens</TabsTrigger>
        </TabsList>

        <TabsContent value="profile">
          <ProfileSettings
            email={user?.email ?? "NO_EMAIL"}
            name={user?.name ?? "NO_NAME"}
            username={user?.username ?? "NO_USERNAME"}
          />
        </TabsContent>

        <TabsContent value="tokens">
          <PersonalAccessTokens />
        </TabsContent>
      </Tabs>
    </div>
  );
}

type ProfileSettingsProps = {
  name: string;
  email: string;
  username: string;
};

function ProfileSettings({ name, email, username }: ProfileSettingsProps) {
  const queryClient = useQueryClient();

  const updateProfileMutation = useMutation({
    mutationFn: async (data: { name: string }) => {
      const result = await authClient.updateUser({
        name: data.name,
      });
      if (result.error) {
        throw new Error(result.error.message || "Failed to update profile");
      }
      await queryClient.refetchQueries({
        queryKey: getSessionOptions.queryKey,
      });
      return result.data;
    },
    onSuccess: async () => {
      toast.success("Profile updated successfully");
    },
    onError: (error: Error) => {
      toast.error(error.message || "Failed to update profile");
    },
  });

  const form = useForm({
    defaultValues: {
      name,
    },
    onSubmit: async ({ value }) => {
      await updateProfileMutation.mutateAsync(value);
    },
    validators: {
      onSubmit: z.object({
        name: z.string().min(2, "Name must be at least 2 characters"),
      }),
    },
  });

  return (
    <div className="rounded-lg border p-6">
      <h2 className="mb-6 font-semibold text-lg">Profile Information</h2>

      <div className="mb-6 flex items-center gap-4">
        <Avatar className="h-20 w-20">
          <AvatarImage
            alt={`@${username}`}
            src={`https://api.dicebear.com/9.x/notionists/svg?seed=${username}&scale=150&backgroundType=solid,gradientLinear&backgroundColor=b6e3f4,c0aede,d1d4f9,ffd5dc,ffdfbf`}
          />
          <AvatarFallback>
            {name
              .split(" ")
              .map((w) => w.at(0))
              .join("")}
          </AvatarFallback>
        </Avatar>
        <div>
          <p className="font-medium text-lg">{name}</p>
          <p className="text-muted-foreground text-sm">@{username}</p>
        </div>
      </div>

      <form
        className="space-y-4"
        onSubmit={(e) => {
          e.preventDefault();
          e.stopPropagation();
          form.handleSubmit();
        }}
      >
        <div>
          <form.Field name="name">
            {(field) => (
              <div className="space-y-2">
                <Label htmlFor={field.name}>Name</Label>
                <Input
                  id={field.name}
                  name={field.name}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  placeholder={name}
                  value={field.state.value}
                />
                {field.state.meta.errors.map((error) => (
                  <p className="text-red-500 text-sm" key={error?.message}>
                    {error?.message}
                  </p>
                ))}
              </div>
            )}
          </form.Field>
        </div>
        <div className="space-y-2">
          <Label htmlFor="username">Username</Label>
          <Input disabled id="username" value={username} />
          <p className="text-muted-foreground text-xs">
            Username cannot be changed
          </p>
        </div>
        <div className="space-y-2">
          <Label htmlFor="email">Email</Label>
          <Input disabled id="email" type="email" value={email} />
          <p className="text-muted-foreground text-xs">
            Email cannot be changed
          </p>
        </div>
        <Button loading={updateProfileMutation.isPending} type="submit">
          Save Changes
        </Button>
      </form>
    </div>
  );
}

function PersonalAccessTokens() {
  const { data, isLoading } = useQuery(listPersonalAccessTokens);
  const queryClient = useQueryClient();

  const [showNewTokenDialog, setShowNewTokenDialog] = useState(false);
  const [newlyCreatedToken, setNewlyCreatedToken] = useState<string | null>(
    null
  );
  const [isCopied, setIsCopied] = useState(false);

  const createPATMutation = useMutation({
    mutationFn: async (name: string) => {
      const { data, error } = await authClient.apiKey.create({
        name,
        prefix: "gvx_",
      });
      if (error) {
        throw new Error(
          error.message || "Failed to create personal access token"
        );
      }
      return data;
    },
    onSuccess: async (data) => {
      await queryClient.refetchQueries({
        queryKey: listPersonalAccessTokens.queryKey,
      });
      setNewlyCreatedToken(data.key);
      setShowNewTokenDialog(false);
      toast.success("Personal access token created successfully");
    },
    onError: (error) => {
      console.log(error);
      toast.error(error.message || "Failed to create personal access token");
    },
  });

  const form = useForm({
    defaultValues: {
      name: "",
    },
    onSubmit: async ({ value }) => {
      await createPATMutation.mutateAsync(value.name);
      form.reset();
    },
    validators: {
      onSubmit: z.object({
        name: z
          .string()
          .min(3, "Token name must be at least 3 characters")
          .max(50, "Token name must be at most 50 characters"),
      }),
    },
  });

  if (isLoading) {
    return <PersonalAccessTokensSkeleton />;
  }

  const tokens = data || [];

  const handleCopyToken = (token: string) => {
    if (!token) {
      toast.error("No token to copy");
      return;
    }
    navigator.clipboard.writeText(token);
    setIsCopied(true);
    setTimeout(() => setIsCopied(false), 1500);
  };

  return (
    <div className="space-y-6">
      <div className="rounded-lg border p-6">
        <div className="mb-6">
          <h2 className="mb-2 font-semibold text-lg">Personal Access Tokens</h2>
          <p className="text-muted-foreground text-sm">
            Personal access tokens function like passwords for Git over HTTP.
            Use them to authenticate when pushing or pulling repositories.
          </p>
        </div>

        <Button onClick={() => setShowNewTokenDialog(true)}>
          Generate New Token
        </Button>

        {/* Create Token Dialog */}
        <Dialog onOpenChange={setShowNewTokenDialog} open={showNewTokenDialog}>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Create Personal Access Token</DialogTitle>
              <DialogDescription>
                Give your token a descriptive name to help you identify it
                later.
              </DialogDescription>
            </DialogHeader>
            <form
              onSubmit={(e) => {
                e.preventDefault();
                e.stopPropagation();
                form.handleSubmit();
              }}
            >
              <form.Field name="name">
                {(field) => (
                  <div className="space-y-2 py-4">
                    <Label htmlFor={field.name}>Token Name</Label>
                    <Input
                      id={field.name}
                      name={field.name}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      placeholder="e.g., My Development Token"
                      value={field.state.value}
                    />
                    <p className="text-muted-foreground text-xs">
                      What is this token for?
                    </p>
                    {field.state.meta.errors.map((error) => (
                      <p
                        className="text-destructive text-sm"
                        key={error?.message}
                      >
                        {error?.message}
                      </p>
                    ))}
                  </div>
                )}
              </form.Field>
              <DialogFooter>
                <Button
                  onClick={() => {
                    setShowNewTokenDialog(false);
                    form.reset();
                  }}
                  type="button"
                  variant="outline"
                >
                  Cancel
                </Button>
                <Button loading={createPATMutation.isPending} type="submit">
                  Generate Token
                </Button>
              </DialogFooter>
            </form>
          </DialogContent>
        </Dialog>

        {newlyCreatedToken && (
          <div className="my-5">
            <h2 className="font-semibold text-lg">Generated Token</h2>
            <p className="mb-2 text-muted-foreground text-sm">
              Make sure to copy your personal access token now. You won't be
              able to see it again!
            </p>
            <div className="flex items-center gap-2">
              <code className="block overflow-x-auto whitespace-nowrap rounded border bg-muted p-2 font-mono text-sm">
                {newlyCreatedToken}
              </code>

              <Button
                className="shrink-0"
                onClick={() => handleCopyToken(newlyCreatedToken || "")}
                size="icon"
                variant="outline"
              >
                {isCopied ? (
                  <CheckIcon className="size-4 text-green-600" />
                ) : (
                  <CopyIcon className="size-4" />
                )}
              </Button>
            </div>
          </div>
        )}
      </div>

      {tokens.length > 0 && (
        <div className="rounded-lg border p-6">
          <h3 className="mb-4 font-semibold text-base">Your Tokens</h3>
          <div className="space-y-3">
            {tokens.map((token) => (
              <TokenCard
                createdAt={token.createdAt}
                id={token.id}
                key={token.id}
                lastRequest={token.lastRequest}
                name={token.name ?? "Unnamed Token"}
                start={token.start ?? "gvx_xxx"}
              />
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

const formatDate = (date: Date) =>
  formatDistanceToNow(date, { addSuffix: true });

type TokenCardProps = {
  id: string;
  name: string;
  start: string;
  createdAt: Date;
  lastRequest: Date | null;
};

function TokenCard({
  id,
  name,
  start,
  createdAt,
  lastRequest,
}: TokenCardProps) {
  const queryClient = useQueryClient();

  const deleteMutation = useMutation({
    mutationFn: async (tokenId: string) => {
      const { data, error } = await authClient.apiKey.delete({
        keyId: tokenId,
      });
      if (error || !data.success) {
        throw new Error(
          error?.message || "Failed to delete personal access token"
        );
      }
      await queryClient.refetchQueries({
        queryKey: listPersonalAccessTokens.queryKey,
      });
    },
    onError: (error) => {
      console.log(error);
      toast.error(error.message || "Failed to delete personal access token");
    },
  });
  return (
    <Card className="p-4" key={id}>
      <div className="flex items-start justify-between">
        <div className="flex-1">
          <div className="mb-2">
            <h4 className="font-semibold text-sm">{name}</h4>
          </div>
          <div className="space-y-1 text-muted-foreground text-xs">
            <p>Created: {formatDate(createdAt)}</p>
            <p>Last used: {lastRequest ? formatDate(lastRequest) : "Never"}</p>
            <code className="text-xs">{`${start}.....`}</code>
          </div>
        </div>

        <Button
          loading={deleteMutation.isPending}
          onClick={() => {
            deleteMutation.mutate(id);
          }}
          size="icon"
          variant="outline"
        >
          <Trash2Icon />
        </Button>
      </div>
    </Card>
  );
}

function PersonalAccessTokensSkeleton() {
  return (
    <div className="space-y-6">
      <div className="rounded-lg border p-6">
        <Skeleton className="mb-2 h-6 w-48" />
        <Skeleton className="mb-6 h-4 w-full max-w-2xl" />
        <Skeleton className="h-10 w-40" />
      </div>

      <div className="rounded-lg border p-6">
        <Skeleton className="mb-4 h-5 w-32" />
        <div className="space-y-3">
          <div className="rounded-lg border p-4">
            <div className="flex items-start justify-between">
              <div className="flex-1 space-y-3">
                <Skeleton className="h-5 w-40" />
                <div className="space-y-2">
                  <Skeleton className="h-3 w-32" />
                  <Skeleton className="h-3 w-28" />
                  <Skeleton className="h-3 w-64" />
                </div>
              </div>
              <Skeleton className="h-8 w-16" />
            </div>
          </div>
          <div className="rounded-lg border p-4">
            <div className="flex items-start justify-between">
              <div className="flex-1 space-y-3">
                <Skeleton className="h-5 w-36" />
                <div className="space-y-2">
                  <Skeleton className="h-3 w-32" />
                  <Skeleton className="h-3 w-24" />
                  <Skeleton className="h-3 w-64" />
                </div>
              </div>
              <Skeleton className="h-8 w-16" />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}