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}