snapshot

Michael MurΓ© created

Change summary

api/auth/oauth/provider.go                | 37 ---------------
api/auth/provider/github.go               | 48 +++++++++++++++++++
api/auth/provider/provider.go             | 45 ++++++++++++++++++
api/graphql/graph/root.generated.go       | 16 +++---
api/graphql/graph/root_.generated.go      | 16 +++---
api/graphql/handler.go                    |  8 +-
api/graphql/models/gen_models.go          |  8 +-
api/graphql/resolvers/query.go            |  6 +-
api/graphql/resolvers/root.go             |  8 +-
api/graphql/schema/root.graphql           |  8 +-
api/http/auth_handler.go                  | 42 ++++++++--------
commands/webui.go                         | 22 ++++----
webui2/src/__generated__/graphql.ts       | 34 +++++++++----
webui2/src/components/bugs/CommentBox.tsx |  1 
webui2/src/components/bugs/Timeline.tsx   | 13 +++-
webui2/src/components/layout/Header.tsx   | 20 ++++----
webui2/src/graphql/BugList.graphql        | 20 +++++++
webui2/src/graphql/ServerConfig.graphql   |  2 
webui2/src/index.css                      | 61 ++++++++++++------------
webui2/src/lib/auth.tsx                   | 46 +++++++++---------
webui2/src/pages/BugListPage.tsx          | 61 ++++++++++++++----------
webui2/src/pages/IdentitySelectPage.tsx   |  1 
22 files changed, 309 insertions(+), 214 deletions(-)

Detailed changes

api/auth/oauth/provider.go πŸ”—

@@ -1,37 +0,0 @@
-// Package oauth defines the Provider interface and UserInfo type used for
-// external OAuth2 authentication in the webui.
-//
-// Each concrete provider (GitHub, GitLab, …) implements Provider and is
-// registered by passing it to the auth handler at server startup.
-// The generic oauth2 flow (PKCE, state, cookie) is handled by the auth
-// handler; providers only need to supply endpoints and profile fetching.
-package oauth
-
-import "context"
-
-// Provider represents an external OAuth2 identity provider.
-type Provider interface {
-	// Name returns the machine-readable identifier, e.g. "github".
-	Name() string
-
-	// HumanName returns a user-facing display label, e.g. "GitHub".
-	HumanName() string
-
-	// AuthURL returns the URL the browser should be redirected to in order
-	// to begin the authorization-code flow.
-	AuthURL(state, callbackURL string) string
-
-	// Exchange converts an authorization code into a normalised UserInfo.
-	// The callbackURL must match the one used in AuthURL.
-	Exchange(ctx context.Context, code, callbackURL string) (*UserInfo, error)
-}
-
-// UserInfo holds the normalised user profile returned by a provider after a
-// successful OAuth2 exchange. Fields may be empty when the provider does not
-// supply them.
-type UserInfo struct {
-	Login     string
-	Email     string
-	Name      string
-	AvatarURL string
-}

api/auth/oauth/github.go β†’ api/auth/provider/github.go πŸ”—

