select-identity.tsx

  1// Identity selection page (/auth/select-identity).
  2//
  3// Reached after a successful OAuth login when no existing git-bug identity
  4// could be matched automatically (via provider metadata set by the bridge).
  5// The user can either adopt an existing identity — which links it to their
  6// OAuth account for future logins — or create a fresh one from their OAuth
  7// profile.
  8
  9import { createFileRoute } from "@tanstack/react-router";
 10import { UserCircle, Plus, AlertCircle } from "lucide-react";
 11import { useEffect, useState } from "react";
 12
 13import { Button } from "@/components/ui/button";
 14import { Skeleton } from "@/components/ui/skeleton";
 15
 16export const Route = createFileRoute("/auth/select-identity")({
 17  component: RouteComponent,
 18});
 19
 20interface IdentityItem {
 21  repoSlug: string;
 22  id: string;
 23  humanId: string;
 24  displayName: string;
 25  login?: string;
 26  avatarUrl?: string;
 27}
 28
 29function RouteComponent() {
 30  const [identities, setIdentities] = useState<IdentityItem[] | null>(null);
 31  const [error, setError] = useState<string | null>(null);
 32  const [working, setWorking] = useState(false);
 33
 34  useEffect(() => {
 35    async function loadIdentities() {
 36      try {
 37        const res = await fetch("/auth/identities", { credentials: "include" });
 38        if (!res.ok) throw new Error(`unexpected status ${res.status}`);
 39        const data: IdentityItem[] = await res.json();
 40        setIdentities(data);
 41      } catch (e) {
 42        setError(String(e));
 43      }
 44    }
 45    void loadIdentities();
 46  }, []);
 47
 48  async function adopt(identityId: string | null) {
 49    setWorking(true);
 50    try {
 51      const res = await fetch("/auth/adopt", {
 52        method: "POST",
 53        credentials: "include",
 54        headers: { "Content-Type": "application/json" },
 55        body: JSON.stringify(identityId ? { identityId } : {}),
 56      });
 57      if (!res.ok) throw new Error(`adopt failed: ${res.status}`);
 58      // Full page reload to reset Apollo cache and auth state cleanly.
 59      window.location.assign("/");
 60    } catch (e) {
 61      setError(String(e));
 62      setWorking(false);
 63    }
 64  }
 65
 66  return (
 67    <div className="mx-auto max-w-lg py-12">
 68      <div className="mb-2 flex items-center gap-3">
 69        <UserCircle className="text-muted-foreground size-6" />
 70        <h1 className="text-xl font-semibold">Choose your identity</h1>
 71      </div>
 72      <p className="text-muted-foreground mb-8 text-sm">
 73        No git-bug identity was found linked to your account. Select an existing identity to link
 74        it, or create a new one from your profile.
 75      </p>
 76
 77      {error && (
 78        <div className="border-destructive/30 bg-destructive/10 text-destructive mb-4 flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
 79          <AlertCircle className="size-4 shrink-0" />
 80          {error}
 81        </div>
 82      )}
 83
 84      {!identities && !error && (
 85        <div className="space-y-2">
 86          {Array.from({ length: 3 }).map((_, i) => (
 87            <Skeleton key={i} className="h-14 w-full rounded-md" />
 88          ))}
 89        </div>
 90      )}
 91
 92      <div className="divide-border border-border divide-y rounded-md border">
 93        {identities?.map((id) => (
 94          <div key={id.id} className="flex items-center gap-3 px-4 py-3">
 95            <div className="min-w-0 flex-1">
 96              <p className="font-medium">{id.displayName}</p>
 97              <p className="text-muted-foreground text-xs">
 98                {id.login ? `@${id.login} · ` : ""}
 99                {id.repoSlug} · {id.humanId}
100              </p>
101            </div>
102            <Button
103              size="sm"
104              disabled={working}
105              onClick={() => {
106                void adopt(id.id);
107              }}
108            >
109              Adopt
110            </Button>
111          </div>
112        ))}
113
114        {/* Always offer to create a new identity */}
115        <div className="flex items-center gap-3 px-4 py-3">
116          <div className="min-w-0 flex-1">
117            <p className="font-medium">Create new identity</p>
118            <p className="text-muted-foreground text-xs">
119              A fresh git-bug identity will be created from your OAuth profile.
120            </p>
121          </div>
122          <Button
123            size="sm"
124            disabled={working}
125            onClick={() => {
126              void adopt(null);
127            }}
128          >
129            <Plus className="size-4" />
130            Create
131          </Button>
132        </div>
133      </div>
134    </div>
135  );
136}