From c70eaeedde8a179d862b33392fe7d064f78563f5 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 8 Apr 2026 21:16:22 +0200 Subject: [PATCH] refactor(web): simplify auth to local-only mode Remove multi-mode auth (external/readonly) from the frontend. The auth context now always fetches the user identity via GraphQL (local mode). Drop the ServerConfig query, OAuth select-identity route, and sign-in/ sign-out UI from the header. Co-Authored-By: Claude Opus 4.6 (1M context) --- webui2/src/__generated__/graphql.ts | 148 ++++++------------- webui2/src/components/layout/header.tsx | 54 +------ webui2/src/graphql/ServerConfig.graphql | 7 - webui2/src/lib/auth.tsx | 161 +++------------------ webui2/src/routeTree.gen.ts | 21 --- webui2/src/routes/auth/select-identity.tsx | 139 ------------------ 6 files changed, 76 insertions(+), 454 deletions(-) delete mode 100644 webui2/src/graphql/ServerConfig.graphql delete mode 100644 webui2/src/routes/auth/select-identity.tsx diff --git a/webui2/src/__generated__/graphql.ts b/webui2/src/__generated__/graphql.ts index b7abd2cc0808e78251c1287ea4c8683fe7d0da35..a09c4332b496b871e6ca29f2572595187ba9eb73 100644 --- a/webui2/src/__generated__/graphql.ts +++ b/webui2/src/__generated__/graphql.ts @@ -733,8 +733,6 @@ export type GitRef = { __typename?: 'GitRef'; /** Commit hash the reference points to. */ hash: Scalars['String']['output']; - /** True for the branch HEAD currently points to. */ - isDefault: Scalars['Boolean']['output']; /** Full reference name, e.g. refs/heads/main or refs/tags/v1.0. */ name: Scalars['String']['output']; /** Short name, e.g. main or v1.0. */ @@ -763,6 +761,11 @@ export type GitTreeEntry = { __typename?: 'GitTreeEntry'; /** Git object hash. */ hash: Scalars['String']['output']; + /** + * The last git commit that touched this tree entry. Null when the entry + * cannot be resolved within the history depth limit. + */ + lastCommit?: Maybe; /** File or directory name within the parent tree. */ name: Scalars['String']['output']; /** Whether this entry is a file, directory, symlink, or submodule. */ @@ -967,8 +970,6 @@ export type Query = { * Returns null if the referenced repository does not exist. */ repository?: Maybe; - /** Server configuration and authentication mode. */ - serverConfig: ServerConfig; }; @@ -1004,6 +1005,12 @@ export type Repository = { * touching path. */ commits: GitCommitConnection; + /** + * The commit pointed to by HEAD in the git repository. + * Null if HEAD cannot be resolved to a commit, for example in an empty or unborn + * repository, or if HEAD is missing or invalid. + */ + head?: Maybe; /** Look up an identity by id prefix. Returns null if no identity matches the prefix. */ identity?: Maybe; /** @@ -1116,21 +1123,6 @@ export type RepositoryEdge = { node: Repository; }; -/** Server-wide configuration, independent of any repository. */ -export type ServerConfig = { - __typename?: 'ServerConfig'; - /** - * Authentication mode: 'local' (single user from git config), - * 'external' (multi-user via OAuth/OIDC providers), or 'readonly'. - */ - authMode: Scalars['String']['output']; - /** - * Names of the login providers enabled on this server, e.g. ['github']. - * Empty when authMode is not 'external'. - */ - loginProviders: Array; -}; - export enum Status { Closed = 'CLOSED', Open = 'OPEN' @@ -1162,12 +1154,6 @@ export type SubscriptionIdentityEventsArgs = { repoRef?: InputMaybe; }; -export type IdentitySummaryFragment = { __typename?: 'Identity', id: string, humanId: string, displayName: string, avatarUrl?: string | null }; - -export type BugSummaryFragment = { __typename?: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename?: 'Label', name: string, color: { __typename?: 'Color', R: number, G: number, B: number } }>, author: { __typename?: 'Identity', id: string, humanId: string, displayName: string, avatarUrl?: string | null }, comments: { __typename?: 'BugCommentConnection', totalCount: number } }; - -export type LabelFieldsFragment = { __typename?: 'Label', name: string, color: { __typename?: 'Color', R: number, G: number, B: number } }; - export type BugCreateCommentFieldsFragment = { __typename?: 'BugCreateTimelineItem', message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename?: 'Identity', id: string, humanId: string, displayName: string, avatarUrl?: string | null } }; export type BugAddCommentFieldsFragment = { __typename?: 'BugAddCommentTimelineItem', message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename?: 'Identity', id: string, humanId: string, displayName: string, avatarUrl?: string | null } }; @@ -1178,6 +1164,12 @@ export type StatusChangeFieldsFragment = { __typename?: 'BugSetStatusTimelineIte export type TitleChangeFieldsFragment = { __typename?: 'BugSetTitleTimelineItem', date: string, title: string, was: string, author: { __typename?: 'Identity', humanId: string, displayName: string } }; +export type IdentitySummaryFragment = { __typename?: 'Identity', id: string, humanId: string, displayName: string, avatarUrl?: string | null }; + +export type BugSummaryFragment = { __typename?: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename?: 'Label', name: string, color: { __typename?: 'Color', R: number, G: number, B: number } }>, author: { __typename?: 'Identity', id: string, humanId: string, displayName: string, avatarUrl?: string | null }, comments: { __typename?: 'BugCommentConnection', totalCount: number } }; + +export type LabelFieldsFragment = { __typename?: 'Label', name: string, color: { __typename?: 'Color', R: number, G: number, B: number } }; + export type AllIdentitiesQueryVariables = Exact<{ ref?: InputMaybe; }>; @@ -1279,11 +1271,6 @@ export type RepositoriesQueryVariables = Exact<{ [key: string]: never; }>; export type RepositoriesQuery = { __typename?: 'Query', repositories: { __typename?: 'RepositoryConnection', totalCount: number, nodes: Array<{ __typename?: 'Repository', name?: string | null }> } }; -export type ServerConfigQueryVariables = Exact<{ [key: string]: never; }>; - - -export type ServerConfigQuery = { __typename?: 'Query', serverConfig: { __typename?: 'ServerConfig', authMode: string, loginProviders: Array } }; - export type UserProfileQueryVariables = Exact<{ ref?: InputMaybe; prefix: Scalars['String']['input']; @@ -1303,16 +1290,6 @@ export type ValidLabelsQueryVariables = Exact<{ export type ValidLabelsQuery = { __typename?: 'Query', repository?: { __typename?: 'Repository', validLabels: { __typename?: 'LabelConnection', nodes: Array<{ __typename?: 'Label', name: string, color: { __typename?: 'Color', R: number, G: number, B: number } }> } } | null }; -export const LabelFieldsFragmentDoc = gql` - fragment LabelFields on Label { - name - color { - R - G - B - } -} - `; export const IdentitySummaryFragmentDoc = gql` fragment IdentitySummary on Identity { id @@ -1321,25 +1298,6 @@ export const IdentitySummaryFragmentDoc = gql` avatarUrl } `; -export const BugSummaryFragmentDoc = gql` - fragment BugSummary on Bug { - id - humanId - status - title - labels { - ...LabelFields - } - author { - ...IdentitySummary - } - createdAt - comments { - totalCount - } -} - ${LabelFieldsFragmentDoc} -${IdentitySummaryFragmentDoc}`; export const BugCreateCommentFieldsFragmentDoc = gql` fragment BugCreateCommentFields on BugCreateTimelineItem { author { @@ -1362,6 +1320,16 @@ export const BugAddCommentFieldsFragmentDoc = gql` edited } ${IdentitySummaryFragmentDoc}`; +export const LabelFieldsFragmentDoc = gql` + fragment LabelFields on Label { + name + color { + R + G + B + } +} + `; export const LabelChangeFieldsFragmentDoc = gql` fragment LabelChangeFields on BugLabelChangeTimelineItem { author { @@ -1398,6 +1366,25 @@ export const TitleChangeFieldsFragmentDoc = gql` was } `; +export const BugSummaryFragmentDoc = gql` + fragment BugSummary on Bug { + id + humanId + status + title + labels { + ...LabelFields + } + author { + ...IdentitySummary + } + createdAt + comments { + totalCount + } +} + ${LabelFieldsFragmentDoc} +${IdentitySummaryFragmentDoc}`; export const AllIdentitiesDocument = gql` query AllIdentities($ref: String) { repository(ref: $ref) { @@ -1965,49 +1952,6 @@ export type RepositoriesQueryHookResult = ReturnType; export type RepositoriesSuspenseQueryHookResult = ReturnType; export type RepositoriesQueryResult = Apollo.QueryResult; -export const ServerConfigDocument = gql` - query ServerConfig { - serverConfig { - authMode - loginProviders - } -} - `; - -/** - * __useServerConfigQuery__ - * - * To run a query within a React component, call `useServerConfigQuery` and pass it any options that fit your needs. - * When your component renders, `useServerConfigQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useServerConfigQuery({ - * variables: { - * }, - * }); - */ -export function useServerConfigQuery(baseOptions?: ApolloReactHooks.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useQuery(ServerConfigDocument, options); - } -export function useServerConfigLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useLazyQuery(ServerConfigDocument, options); - } -// @ts-ignore -export function useServerConfigSuspenseQuery(baseOptions?: ApolloReactHooks.SuspenseQueryHookOptions): ApolloReactHooks.UseSuspenseQueryResult; -export function useServerConfigSuspenseQuery(baseOptions?: ApolloReactHooks.SkipToken | ApolloReactHooks.SuspenseQueryHookOptions): ApolloReactHooks.UseSuspenseQueryResult; -export function useServerConfigSuspenseQuery(baseOptions?: ApolloReactHooks.SkipToken | ApolloReactHooks.SuspenseQueryHookOptions) { - const options = baseOptions === ApolloReactHooks.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useSuspenseQuery(ServerConfigDocument, options); - } -export type ServerConfigQueryHookResult = ReturnType; -export type ServerConfigLazyQueryHookResult = ReturnType; -export type ServerConfigSuspenseQueryHookResult = ReturnType; -export type ServerConfigQueryResult = Apollo.QueryResult; export const UserProfileDocument = gql` query UserProfile($ref: String, $prefix: String!, $openQuery: String!, $closedQuery: String!, $listQuery: String!, $after: String) { repository(ref: $ref) { diff --git a/webui2/src/components/layout/header.tsx b/webui2/src/components/layout/header.tsx index 5333ca4ae1f3bdb3bd35a6d1405d75bbe93061fc..8d2d89513b0c8ae0288902a857f63f0cd2381c0f 100644 --- a/webui2/src/components/layout/header.tsx +++ b/webui2/src/components/layout/header.tsx @@ -2,12 +2,9 @@ // page (root) or inside a specific repo: // - Root: shows logo only, no Code/Issues links // - Repo: shows Code + Issues nav links scoped to the current repo slug -// -// In external mode, shows a "Sign in" button when logged out and a sign-out -// action when logged in. import { Link, useParams, useRouterState } from "@tanstack/react-router"; -import { Plus, Sun, Moon, LogIn, LogOut } from "lucide-react"; +import { Plus, Sun, Moon } from "lucide-react"; import Logo from "@/assets/logo.svg?react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -17,33 +14,14 @@ import { useAuth } from "@/lib/auth"; import { useTheme } from "@/lib/theme"; import { cn } from "@/lib/utils"; -// SignOutButton sends a POST to /auth/logout and reloads the page. -// A full reload is the simplest way to reset all Apollo cache + React state. -function handleSignOut() { - void fetch("/auth/logout", { method: "POST", credentials: "include" }).finally(() => - window.location.assign("/"), - ); -} - -function SignOutButton() { - return ( - - ); -} - export function Header() { - const { user, mode, loginProviders } = useAuth(); + const { user } = useAuth(); const { theme, toggle } = useTheme(); // Detect if we're inside a /$repo route and grab the slug. const params = useParams({ strict: false }); const repo = params.repo ?? null; - // Don't show repo nav on the /auth/* pages. - const effectiveRepo = repo === "auth" ? null : repo; - return (
@@ -54,34 +32,22 @@ export function Header() { {/* Repo-scoped nav links — only shown when inside a repo */} - {effectiveRepo && } + {repo && }
- {mode === "readonly" && Read only} - - {/* External mode: show sign-in buttons when logged out */} - {mode === "external" && - !user && - loginProviders.map((p) => ( - - ))} - - {user && effectiveRepo && ( + {user && repo && ( <> - + New issue @@ -93,9 +59,6 @@ export function Header() { )} - - {/* Sign out only shown in external mode when logged in */} - {mode === "external" && user && }
@@ -135,8 +98,3 @@ function RepoNav({ repo }: { repo: string }) { ); } - -function providerLabel(name: string): string { - const labels: Record = { github: "GitHub", gitlab: "GitLab", gitea: "Gitea" }; - return labels[name] ?? name; -} diff --git a/webui2/src/graphql/ServerConfig.graphql b/webui2/src/graphql/ServerConfig.graphql deleted file mode 100644 index a7d5140ad582a32bcf66e183bdbf4f54757a92ca..0000000000000000000000000000000000000000 --- a/webui2/src/graphql/ServerConfig.graphql +++ /dev/null @@ -1,7 +0,0 @@ -# Fetched once at startup to determine the auth mode and available providers. -query ServerConfig { - serverConfig { - authMode - loginProviders - } -} diff --git a/webui2/src/lib/auth.tsx b/webui2/src/lib/auth.tsx index 13f9011f9f4c1fdf42a784eeac74d7df95b7736a..8320453c171d4d7c5a1b2a335dfc45968ca2de11 100644 --- a/webui2/src/lib/auth.tsx +++ b/webui2/src/lib/auth.tsx @@ -1,60 +1,11 @@ // auth.tsx — authentication context for the webui. // -// Auth has three modes determined by the server's serverConfig.authMode: -// -// local Single-user mode. The identity is taken from git config at -// server startup. No login UI is needed. -// -// external Multi-user mode. Users log in via an OAuth or OIDC provider. -// The current user is fetched from GET /auth/user and can be null -// (not logged in) even while the server is running. -// -// readonly No writes allowed. No identity is ever returned. -// -// All three modes expose the same AuthContextValue shape, so the rest of the -// component tree doesn't need to know which mode is active. +// Currently only supports local (single-user) mode: the identity is taken from +// git config at server startup and fetched via GraphQL. import { gql } from "@apollo/client"; import { useQuery } from "@apollo/client/react"; -import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; -import * as v from "valibot"; - -import { useServerConfigQuery } from "@/__generated__/graphql"; - -const authUserSchema = v.object({ - id: v.string(), - humanId: v.string(), - name: v.nullable(v.string()), - displayName: v.string(), - avatarUrl: v.nullable(v.string()), - email: v.nullable(v.string()), - login: v.nullable(v.string()), -}); - -// AuthUser matches the Identity type fields we care about for auth purposes. -export type AuthUser = v.InferOutput; - -// 'local' — single-user mode, identity from git config -// 'external' — multi-user mode, identity from OAuth/OIDC session -// 'readonly' — no identity, write operations disabled -export type AuthMode = "local" | "external" | "readonly"; - -export interface AuthContextValue { - user: AuthUser | null; - mode: AuthMode; - // List of enabled login provider names, e.g. ['github']. Only set in external mode. - loginProviders: string[]; - loading: boolean; -} - -const AuthContext = createContext({ - user: null, - mode: "readonly", - loginProviders: [], - loading: true, -}); - -// ── Local mode ──────────────────────────────────────────────────────────────── +import { createContext, useContext, type ReactNode } from "react"; const USER_IDENTITY_QUERY = gql` query UserIdentity { @@ -72,103 +23,39 @@ const USER_IDENTITY_QUERY = gql` } `; -function LocalAuthProvider({ - children, - loginProviders, -}: { - children: ReactNode; - loginProviders: string[]; -}) { - const { data, loading } = useQuery<{ repository: { userIdentity: AuthUser | null } }>( - USER_IDENTITY_QUERY, - ); - const user: AuthUser | null = data?.repository?.userIdentity ?? null; - const mode: AuthMode = loading ? "local" : user ? "local" : "readonly"; - return ( - - {children} - - ); +export interface AuthUser { + id: string; + humanId: string; + name: string | null; + displayName: string; + avatarUrl: string | null; + email: string | null; + login: string | null; } -// ── External (OAuth / OIDC) mode ────────────────────────────────────────────── - -// ExternalAuthProvider fetches the current user from the REST endpoint that the -// Go auth handler exposes. A 401 response means "not logged in" (user is null), -// not an error. -function ExternalAuthProvider({ - children, - loginProviders, -}: { - children: ReactNode; - loginProviders: string[]; -}) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); +export interface AuthContextValue { + user: AuthUser | null; + loading: boolean; +} - useEffect(() => { - void fetch("/auth/user", { credentials: "include" }) - .then(async (res) => { - if (res.status === 401) return null; - if (!res.ok) throw new Error(`/auth/user returned ${res.status}`); - return v.parse(authUserSchema, await res.json()); - }) - .then((u) => setUser(u)) - .catch(() => setUser(null)) - .finally(() => setLoading(false)); - }, []); +const AuthContext = createContext({ + user: null, + loading: true, +}); - return ( - - {children} - +export function AuthProvider({ children }: { children: ReactNode }) { + const { data, loading } = useQuery<{ repository: { userIdentity: AuthUser | null } }>( + USER_IDENTITY_QUERY, ); -} - -// ── Read-only mode ──────────────────────────────────────────────────────────── + const user: AuthUser | null = data?.repository?.userIdentity ?? null; -function ReadonlyAuthProvider({ children }: { children: ReactNode }) { return ( - + {children} ); } -// ── Root provider ───────────────────────────────────────────────────────────── - -// AuthProvider first fetches serverConfig to learn the auth mode, then renders -// the appropriate sub-provider. The split avoids conditional hook calls. -export function AuthProvider({ children }: { children: ReactNode }) { - const { data, loading } = useServerConfigQuery(); - - if (loading || !data) { - // Keep the default context (readonly + loading:true) while the config loads. - return ( - - {children} - - ); - } - - const { authMode, loginProviders } = data.serverConfig; - - if (authMode === "readonly") { - return {children}; - } - - if (authMode === "external") { - return {children}; - } - - // Default: 'local' - return {children}; -} - export function useAuth(): AuthContextValue { return useContext(AuthContext); } diff --git a/webui2/src/routeTree.gen.ts b/webui2/src/routeTree.gen.ts index de70c6c3880299cde655a3de6150d086b8e85a90..152d2202c1f8b694c7be5b1632e511523c923bf2 100644 --- a/webui2/src/routeTree.gen.ts +++ b/webui2/src/routeTree.gen.ts @@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as RepoRouteImport } from './routes/$repo' import { Route as IndexRouteImport } from './routes/index' import { Route as RepoIndexRouteImport } from './routes/$repo/index' -import { Route as AuthSelectIdentityRouteImport } from './routes/auth/select-identity' import { Route as RepoIssuesRouteImport } from './routes/$repo/_issues' import { Route as RepoCodeRouteImport } from './routes/$repo/_code' import { Route as RepoCommitHashRouteImport } from './routes/$repo/commit/$hash' @@ -39,11 +38,6 @@ const RepoIndexRoute = RepoIndexRouteImport.update({ path: '/', getParentRoute: () => RepoRoute, } as any) -const AuthSelectIdentityRoute = AuthSelectIdentityRouteImport.update({ - id: '/auth/select-identity', - path: '/auth/select-identity', - getParentRoute: () => rootRouteImport, -} as any) const RepoIssuesRoute = RepoIssuesRouteImport.update({ id: '/_issues', getParentRoute: () => RepoRoute, @@ -96,7 +90,6 @@ const RepoCodeBlobRefSplatRoute = RepoCodeBlobRefSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$repo': typeof RepoIssuesRouteWithChildren - '/auth/select-identity': typeof AuthSelectIdentityRoute '/$repo/': typeof RepoIndexRoute '/$repo/commit/$hash': typeof RepoCommitHashRoute '/$repo/commits/$ref': typeof RepoCodeCommitsRefRoute @@ -110,7 +103,6 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/$repo': typeof RepoIndexRoute - '/auth/select-identity': typeof AuthSelectIdentityRoute '/$repo/commit/$hash': typeof RepoCommitHashRoute '/$repo/commits/$ref': typeof RepoCodeCommitsRefRoute '/$repo/issues/$id': typeof RepoIssuesIssuesIdRoute @@ -126,7 +118,6 @@ export interface FileRoutesById { '/$repo': typeof RepoRouteWithChildren '/$repo/_code': typeof RepoCodeRouteWithChildren '/$repo/_issues': typeof RepoIssuesRouteWithChildren - '/auth/select-identity': typeof AuthSelectIdentityRoute '/$repo/': typeof RepoIndexRoute '/$repo/commit/$hash': typeof RepoCommitHashRoute '/$repo/_code/commits/$ref': typeof RepoCodeCommitsRefRoute @@ -142,7 +133,6 @@ export interface FileRouteTypes { fullPaths: | '/' | '/$repo' - | '/auth/select-identity' | '/$repo/' | '/$repo/commit/$hash' | '/$repo/commits/$ref' @@ -156,7 +146,6 @@ export interface FileRouteTypes { to: | '/' | '/$repo' - | '/auth/select-identity' | '/$repo/commit/$hash' | '/$repo/commits/$ref' | '/$repo/issues/$id' @@ -171,7 +160,6 @@ export interface FileRouteTypes { | '/$repo' | '/$repo/_code' | '/$repo/_issues' - | '/auth/select-identity' | '/$repo/' | '/$repo/commit/$hash' | '/$repo/_code/commits/$ref' @@ -186,7 +174,6 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute RepoRoute: typeof RepoRouteWithChildren - AuthSelectIdentityRoute: typeof AuthSelectIdentityRoute } declare module '@tanstack/react-router' { @@ -212,13 +199,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RepoIndexRouteImport parentRoute: typeof RepoRoute } - '/auth/select-identity': { - id: '/auth/select-identity' - path: '/auth/select-identity' - fullPath: '/auth/select-identity' - preLoaderRoute: typeof AuthSelectIdentityRouteImport - parentRoute: typeof rootRouteImport - } '/$repo/_issues': { id: '/$repo/_issues' path: '' @@ -345,7 +325,6 @@ const RepoRouteWithChildren = RepoRoute._addFileChildren(RepoRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, RepoRoute: RepoRouteWithChildren, - AuthSelectIdentityRoute: AuthSelectIdentityRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/webui2/src/routes/auth/select-identity.tsx b/webui2/src/routes/auth/select-identity.tsx deleted file mode 100644 index bde24d4a07982b4e3613fa102f8d71de00e8ba67..0000000000000000000000000000000000000000 --- a/webui2/src/routes/auth/select-identity.tsx +++ /dev/null @@ -1,139 +0,0 @@ -// Identity selection page (/auth/select-identity). -// -// Reached after a successful OAuth login when no existing git-bug identity -// could be matched automatically (via provider metadata set by the bridge). -// The user can either adopt an existing identity — which links it to their -// OAuth account for future logins — or create a fresh one from their OAuth -// profile. - -import { createFileRoute } from "@tanstack/react-router"; -import { UserCircle, Plus, AlertCircle } from "lucide-react"; -import { useEffect, useState } from "react"; -import * as v from "valibot"; - -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; - -export const Route = createFileRoute("/auth/select-identity")({ - component: RouteComponent, -}); - -const identityItemSchema = v.object({ - repoSlug: v.string(), - id: v.string(), - humanId: v.string(), - displayName: v.string(), - login: v.optional(v.string()), - avatarUrl: v.optional(v.string()), -}); - -type IdentityItem = v.InferOutput; - -function RouteComponent() { - const [identities, setIdentities] = useState(null); - const [error, setError] = useState(null); - const [working, setWorking] = useState(false); - - useEffect(() => { - async function loadIdentities() { - try { - const res = await fetch("/auth/identities", { credentials: "include" }); - if (!res.ok) throw new Error(`unexpected status ${res.status}`); - const data = v.parse(v.array(identityItemSchema), await res.json()); - setIdentities(data); - } catch (e) { - setError(String(e)); - } - } - void loadIdentities(); - }, []); - - async function adopt(identityId: string | null) { - setWorking(true); - try { - const res = await fetch("/auth/adopt", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(identityId ? { identityId } : {}), - }); - if (!res.ok) throw new Error(`adopt failed: ${res.status}`); - // Full page reload to reset Apollo cache and auth state cleanly. - window.location.assign("/"); - } catch (e) { - setError(String(e)); - setWorking(false); - } - } - - return ( -
-
- -

Choose your identity

-
-

- No git-bug identity was found linked to your account. Select an existing identity to link - it, or create a new one from your profile. -

- - {error && ( -
- - {error} -
- )} - - {!identities && !error && ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- )} - -
- {identities?.map((id) => ( -
-
-

{id.displayName}

-

- {id.login ? `@${id.login} · ` : ""} - {id.repoSlug} · {id.humanId} -

-
- -
- ))} - - {/* Always offer to create a new identity */} -
-
-

Create new identity

-

- A fresh git-bug identity will be created from your OAuth profile. -

-
- -
-
-
- ); -}