@@ -1,4 +1,4 @@
-package oauth
+package provider
 
 import (
 	"context"
@@ -15,6 +15,9 @@ var _ Provider = &GitHub{}
 // GitHub implements Provider for GitHub OAuth2.
 // It uses the standard authorization-code flow (not the device flow used by
 // the bridge) because the webui has a browser redirect available.
+//
+// GitHub does not support OpenID Connect, so this provider uses the GitHub
+// REST API to fetch profile and public key data after the token exchange.
 type GitHub struct {
 	clientID     string
 	clientSecret string
@@ -50,6 +53,22 @@ func (g *GitHub) Exchange(ctx context.Context, code, callbackURL string) (*UserI
 	}
 
 	client := g.config(callbackURL).Client(ctx, token)
+
+	user, err := g.fetchProfile(client)
+	if err != nil {
+		return nil, err
+	}
+
+	user.PublicKeys, err = g.fetchPublicKeys(client, user.Login)
+	if err != nil {
+		// Public keys are best-effort; a failure here should not block login.
+		user.PublicKeys = nil
+	}
+
+	return user, nil
+}
+
+func (g *GitHub) fetchProfile(client *http.Client) (*UserInfo, error) {
 	resp, err := client.Get("https://api.github.com/user")
 	if err != nil {
 		return nil, fmt.Errorf("github: fetch profile: %w", err)
@@ -77,3 +96,30 @@ func (g *GitHub) Exchange(ctx context.Context, code, callbackURL string) (*UserI
 		AvatarURL: u.AvatarURL,
 	}, nil
 }
+
+// fetchPublicKeys retrieves the user's public SSH keys from the GitHub API.
+// Returns the raw key strings (e.g. "ssh-ed25519 AAAA...").
+func (g *GitHub) fetchPublicKeys(client *http.Client, login string) ([]string, error) {
+	resp, err := client.Get("https://api.github.com/users/" + login + "/keys")
+	if err != nil {
+		return nil, fmt.Errorf("github: fetch keys: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("github: unexpected status %d from /keys", resp.StatusCode)
+	}
+
+	var keys []struct {
+		Key string `json:"key"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil {
+		return nil, fmt.Errorf("github: decode keys: %w", err)
+	}
+
+	result := make([]string, len(keys))
+	for i, k := range keys {
+		result[i] = k.Key
+	}
+	return result, nil
+}

api/auth/provider/provider.go πŸ”—

@@ -0,0 +1,45 @@
+// Package provider defines the Provider interface and UserInfo type used for
+// external authentication in the webui.
+//
+// Each concrete provider (GitHub, GitLab, OIDC, …) implements Provider and is
+// registered by passing it to the auth handler at server startup.
+// The generic authorization-code flow (state, cookie) is handled by the auth
+// handler; providers only need to supply endpoints and profile fetching.
+//
+// The Provider interface is deliberately protocol-agnostic: it works for both
+// OAuth 2.0 providers (GitHub, legacy systems) and OpenID Connect providers
+// (GitLab, Gitea, Keycloak, Google). OIDC is simply OAuth 2.0 + a standard
+// identity layer; the same AuthURL/Exchange flow applies to both.
+package provider
+
+import "context"
+
+// Provider represents an external identity provider.
+type Provider interface {
+	// Name returns the machine-readable identifier, e.g. "github".
+	Name() string
+
+	// HumanName returns a user-facing display label, e.g. "GitHub".
+	HumanName() string
+
+	// AuthURL returns the URL the browser should be redirected to in order
+	// to begin the authorization-code flow.
+	AuthURL(state, callbackURL string) string
+
+	// Exchange converts an authorization code into a normalised UserInfo.
+	// The callbackURL must match the one used in AuthURL.
+	Exchange(ctx context.Context, code, callbackURL string) (*UserInfo, error)
+}
+
+// UserInfo holds the normalised user profile returned by a provider after a
+// successful authorization-code exchange. Fields may be empty when the
+// provider does not supply them.
+type UserInfo struct {
+	Login      string
+	Email      string
+	Name       string
+	AvatarURL  string
+	// PublicKeys holds SSH or GPG public keys associated with the account,
+	// if the provider exposes them. Used to pre-populate identity key data.
+	PublicKeys []string
+}

api/graphql/graph/root.generated.go πŸ”—

@@ -1065,8 +1065,8 @@ func (ec *executionContext) fieldContext_Query_serverConfig(_ context.Context, f
 			switch field.Name {
 			case "authMode":
 				return ec.fieldContext_ServerConfig_authMode(ctx, field)
-			case "oauthProviders":
-				return ec.fieldContext_ServerConfig_oauthProviders(ctx, field)
+			case "loginProviders":
+				return ec.fieldContext_ServerConfig_loginProviders(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type ServerConfig", field.Name)
 		},
@@ -1382,8 +1382,8 @@ func (ec *executionContext) fieldContext_ServerConfig_authMode(_ context.Context
 	return fc, nil
 }
 
-func (ec *executionContext) _ServerConfig_oauthProviders(ctx context.Context, field graphql.CollectedField, obj *models.ServerConfig) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_ServerConfig_oauthProviders(ctx, field)
+func (ec *executionContext) _ServerConfig_loginProviders(ctx context.Context, field graphql.CollectedField, obj *models.ServerConfig) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_ServerConfig_loginProviders(ctx, field)
 	if err != nil {
 		return graphql.Null
 	}
@@ -1396,7 +1396,7 @@ func (ec *executionContext) _ServerConfig_oauthProviders(ctx context.Context, fi
 	}()
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
 		ctx = rctx // use context from middleware stack in children
-		return obj.OauthProviders, nil
+		return obj.LoginProviders, nil
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -1413,7 +1413,7 @@ func (ec *executionContext) _ServerConfig_oauthProviders(ctx context.Context, fi
 	return ec.marshalNString2αš•stringαš„(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) fieldContext_ServerConfig_oauthProviders(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+func (ec *executionContext) fieldContext_ServerConfig_loginProviders(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
 	fc = &graphql.FieldContext{
 		Object:     "ServerConfig",
 		Field:      field,
@@ -1672,8 +1672,8 @@ func (ec *executionContext) _ServerConfig(ctx context.Context, sel ast.Selection
 			if out.Values[i] == graphql.Null {
 				out.Invalids++
 			}
-		case "oauthProviders":
-			out.Values[i] = ec._ServerConfig_oauthProviders(ctx, field, obj)
+		case "loginProviders":
+			out.Values[i] = ec._ServerConfig_loginProviders(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
 				out.Invalids++
 			}

api/graphql/graph/root_.generated.go πŸ”—

@@ -404,7 +404,7 @@ type ComplexityRoot struct {
 
 	ServerConfig struct {
 		AuthMode       func(childComplexity int) int
-		OauthProviders func(childComplexity int) int
+		LoginProviders func(childComplexity int) int
 	}
 
 	Subscription struct {
@@ -1931,12 +1931,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.ServerConfig.AuthMode(childComplexity), true
 
-	case "ServerConfig.oauthProviders":
-		if e.complexity.ServerConfig.OauthProviders == nil {
+	case "ServerConfig.loginProviders":
+		if e.complexity.ServerConfig.LoginProviders == nil {
 			break
 		}
 
-		return e.complexity.ServerConfig.OauthProviders(childComplexity), true
+		return e.complexity.ServerConfig.LoginProviders(childComplexity), true
 
 	case "Subscription.allEvents":
 		if e.complexity.Subscription.AllEvents == nil {
@@ -2789,12 +2789,12 @@ type RepositoryEdge {
 	{Name: "../schema/root.graphql", Input: `"""Server-wide configuration, independent of any repository."""
 type ServerConfig {
     """Authentication mode: 'local' (single user from git config),
-    'oauth' (multi-user via external providers), or 'readonly'."""
+    'external' (multi-user via OAuth/OIDC providers), or 'readonly'."""
     authMode: String!
 
-    """Names of the OAuth providers enabled on this server, e.g. ['github'].
-    Empty when authMode is not 'oauth'."""
-    oauthProviders: [String!]!
+    """Names of the login providers enabled on this server, e.g. ['github'].
+    Empty when authMode is not 'external'."""
+    loginProviders: [String!]!
 }
 
 type Query {

api/graphql/handler.go πŸ”—

@@ -23,14 +23,14 @@ import (
 // ServerConfig carries server-level configuration that is passed down to
 // GraphQL resolvers. It is constructed once at startup and does not change.
 type ServerConfig struct {
-	// AuthMode is one of "local", "oauth", or "readonly".
+	// AuthMode is one of "local", "external", or "readonly".
 	AuthMode string
-	// OAuthProviders lists the names of enabled OAuth providers, e.g. ["github"].
-	OAuthProviders []string
+	// LoginProviders lists the names of enabled login providers, e.g. ["github"].
+	LoginProviders []string
 }
 
 func NewHandler(mrc *cache.MultiRepoCache, cfg ServerConfig, errorOut io.Writer) http.Handler {
-	rootResolver := resolvers.NewRootResolver(mrc, cfg.AuthMode, cfg.OAuthProviders)
+	rootResolver := resolvers.NewRootResolver(mrc, cfg.AuthMode, cfg.LoginProviders)
 	config := graph.Config{Resolvers: rootResolver}
 
 	h := handler.New(graph.NewExecutableSchema(config))

api/graphql/models/gen_models.go πŸ”—

@@ -345,11 +345,11 @@ type RepositoryEdge struct {
 // Server-wide configuration, independent of any repository.
 type ServerConfig struct {
 	// Authentication mode: 'local' (single user from git config),
-	//     'oauth' (multi-user via external providers), or 'readonly'.
+	//     'external' (multi-user via OAuth/OIDC providers), or 'readonly'.
 	AuthMode string `json:"authMode"`
-	// Names of the OAuth providers enabled on this server, e.g. ['github'].
-	//     Empty when authMode is not 'oauth'.
-	OauthProviders []string `json:"oauthProviders"`
+	// Names of the login providers enabled on this server, e.g. ['github'].
+	//     Empty when authMode is not 'external'.
+	LoginProviders []string `json:"loginProviders"`
 }
 
 type Subscription struct {

api/graphql/resolvers/query.go πŸ”—

@@ -14,20 +14,20 @@ var _ graph.QueryResolver = &rootQueryResolver{}
 type rootQueryResolver struct {
 	cache          *cache.MultiRepoCache
 	authMode       string
-	oauthProviders []string
+	loginProviders []string
 }
 
 // ServerConfig returns static server configuration including the auth mode.
 // The frontend uses this to decide whether to show a login button, show "Read only",
 // or operate silently in single-user local mode.
 func (r rootQueryResolver) ServerConfig(_ context.Context) (*models.ServerConfig, error) {
-	providers := r.oauthProviders
+	providers := r.loginProviders
 	if providers == nil {
 		providers = []string{}
 	}
 	return &models.ServerConfig{
 		AuthMode:       r.authMode,
-		OauthProviders: providers,
+		LoginProviders: providers,
 	}, nil
 }
 

api/graphql/resolvers/root.go πŸ”—

@@ -13,14 +13,14 @@ type RootResolver struct {
 	bugRootSubResolver
 
 	authMode       string
-	oauthProviders []string
+	loginProviders []string
 }
 
-func NewRootResolver(mrc *cache.MultiRepoCache, authMode string, oauthProviders []string) *RootResolver {
+func NewRootResolver(mrc *cache.MultiRepoCache, authMode string, loginProviders []string) *RootResolver {
 	return &RootResolver{
 		MultiRepoCache: mrc,
 		authMode:       authMode,
-		oauthProviders: oauthProviders,
+		loginProviders: loginProviders,
 	}
 }
 
@@ -28,7 +28,7 @@ func (r RootResolver) Query() graph.QueryResolver {
 	return &rootQueryResolver{
 		cache:          r.MultiRepoCache,
 		authMode:       r.authMode,
-		oauthProviders: r.oauthProviders,
+		loginProviders: r.loginProviders,
 	}
 }
 

api/graphql/schema/root.graphql πŸ”—

@@ -1,12 +1,12 @@
 """Server-wide configuration, independent of any repository."""
 type ServerConfig {
     """Authentication mode: 'local' (single user from git config),
-    'oauth' (multi-user via external providers), or 'readonly'."""
+    'external' (multi-user via OAuth/OIDC providers), or 'readonly'."""
     authMode: String!
 
-    """Names of the OAuth providers enabled on this server, e.g. ['github'].
-    Empty when authMode is not 'oauth'."""
-    oauthProviders: [String!]!
+    """Names of the login providers enabled on this server, e.g. ['github'].
+    Empty when authMode is not 'external'."""
+    loginProviders: [String!]!
 }
 
 type Query {

api/http/auth_handler.go πŸ”—

@@ -28,13 +28,13 @@ import (
 	"time"
 
 	apiauth "github.com/git-bug/git-bug/api/auth"
-	"github.com/git-bug/git-bug/api/auth/oauth"
+	"github.com/git-bug/git-bug/api/auth/provider"
 	"github.com/git-bug/git-bug/cache"
 	"github.com/git-bug/git-bug/entity"
 )
 
 const (
-	oauthStateCookie   = "git-bug-oauth-state"
+	authStateCookie    = "git-bug-auth-state"
 	oauthPendingCookie = "git-bug-pending"
 )
 
@@ -47,31 +47,31 @@ func providerMetaKey(providerName string) string {
 	return providerName + "-login"
 }
 
-// oauthState is JSON-encoded as the OAuth2 state parameter.
+// authState is JSON-encoded as the OAuth2/OIDC state parameter.
 // It carries both a CSRF nonce and the provider name, so the callback can
 // verify the request and dispatch to the right provider without extra cookies.
-type oauthState struct {
+type authState struct {
 	Nonce    string `json:"nonce"`
 	Provider string `json:"provider"`
 }
 
-// pendingAuth holds the OAuth profile for a user who has authenticated with
-// the provider but has not yet been linked to a git-bug identity.
+// pendingAuth holds the provider profile for a user who has authenticated
+// but has not yet been linked to a git-bug identity.
 // It expires after 10 minutes to limit the window for token reuse.
 type pendingAuth struct {
-	UserInfo  *oauth.UserInfo
+	UserInfo  *provider.UserInfo
 	Provider  string
 	ExpiresAt time.Time
 }
 
-// AuthHandler handles the full OAuth2 login flow. It is intentionally
-// provider-agnostic: concrete providers implement oauth.Provider and are
-// passed in at construction time.
+// AuthHandler handles the external login flow (OAuth 2.0 or OIDC).
+// It is protocol-agnostic: concrete providers implement provider.Provider and
+// are passed in at construction time.
 type AuthHandler struct {
 	mrc       *cache.MultiRepoCache
 	sessions  *apiauth.SessionStore
-	providers map[string]oauth.Provider // provider name β†’ implementation
-	baseURL   string                    // e.g. "http://localhost:3000"
+	providers map[string]provider.Provider // provider name β†’ implementation
+	baseURL   string                       // e.g. "http://localhost:3000"
 
 	// pending maps a short-lived random token (stored in a cookie) to an
 	// OAuth profile that needs identity selection before a real session is
@@ -80,8 +80,8 @@ type AuthHandler struct {
 	pending   map[string]*pendingAuth
 }
 
-func NewAuthHandler(mrc *cache.MultiRepoCache, sessions *apiauth.SessionStore, providers []oauth.Provider, baseURL string) *AuthHandler {
-	pm := make(map[string]oauth.Provider, len(providers))
+func NewAuthHandler(mrc *cache.MultiRepoCache, sessions *apiauth.SessionStore, providers []provider.Provider, baseURL string) *AuthHandler {
+	pm := make(map[string]provider.Provider, len(providers))
 	for _, p := range providers {
 		pm[p.Name()] = p
 	}
@@ -125,14 +125,14 @@ func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	stateData, _ := json.Marshal(oauthState{Nonce: nonce, Provider: providerName})
+	stateData, _ := json.Marshal(authState{Nonce: nonce, Provider: providerName})
 	stateEncoded := base64.RawURLEncoding.EncodeToString(stateData)
 
 	// Store the state in a short-lived cookie for CSRF verification on callback.
 	http.SetCookie(w, &http.Cookie{
-		Name:     oauthStateCookie,
+		Name:     authStateCookie,
 		Value:    stateEncoded,
-		MaxAge:   300, // 5 minutes β€” enough time to complete the OAuth redirect
+		MaxAge:   300, // 5 minutes β€” enough time to complete the login redirect
 		HttpOnly: true,
 		SameSite: http.SameSiteLaxMode,
 		Path:     "/",
@@ -145,19 +145,19 @@ func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
 // GET /auth/callback?code=...&state=...
 func (h *AuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
 	// CSRF: verify that the state parameter matches the cookie we set.
-	stateCookie, err := r.Cookie(oauthStateCookie)
+	stateCookie, err := r.Cookie(authStateCookie)
 	if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
-		http.Error(w, "invalid OAuth state", http.StatusBadRequest)
+		http.Error(w, "invalid auth state", http.StatusBadRequest)
 		return
 	}
-	http.SetCookie(w, &http.Cookie{Name: oauthStateCookie, MaxAge: -1, Path: "/"})
+	http.SetCookie(w, &http.Cookie{Name: authStateCookie, MaxAge: -1, Path: "/"})
 
 	stateBytes, err := base64.RawURLEncoding.DecodeString(stateCookie.Value)
 	if err != nil {
 		http.Error(w, "malformed state", http.StatusBadRequest)
 		return
 	}
-	var state oauthState
+	var state authState
 	if err := json.Unmarshal(stateBytes, &state); err != nil {
 		http.Error(w, "malformed state", http.StatusBadRequest)
 		return

commands/webui.go πŸ”—

@@ -22,7 +22,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/git-bug/git-bug/api/auth"
-	"github.com/git-bug/git-bug/api/auth/oauth"
+	"github.com/git-bug/git-bug/api/auth/provider"
 	"github.com/git-bug/git-bug/api/graphql"
 	httpapi "github.com/git-bug/git-bug/api/http"
 	"github.com/git-bug/git-bug/cache"
@@ -105,10 +105,10 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 		toOpen = fmt.Sprintf("%s/?q=%s", webUiAddr, url.QueryEscape(opts.query))
 	}
 
-	// Collect enabled OAuth providers.
-	var providers []oauth.Provider
+	// Collect enabled login providers.
+	var providers []provider.Provider
 	if opts.githubClientId != "" {
-		providers = append(providers, oauth.NewGitHub(opts.githubClientId, opts.githubClientSecret))
+		providers = append(providers, provider.NewGitHub(opts.githubClientId, opts.githubClientSecret))
 	}
 
 	// Determine auth mode and configure middleware accordingly.
@@ -122,7 +122,7 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 		// No middleware: every request is unauthenticated.
 
 	case len(providers) > 0:
-		authMode = "oauth"
+		authMode = "external"
 		sessions = auth.NewSessionStore()
 		router.Use(auth.SessionMiddleware(sessions))
 
@@ -158,11 +158,11 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 
 	graphqlHandler := graphql.NewHandler(mrc, graphql.ServerConfig{
 		AuthMode:       authMode,
-		OAuthProviders: providerNames,
+		LoginProviders: providerNames,
 	}, errOut)
 
 	// Register OAuth routes before the catch-all static handler.
-	if authMode == "oauth" {
+	if authMode == "external" {
 		ah := httpapi.NewAuthHandler(mrc, sessions, providers, baseURL)
 		router.Path("/auth/login").Methods("GET").HandlerFunc(ah.HandleLogin)
 		router.Path("/auth/callback").Methods("GET").HandlerFunc(ah.HandleCallback)
@@ -184,7 +184,7 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	// server safe to deploy publicly. In local and readonly modes the
 	// middleware only injects identity without blocking.
 	apiRepos := router.PathPrefix("/api/repos/{owner}/{repo}").Subrouter()
-	if authMode == "oauth" {
+	if authMode == "external" {
 		apiRepos.Use(auth.RequireAuth)
 	}
 	apiRepos.Path("/git/refs").Methods("GET").Handler(httpapi.NewGitRefsHandler(mrc))
@@ -234,9 +234,9 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	env.Out.Printf("Web UI: %s\n", webUiAddr)
 	env.Out.Printf("Graphql API: http://%s/graphql\n", addr)
 	env.Out.Printf("Graphql Playground: http://%s/playground\n", addr)
-	if authMode == "oauth" {
-		env.Out.Printf("OAuth callback URL: %s/auth/callback\n", baseURL)
-		env.Out.Println("  ↳ Register this URL in your OAuth application settings")
+	if authMode == "external" {
+		env.Out.Printf("Login callback URL: %s/auth/callback\n", baseURL)
+		env.Out.Println("  ↳ Register this URL in your OAuth/OIDC application settings")
 	}
 	env.Out.Println("Press Ctrl+c to quit")
 

webui2/src/__generated__/graphql.ts πŸ”—

@@ -823,14 +823,14 @@ export type ServerConfig = {
   __typename?: 'ServerConfig';
   /**
    * Authentication mode: 'local' (single user from git config),
-   * 'oauth' (multi-user via external providers), or 'readonly'.
+   * 'external' (multi-user via OAuth/OIDC providers), or 'readonly'.
    */
   authMode: Scalars['String']['output'];
   /**
-   * Names of the OAuth providers enabled on this server, e.g. ['github'].
-   * Empty when authMode is not 'oauth'.
+   * Names of the login providers enabled on this server, e.g. ['github'].
+   * Empty when authMode is not 'external'.
    */
-  oauthProviders: Array<Scalars['String']['output']>;
+  loginProviders: Array<Scalars['String']['output']>;
 };
 
 export enum Status {
@@ -881,13 +881,15 @@ export type BugDetailQuery = { __typename?: 'Query', repository?: { __typename?:
 
 export type BugListQueryVariables = Exact<{
   ref?: InputMaybe<Scalars['String']['input']>;
-  query?: InputMaybe<Scalars['String']['input']>;
+  openQuery: Scalars['String']['input'];
+  closedQuery: Scalars['String']['input'];
+  listQuery: Scalars['String']['input'];
   first?: InputMaybe<Scalars['Int']['input']>;
   after?: InputMaybe<Scalars['String']['input']>;
 }>;
 
 
-export type BugListQuery = { __typename?: 'Query', repository?: { __typename?: 'Repository', allBugs: { __typename?: 'BugConnection', totalCount: number, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<{ __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 } }> } } | null };
+export type BugListQuery = { __typename?: 'Query', repository?: { __typename?: 'Repository', openCount: { __typename?: 'BugConnection', totalCount: number }, closedCount: { __typename?: 'BugConnection', totalCount: number }, bugs: { __typename?: 'BugConnection', totalCount: number, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<{ __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 } }> } } | null };
 
 export type BugCreateMutationVariables = Exact<{
   input: BugCreateInput;
@@ -960,7 +962,7 @@ export type RepositoriesQuery = { __typename?: 'Query', repositories: { __typena
 export type ServerConfigQueryVariables = Exact<{ [key: string]: never; }>;
 
 
-export type ServerConfigQuery = { __typename?: 'Query', serverConfig: { __typename?: 'ServerConfig', authMode: string, oauthProviders: Array<string> } };
+export type ServerConfigQuery = { __typename?: 'Query', serverConfig: { __typename?: 'ServerConfig', authMode: string, loginProviders: Array<string> } };
 
 export type UserProfileQueryVariables = Exact<{
   ref?: InputMaybe<Scalars['String']['input']>;
@@ -1179,9 +1181,15 @@ export type BugDetailLazyQueryHookResult = ReturnType<typeof useBugDetailLazyQue
 export type BugDetailSuspenseQueryHookResult = ReturnType<typeof useBugDetailSuspenseQuery>;
 export type BugDetailQueryResult = Apollo.QueryResult<BugDetailQuery, BugDetailQueryVariables>;
 export const BugListDocument = gql`
-    query BugList($ref: String, $query: String, $first: Int, $after: String) {
+    query BugList($ref: String, $openQuery: String!, $closedQuery: String!, $listQuery: String!, $first: Int, $after: String) {
   repository(ref: $ref) {
-    allBugs(query: $query, first: $first, after: $after) {
+    openCount: allBugs(query: $openQuery, first: 1) {
+      totalCount
+    }
+    closedCount: allBugs(query: $closedQuery, first: 1) {
+      totalCount
+    }
+    bugs: allBugs(query: $listQuery, first: $first, after: $after) {
       totalCount
       pageInfo {
         hasNextPage
@@ -1229,13 +1237,15 @@ export const BugListDocument = gql`
  * const { data, loading, error } = useBugListQuery({
  *   variables: {
  *      ref: // value for 'ref'
- *      query: // value for 'query'
+ *      openQuery: // value for 'openQuery'
+ *      closedQuery: // value for 'closedQuery'
+ *      listQuery: // value for 'listQuery'
  *      first: // value for 'first'
  *      after: // value for 'after'
  *   },
  * });
  */
-export function useBugListQuery(baseOptions?: Apollo.QueryHookOptions<BugListQuery, BugListQueryVariables>) {
+export function useBugListQuery(baseOptions: Apollo.QueryHookOptions<BugListQuery, BugListQueryVariables> & ({ variables: BugListQueryVariables; skip?: boolean; } | { skip: boolean; }) ) {
         const options = {...defaultOptions, ...baseOptions}
         return Apollo.useQuery<BugListQuery, BugListQueryVariables>(BugListDocument, options);
       }
@@ -1630,7 +1640,7 @@ export const ServerConfigDocument = gql`
     query ServerConfig {
   serverConfig {
     authMode
-    oauthProviders
+    loginProviders
   }
 }
     `;

webui2/src/components/bugs/CommentBox.tsx πŸ”—

@@ -122,6 +122,7 @@ export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
             size="sm"
             onClick={handleToggleStatus}
             disabled={busy}
+            className="min-w-[7.5rem]"
           >
             {isOpen ? 'Close issue' : 'Reopen issue'}
           </Button>

webui2/src/components/bugs/Timeline.tsx πŸ”—

@@ -14,6 +14,7 @@ import {
   BugDetailDocument,
 } from '@/__generated__/graphql'
 import { useAuth } from '@/lib/auth'
+import { useRepo } from '@/lib/repo'
 
 type TimelineNode = NonNullable<
   NonNullable<NonNullable<BugDetailQuery['repository']>['bug']>['timeline']['nodes'][number]
@@ -58,6 +59,7 @@ type CommentItem = Extract<
 
 function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string }) {
   const { user } = useAuth()
+  const repo = useRepo()
   const [editing, setEditing] = useState(false)
   const [editValue, setEditValue] = useState(item.message ?? '')
 
@@ -93,7 +95,7 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
 
       <div className="min-w-0 flex-1 rounded-md border border-border">
         <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2 text-sm">
-          <Link to={`/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">
+          <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">
             {item.author.displayName}
           </Link>
           <span className="text-muted-foreground">
@@ -164,10 +166,11 @@ function EventRow({ icon, children }: { icon: React.ReactNode; children: React.R
 }
 
 function LabelChangeItem({ item }: { item: LabelChangeItem }) {
+  const repo = useRepo()
   return (
     <EventRow icon={<Tag className="size-4" />}>
       <span>
-        <Link to={`/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link>{' '}
+        <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link>{' '}
         {item.added.length > 0 && (
           <>
             added{' '}
@@ -191,6 +194,7 @@ function LabelChangeItem({ item }: { item: LabelChangeItem }) {
 }
 
 function StatusChangeItem({ item }: { item: StatusChangeItem }) {
+  const repo = useRepo()
   const isOpen = item.status === Status.Open
   return (
     <EventRow
@@ -203,7 +207,7 @@ function StatusChangeItem({ item }: { item: StatusChangeItem }) {
       }
     >
       <span>
-        <Link to={`/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link>{' '}
+        <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link>{' '}
         {isOpen ? 'reopened' : 'closed'} this{' '}
         {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
       </span>
@@ -212,10 +216,11 @@ function StatusChangeItem({ item }: { item: StatusChangeItem }) {
 }
 
 function TitleChangeItem({ item }: { item: TitleChangeItem }) {
+  const repo = useRepo()
   return (
     <EventRow icon={<Pencil className="size-4" />}>
       <span>
-        <Link to={`/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link> changed the
+        <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link> changed the
         title from <span className="line-through">{item.was}</span> to{' '}
         <span className="font-medium text-foreground">{item.title}</span>{' '}
         {formatDistanceToNow(new Date(item.date), { addSuffix: true })}

webui2/src/components/layout/Header.tsx πŸ”—

@@ -3,7 +3,7 @@
 //   - Root: shows logo only, no Code/Issues links
 //   - Repo: shows Code + Issues nav links scoped to the current repo slug
 //
-// In oauth mode, shows a "Sign in" button when logged out and a sign-out
+// In external mode, shows a "Sign in" button when logged out and a sign-out
 // action when logged in.
 
 import { Link, useMatch, NavLink } from 'react-router-dom'
@@ -30,7 +30,7 @@ function SignOutButton() {
 }
 
 export function Header() {
-  const { user, mode, oauthProviders } = useAuth()
+  const { user, mode, loginProviders } = useAuth()
   const { theme, toggle } = useTheme()
 
   // Detect if we're inside a /:repo route and grab the slug.
@@ -89,19 +89,19 @@ export function Header() {
             {theme === 'light' ? <Moon className="size-4" /> : <Sun className="size-4" />}
           </Button>
 
-          {/* OAuth mode: show sign-in buttons when logged out */}
-          {mode === 'oauth' && !user && oauthProviders.map((provider) => (
-            <Button key={provider} asChild size="sm" variant="outline">
-              <a href={`/auth/login?provider=${provider}`}>
+          {/* External mode: show sign-in buttons when logged out */}
+          {mode === 'external' && !user && loginProviders.map((p) => (
+            <Button key={p} asChild size="sm">
+              <a href={`/auth/login?provider=${p}`}>
                 <LogIn className="size-4" />
-                Sign in with {providerLabel(provider)}
+                Sign in with {providerLabel(p)}
               </a>
             </Button>
           ))}
 
           {user && effectiveRepo && (
             <>
-              <Button asChild size="sm" variant="outline">
+              <Button asChild size="sm">
                 <Link to={`/${effectiveRepo}/issues/new`}>
                   <Plus className="size-4" />
                   New issue
@@ -118,8 +118,8 @@ export function Header() {
             </>
           )}
 
-          {/* Sign out only shown in oauth mode when logged in */}
-          {mode === 'oauth' && user && <SignOutButton />}
+          {/* Sign out only shown in external mode when logged in */}
+          {mode === 'external' && user && <SignOutButton />}
         </div>
       </div>
     </header>

webui2/src/graphql/BugList.graphql πŸ”—

@@ -1,6 +1,22 @@
-query BugList($ref: String, $query: String, $first: Int, $after: String) {
+# Always fetch open and closed counts unconditionally so both numbers are
+# visible in the toggle header regardless of which tab is active.
+# The paginated `bugs` alias fetches the selected-status list separately.
+query BugList(
+  $ref: String
+  $openQuery: String!
+  $closedQuery: String!
+  $listQuery: String!
+  $first: Int
+  $after: String
+) {
   repository(ref: $ref) {
-    allBugs(query: $query, first: $first, after: $after) {
+    openCount: allBugs(query: $openQuery, first: 1) {
+      totalCount
+    }
+    closedCount: allBugs(query: $closedQuery, first: 1) {
+      totalCount
+    }
+    bugs: allBugs(query: $listQuery, first: $first, after: $after) {
       totalCount
       pageInfo {
         hasNextPage

webui2/src/index.css πŸ”—

@@ -7,50 +7,51 @@
 
 @layer base {
   :root {
-    /* zinc palette β€” clean neutral, GitHub-adjacent */
+    /* Blue-accented light palette. Primary is GitHub-style indigo-blue so
+       action buttons are clearly coloured, not a flat dark grey. */
     --background: 0 0% 100%;
-    --foreground: 240 10% 3.9%;
+    --foreground: 222 20% 18%;
     --card: 0 0% 100%;
-    --card-foreground: 240 10% 3.9%;
+    --card-foreground: 222 20% 18%;
     --popover: 0 0% 100%;
-    --popover-foreground: 240 10% 3.9%;
-    --primary: 240 5.9% 10%;
-    --primary-foreground: 0 0% 98%;
-    --secondary: 240 4.8% 95.9%;
-    --secondary-foreground: 240 5.9% 10%;
-    --muted: 240 4.8% 95.9%;
-    --muted-foreground: 240 3.8% 46.1%;
-    --accent: 240 4.8% 95.9%;
-    --accent-foreground: 240 5.9% 10%;
+    --popover-foreground: 222 20% 18%;
+    --primary: 212 88% 44%;
+    --primary-foreground: 0 0% 100%;
+    --secondary: 214 32% 95%;
+    --secondary-foreground: 222 20% 18%;
+    --muted: 214 32% 96%;
+    --muted-foreground: 220 9% 46%;
+    --accent: 214 88% 95%;
+    --accent-foreground: 212 88% 35%;
     --destructive: 0 84.2% 60.2%;
     --destructive-foreground: 0 0% 98%;
-    --border: 240 5.9% 90%;
-    --input: 240 5.9% 90%;
-    --ring: 240 5.9% 10%;
-    --radius: 0.375rem;
+    --border: 214 32% 88%;
+    --input: 214 32% 88%;
+    --ring: 212 88% 44%;
+    --radius: 0.5rem;
   }
 
   .dark {
-    /* dimmed dark β€” comfortable grey, not pitch black */
+    /* Dimmed dark β€” comfortable grey with blue-tinted accents. */
     --background: 220 13% 13%;
-    --foreground: 220 10% 88%;
+    --foreground: 220 12% 84%;
     --card: 220 13% 16%;
-    --card-foreground: 220 10% 88%;
+    --card-foreground: 220 12% 84%;
     --popover: 220 13% 16%;
-    --popover-foreground: 220 10% 88%;
-    --primary: 220 10% 90%;
-    --primary-foreground: 220 13% 11%;
-    --secondary: 220 10% 22%;
-    --secondary-foreground: 220 10% 88%;
-    --muted: 220 10% 22%;
+    --popover-foreground: 220 12% 84%;
+    --primary: 213 88% 62%;
+    --primary-foreground: 220 20% 10%;
+    --secondary: 220 12% 22%;
+    --secondary-foreground: 220 12% 84%;
+    --muted: 220 12% 22%;
     --muted-foreground: 220 8% 55%;
-    --accent: 220 10% 22%;
-    --accent-foreground: 220 10% 88%;
+    --accent: 220 20% 26%;
+    --accent-foreground: 213 88% 72%;
     --destructive: 0 65% 50%;
     --destructive-foreground: 0 0% 98%;
-    --border: 220 10% 26%;
-    --input: 220 10% 26%;
-    --ring: 220 10% 70%;
+    --border: 220 12% 28%;
+    --input: 220 12% 28%;
+    --ring: 213 88% 62%;
   }
 }
 

webui2/src/lib/auth.tsx πŸ”—

@@ -5,8 +5,8 @@
 //   local    Single-user mode. The identity is taken from git config at
 //            server startup. No login UI is needed.
 //
-//   oauth    Multi-user mode. Users log in via an OAuth provider. The
-//            current user is fetched from GET /auth/user and can be null
+//   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.
@@ -30,22 +30,22 @@ export interface AuthUser {
 }
 
 // 'local'    β€” single-user mode, identity from git config
-// 'oauth'    β€” multi-user mode, identity from OAuth session
+// 'external' β€” multi-user mode, identity from OAuth/OIDC session
 // 'readonly' β€” no identity, write operations disabled
-export type AuthMode = 'local' | 'oauth' | 'readonly'
+export type AuthMode = 'local' | 'external' | 'readonly'
 
 export interface AuthContextValue {
   user: AuthUser | null
   mode: AuthMode
-  // List of enabled OAuth provider names, e.g. ['github']. Only set in oauth mode.
-  oauthProviders: string[]
+  // List of enabled login provider names, e.g. ['github']. Only set in external mode.
+  loginProviders: string[]
   loading: boolean
 }
 
 const AuthContext = createContext<AuthContextValue>({
   user: null,
   mode: 'readonly',
-  oauthProviders: [],
+  loginProviders: [],
   loading: true,
 })
 
@@ -69,32 +69,32 @@ const USER_IDENTITY_QUERY = gql`
 
 function LocalAuthProvider({
   children,
-  oauthProviders,
+  loginProviders,
 }: {
   children: ReactNode
-  oauthProviders: string[]
+  loginProviders: string[]
 }) {
   const { data, loading } = useQuery(USER_IDENTITY_QUERY)
   const user: AuthUser | null = data?.repository?.userIdentity ?? null
   const mode: AuthMode = loading ? 'local' : user ? 'local' : 'readonly'
   return (
-    <AuthContext.Provider value={{ user, mode, oauthProviders, loading }}>
+    <AuthContext.Provider value={{ user, mode, loginProviders, loading }}>
       {children}
     </AuthContext.Provider>
   )
 }
 
-// ── OAuth mode ────────────────────────────────────────────────────────────────
+// ── External (OAuth / OIDC) mode ──────────────────────────────────────────────
 
-// OAuthAuthProvider fetches the current user from the REST endpoint that the
+// 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 OAuthAuthProvider({
+function ExternalAuthProvider({
   children,
-  oauthProviders,
+  loginProviders,
 }: {
   children: ReactNode
-  oauthProviders: string[]
+  loginProviders: string[]
 }) {
   const [user, setUser] = useState<AuthUser | null>(null)
   const [loading, setLoading] = useState(true)
@@ -113,7 +113,7 @@ function OAuthAuthProvider({
 
   return (
     <AuthContext.Provider
-      value={{ user, mode: 'oauth', oauthProviders, loading }}
+      value={{ user, mode: 'external', loginProviders, loading }}
     >
       {children}
     </AuthContext.Provider>
@@ -125,7 +125,7 @@ function OAuthAuthProvider({
 function ReadonlyAuthProvider({ children }: { children: ReactNode }) {
   return (
     <AuthContext.Provider
-      value={{ user: null, mode: 'readonly', oauthProviders: [], loading: false }}
+      value={{ user: null, mode: 'readonly', loginProviders: [], loading: false }}
     >
       {children}
     </AuthContext.Provider>
@@ -143,30 +143,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
     // Keep the default context (readonly + loading:true) while the config loads.
     return (
       <AuthContext.Provider
-        value={{ user: null, mode: 'readonly', oauthProviders: [], loading: true }}
+        value={{ user: null, mode: 'readonly', loginProviders: [], loading: true }}
       >
         {children}
       </AuthContext.Provider>
     )
   }
 
-  const { authMode, oauthProviders } = data.serverConfig
+  const { authMode, loginProviders } = data.serverConfig
 
   if (authMode === 'readonly') {
     return <ReadonlyAuthProvider>{children}</ReadonlyAuthProvider>
   }
 
-  if (authMode === 'oauth') {
+  if (authMode === 'external') {
     return (
-      <OAuthAuthProvider oauthProviders={oauthProviders}>
+      <ExternalAuthProvider loginProviders={loginProviders}>
         {children}
-      </OAuthAuthProvider>
+      </ExternalAuthProvider>
     )
   }
 
   // Default: 'local'
   return (
-    <LocalAuthProvider oauthProviders={oauthProviders}>
+    <LocalAuthProvider loginProviders={loginProviders}>
       {children}
     </LocalAuthProvider>
   )

webui2/src/pages/BugListPage.tsx πŸ”—

@@ -31,20 +31,27 @@ export function BugListPage() {
   const [cursors, setCursors] = useState<(string | undefined)[]>([undefined])
   const page = cursors.length - 1  // 0-indexed current page
 
-  const query = buildQueryString(statusFilter, selectedLabels, selectedAuthorQuery, freeText)
+  // Build separate query strings: two for the always-visible counts (open/closed),
+  // one for the paginated list. The count queries share all filters except status.
+  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText)
+  const openQuery = `status:open ${baseQuery}`.trim()
+  const closedQuery = `status:closed ${baseQuery}`.trim()
+  const listQuery = buildQueryString(statusFilter, selectedLabels, selectedAuthorQuery, freeText)
 
   const { data, loading, error } = useBugListQuery({
-    variables: { ref: repo, query, first: PAGE_SIZE, after: cursors[page] },
+    variables: { ref: repo, openQuery, closedQuery, listQuery, first: PAGE_SIZE, after: cursors[page] },
   })
 
-  const bugs = data?.repository?.allBugs
+  const openCount = data?.repository?.openCount.totalCount ?? 0
+  const closedCount = data?.repository?.closedCount.totalCount ?? 0
+  const bugs = data?.repository?.bugs
   const totalCount = bugs?.totalCount ?? 0
   const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
   const hasNext = bugs?.pageInfo.hasNextPage ?? false
   const hasPrev = page > 0
 
-  // Reset to page 1 whenever the query changes.
-  useEffect(() => { setCursors([undefined]) }, [query])
+  // Reset to page 1 whenever the list query changes.
+  useEffect(() => { setCursors([undefined]) }, [listQuery])
 
   // Apply all filters at once, keeping draft in sync with the structured state.
   function applyFilters(
@@ -92,7 +99,7 @@ export function BugListPage() {
           onSubmit={handleSearch}
           placeholder="status:open author:… label:…"
         />
-        <Button type="submit" variant="outline">
+        <Button type="submit">
           Search
         </Button>
       </form>
@@ -113,11 +120,9 @@ export function BugListPage() {
             >
               <CircleDot className={cn('size-4', statusFilter === 'open' && 'text-green-600 dark:text-green-400')} />
               Open
-              {bugs && statusFilter === 'open' && (
-                <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none">
-                  {bugs.totalCount}
-                </span>
-              )}
+              <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none tabular-nums">
+                {openCount}
+              </span>
             </button>
 
             <button
@@ -131,11 +136,9 @@ export function BugListPage() {
             >
               <CircleCheck className={cn('size-4', statusFilter === 'closed' && 'text-purple-600 dark:text-purple-400')} />
               Closed
-              {bugs && statusFilter === 'closed' && (
-                <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none">
-                  {bugs.totalCount}
-                </span>
-              )}
+              <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none tabular-nums">
+                {closedCount}
+              </span>
             </button>
           </div>
 
@@ -145,7 +148,7 @@ export function BugListPage() {
               onLabelsChange={(labels) => applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)}
               selectedAuthorId={selectedAuthorId}
               onAuthorChange={(id, qv) => applyFilters(statusFilter, selectedLabels, id, qv, freeText)}
-              recentAuthorIds={bugs?.nodes.map((b) => b.author.humanId) ?? []}
+              recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
             />
           </div>
         </div>
@@ -217,6 +220,20 @@ export function BugListPage() {
   )
 }
 
+// buildBaseQuery returns the filter parts (labels, author, freeText) without
+// the status prefix, so it can be combined with "status:open" / "status:closed".
+function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
+  const parts: string[] = []
+  for (const label of labels) {
+    parts.push(label.includes(' ') ? `label:"${label}"` : `label:${label}`)
+  }
+  if (author) {
+    parts.push(author.includes(' ') ? `author:"${author}"` : `author:${author}`)
+  }
+  if (freeText.trim()) parts.push(freeText.trim())
+  return parts.join(' ')
+}
+
 // Build the structured query string sent to the GraphQL allBugs(query:) argument.
 // Multi-word label/author values are wrapped in quotes so the backend parser
 // treats them as a single token (e.g. label:"my label" vs label:my label).
@@ -226,15 +243,7 @@ function buildQueryString(
   author: string | null,
   freeText: string,
 ): string {
-  const parts = [`status:${status}`]
-  for (const label of labels) {
-    parts.push(label.includes(' ') ? `label:"${label}"` : `label:${label}`)
-  }
-  if (author) {
-    parts.push(author.includes(' ') ? `author:"${author}"` : `author:${author}`)
-  }
-  if (freeText.trim()) parts.push(freeText.trim())
-  return parts.join(' ')
+  return `status:${status} ${buildBaseQuery(labels, author, freeText)}`.trim()
 }
 
 // Tokenize a query string, keeping quoted spans (e.g. author:"RenΓ© Descartes")