Detailed changes
@@ -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
-}
@@ -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
+}
@@ -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
+}
@@ -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++
}
@@ -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 {
@@ -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))
@@ -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 {
@@ -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
}
@@ -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,
}
}
@@ -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 {
@@ -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
@@ -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")
@@ -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
}
}
`;
@@ -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>
@@ -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 })}
@@ -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>
@@ -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
@@ -2,6 +2,6 @@
query ServerConfig {
serverConfig {
authMode
- oauthProviders
+ loginProviders
}
}
@@ -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%;
}
}
@@ -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>
)
@@ -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")
@@ -90,7 +90,6 @@ export function IdentitySelectPage() {
</div>
<Button
size="sm"
- variant="outline"
disabled={working}
onClick={() => adopt(id.id)}
>