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