From e05d0d04892a5fbc0e548a58e1265c92d3f22de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Sat, 14 Mar 2026 13:44:52 +0100 Subject: [PATCH] snapshot --- api/auth/oauth/provider.go | 37 -------------- api/auth/{oauth => 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(-) delete mode 100644 api/auth/oauth/provider.go rename api/auth/{oauth => provider}/github.go (61%) create mode 100644 api/auth/provider/provider.go diff --git a/api/auth/oauth/provider.go b/api/auth/oauth/provider.go deleted file mode 100644 index 04563d1ea24ba78a2bef1ef6f5fb00edd35ca900..0000000000000000000000000000000000000000 --- a/api/auth/oauth/provider.go +++ /dev/null @@ -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 -} diff --git a/api/auth/oauth/github.go b/api/auth/provider/github.go similarity index 61% rename from api/auth/oauth/github.go rename to api/auth/provider/github.go index 4c000f46e8d59b93afd3643ccb76a104282a629a..9ae68a8f5363d2b90ed13ea9ae989d708ff7bb6a 100644 --- a/api/auth/oauth/github.go +++ b/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 +} diff --git a/api/auth/provider/provider.go b/api/auth/provider/provider.go new file mode 100644 index 0000000000000000000000000000000000000000..1fb45bdf0539d62edea131ace20e68d120a61299 --- /dev/null +++ b/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 +} diff --git a/api/graphql/graph/root.generated.go b/api/graphql/graph/root.generated.go index 9d51fb9ed357fd9f0aa5ec2dfb5fbea78ead61b5..885d556efc903fddff6ced9d8427cda6548fbaf1 100644 --- a/api/graphql/graph/root.generated.go +++ b/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++ } diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go index 3c17263d6936e87b22914d6c5a118ed9655a3795..a6a5ef1939b78093be919c22390b9d332cf42578 100644 --- a/api/graphql/graph/root_.generated.go +++ b/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 { diff --git a/api/graphql/handler.go b/api/graphql/handler.go index f3e62bac9c4260054efdd2ae447f05dc2ba2c9ac..733f8369f2ae0a2395813eee1a8ec8c5ee72cdf0 100644 --- a/api/graphql/handler.go +++ b/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)) diff --git a/api/graphql/models/gen_models.go b/api/graphql/models/gen_models.go index 8d43befb8535a261ac88b7b53984a24b65171fce..17bbf84af179bae4887769f88ec04c7dc5dfcde2 100644 --- a/api/graphql/models/gen_models.go +++ b/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 { diff --git a/api/graphql/resolvers/query.go b/api/graphql/resolvers/query.go index e4f1995587146680beb87f9f5c295743c74ee5ab..7e47a28980e92290e189d6365d22818fe2f61f85 100644 --- a/api/graphql/resolvers/query.go +++ b/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 } diff --git a/api/graphql/resolvers/root.go b/api/graphql/resolvers/root.go index cb6424b29d873cd540b58d9b0585ceccceea3909..387066d4d960edf01152dbe998d94c97001abd42 100644 --- a/api/graphql/resolvers/root.go +++ b/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, } } diff --git a/api/graphql/schema/root.graphql b/api/graphql/schema/root.graphql index d9660583d815072dc88c081f84ae49c897a24069..455cd3614b125edd7c80459a100193440b37d81a 100644 --- a/api/graphql/schema/root.graphql +++ b/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 { diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go index 842606e4fd71cd91b07e79c90ce1f36b85be5579..3b29379e1540f98d9435f65e0a5cd20864cce7ba 100644 --- a/api/http/auth_handler.go +++ b/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 diff --git a/commands/webui.go b/commands/webui.go index af18d746031bb7f28ac7c4fd8c4e3dea2d1cf998..75a1ddd0b2cf0cb13c52059cbb1b2192c0d6e216 100644 --- a/commands/webui.go +++ b/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") diff --git a/webui2/src/__generated__/graphql.ts b/webui2/src/__generated__/graphql.ts index 0df92e0640ae2231469c2a05ab7a122a1bfde9ac..518a0c09a776d83d66ace236754ab6dc3efa394b 100644 --- a/webui2/src/__generated__/graphql.ts +++ b/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; + loginProviders: Array; }; export enum Status { @@ -881,13 +881,15 @@ export type BugDetailQuery = { __typename?: 'Query', repository?: { __typename?: export type BugListQueryVariables = Exact<{ ref?: InputMaybe; - query?: InputMaybe; + openQuery: Scalars['String']['input']; + closedQuery: Scalars['String']['input']; + listQuery: Scalars['String']['input']; first?: InputMaybe; after?: InputMaybe; }>; -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 } }; +export type ServerConfigQuery = { __typename?: 'Query', serverConfig: { __typename?: 'ServerConfig', authMode: string, loginProviders: Array } }; export type UserProfileQueryVariables = Exact<{ ref?: InputMaybe; @@ -1179,9 +1181,15 @@ export type BugDetailLazyQueryHookResult = ReturnType; export type BugDetailQueryResult = Apollo.QueryResult; 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) { +export function useBugListQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: BugListQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { const options = {...defaultOptions, ...baseOptions} return Apollo.useQuery(BugListDocument, options); } @@ -1630,7 +1640,7 @@ export const ServerConfigDocument = gql` query ServerConfig { serverConfig { authMode - oauthProviders + loginProviders } } `; diff --git a/webui2/src/components/bugs/CommentBox.tsx b/webui2/src/components/bugs/CommentBox.tsx index 3f6b718508bc4494c2c24ac586f36cb58d8d5e8d..476b120303fa4b379e7db76aeafda1fbae1b2cc0 100644 --- a/webui2/src/components/bugs/CommentBox.tsx +++ b/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'} diff --git a/webui2/src/components/bugs/Timeline.tsx b/webui2/src/components/bugs/Timeline.tsx index 940e264de794d0129b8baf8e2833cefa80158684..4bda0f626b278b837acf7faecd5c384f16736769 100644 --- a/webui2/src/components/bugs/Timeline.tsx +++ b/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['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
- + {item.author.displayName} @@ -164,10 +166,11 @@ function EventRow({ icon, children }: { icon: React.ReactNode; children: React.R } function LabelChangeItem({ item }: { item: LabelChangeItem }) { + const repo = useRepo() return ( }> - {item.author.displayName}{' '} + {item.author.displayName}{' '} {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 ( - {item.author.displayName}{' '} + {item.author.displayName}{' '} {isOpen ? 'reopened' : 'closed'} this{' '} {formatDistanceToNow(new Date(item.date), { addSuffix: true })} @@ -212,10 +216,11 @@ function StatusChangeItem({ item }: { item: StatusChangeItem }) { } function TitleChangeItem({ item }: { item: TitleChangeItem }) { + const repo = useRepo() return ( }> - {item.author.displayName} changed the + {item.author.displayName} changed the title from {item.was} to{' '} {item.title}{' '} {formatDistanceToNow(new Date(item.date), { addSuffix: true })} diff --git a/webui2/src/components/layout/Header.tsx b/webui2/src/components/layout/Header.tsx index b7b87a7bd87e65e3d6f08a9006627e9c53bf2953..c254b8de24209b2cd3f9bb3812315561607a5cda 100644 --- a/webui2/src/components/layout/Header.tsx +++ b/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' ? : } - {/* OAuth mode: show sign-in buttons when logged out */} - {mode === 'oauth' && !user && oauthProviders.map((provider) => ( - ))} {user && effectiveRepo && ( <> -
diff --git a/webui2/src/graphql/BugList.graphql b/webui2/src/graphql/BugList.graphql index 07c88f0dcf8fb5868e6224bb100e148981dc331d..a4bfe3aee5e1d91dad8707e12fb1887c775d58ed 100644 --- a/webui2/src/graphql/BugList.graphql +++ b/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 diff --git a/webui2/src/graphql/ServerConfig.graphql b/webui2/src/graphql/ServerConfig.graphql index 83af5ae0d3fc2094f8e397ab05f5f6227e9d1ebf..a7d5140ad582a32bcf66e183bdbf4f54757a92ca 100644 --- a/webui2/src/graphql/ServerConfig.graphql +++ b/webui2/src/graphql/ServerConfig.graphql @@ -2,6 +2,6 @@ query ServerConfig { serverConfig { authMode - oauthProviders + loginProviders } } diff --git a/webui2/src/index.css b/webui2/src/index.css index 7073eb86fceee06148cae4fba1b45b121395dcd4..a4fd94d5c86e9861b8e354c82dd6d9c5be30336b 100644 --- a/webui2/src/index.css +++ b/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%; } } diff --git a/webui2/src/lib/auth.tsx b/webui2/src/lib/auth.tsx index 62eced5881a9add945efa87c6e03070242492cbe..58724cb4dd5f577e1949ab7fd18764a08278a965 100644 --- a/webui2/src/lib/auth.tsx +++ b/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({ 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 ( - + {children} ) } -// ── 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(null) const [loading, setLoading] = useState(true) @@ -113,7 +113,7 @@ function OAuthAuthProvider({ return ( {children} @@ -125,7 +125,7 @@ function OAuthAuthProvider({ function ReadonlyAuthProvider({ children }: { children: ReactNode }) { return ( {children} @@ -143,30 +143,30 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Keep the default context (readonly + loading:true) while the config loads. return ( {children} ) } - const { authMode, oauthProviders } = data.serverConfig + const { authMode, loginProviders } = data.serverConfig if (authMode === 'readonly') { return {children} } - if (authMode === 'oauth') { + if (authMode === 'external') { return ( - + {children} - + ) } // Default: 'local' return ( - + {children} ) diff --git a/webui2/src/pages/BugListPage.tsx b/webui2/src/pages/BugListPage.tsx index e4664c31d85eceab4b6cd166ea32dc7d6c950276..34cc9e6c48a8fb62528956b72db1b6fd2ee5dfaa 100644 --- a/webui2/src/pages/BugListPage.tsx +++ b/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:…" /> - @@ -113,11 +120,9 @@ export function BugListPage() { > Open - {bugs && statusFilter === 'open' && ( - - {bugs.totalCount} - - )} + + {openCount} + @@ -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) ?? []} /> @@ -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") diff --git a/webui2/src/pages/IdentitySelectPage.tsx b/webui2/src/pages/IdentitySelectPage.tsx index 691a1dc440a99ac89af6fd37d925e59b8c12cb00..b63daf7008400af3bfe7d2db79883d1a26c72da4 100644 --- a/webui2/src/pages/IdentitySelectPage.tsx +++ b/webui2/src/pages/IdentitySelectPage.tsx @@ -90,7 +90,6 @@ export function IdentitySelectPage() {