Merge pull request #407 from lukegb/fix-402

Michael MurΓ© created

Add support for read-only mode for web UI.

Change summary

README.md                                      |   2 
api/auth/context.go                            |  28 +++
api/auth/errors.go                             |   6 
api/auth/middleware.go                         |  16 ++
api/graphql/connections/connection_template.go |   2 
api/graphql/connections/connections.go         |   0 
api/graphql/connections/edges.go               |   0 
api/graphql/connections/gen_comment.go         |   2 
api/graphql/connections/gen_identity.go        |   2 
api/graphql/connections/gen_label.go           |   2 
api/graphql/connections/gen_lazy_bug.go        |   2 
api/graphql/connections/gen_lazy_identity.go   |   2 
api/graphql/connections/gen_operation.go       |   2 
api/graphql/connections/gen_timeline.go        |   2 
api/graphql/gen_graphql.go                     |   0 
api/graphql/gqlgen.yml                         |   0 
api/graphql/graph/gen_graph.go                 |   7 
api/graphql/graphql_test.go                    |  17 +
api/graphql/handler.go                         |  32 ++++
api/graphql/models/edges.go                    |   0 
api/graphql/models/gen_models.go               |   0 
api/graphql/models/lazy_bug.go                 |   0 
api/graphql/models/lazy_identity.go            |   0 
api/graphql/models/models.go                   |   0 
api/graphql/resolvers/bug.go                   |   6 
api/graphql/resolvers/color.go                 |   2 
api/graphql/resolvers/comment.go               |   4 
api/graphql/resolvers/identity.go              |   4 
api/graphql/resolvers/label.go                 |   4 
api/graphql/resolvers/mutation.go              |  80 +++++++--
api/graphql/resolvers/operations.go            |   4 
api/graphql/resolvers/query.go                 |   4 
api/graphql/resolvers/repo.go                  |  18 +-
api/graphql/resolvers/root.go                  |  12 
api/graphql/resolvers/timeline.go              |   4 
api/graphql/schema/bug.graphql                 |   0 
api/graphql/schema/identity.graphql            |   0 
api/graphql/schema/label.graphql               |   0 
api/graphql/schema/mutations.graphql           |   0 
api/graphql/schema/operations.graphql          |   0 
api/graphql/schema/repository.graphql          |   0 
api/graphql/schema/root.graphql                |   0 
api/graphql/schema/timeline.graphql            |   0 
api/graphql/schema/types.graphql               |   0 
api/http/git_file_handler.go                   |  61 +++++++
api/http/git_file_handlers_test.go             |  91 +++++++++++
api/http/git_file_upload_handler.go            | 104 +++++++++++++
cache/multi_repo_cache.go                      |  16 +-
cache/repo_cache.go                            |  10 +
commands/webui.go                              | 159 +++----------------
doc/man/git-bug-webui.1                        |   4 
doc/md/git-bug_webui.md                        |   9 
graphql/handler.go                             |  39 ----
misc/bash_completion/git-bug                   |   2 
misc/powershell_completion/git-bug             |   1 
misc/zsh_completion/git-bug                    |   3 
webui/codegen.yaml                             |   2 
webui/handler.go                               |  31 +++
webui/packed_assets.go                         |   4 
webui/src/layout/IfLoggedIn.tsx                |  14 +
webui/src/pages/bug/Bug.tsx                    |  11 +
61 files changed, 568 insertions(+), 259 deletions(-)

Detailed changes

README.md πŸ”—

@@ -174,7 +174,7 @@ You can launch a rich Web UI with `git bug webui`.
 
 This web UI is entirely packed inside the same go binary and serve static content through a localhost http server.
 
-The web UI interact with the backend through a GraphQL API. The schema is available [here](graphql/).
+The web UI interact with the backend through a GraphQL API. The schema is available [here](api/graphql/schema).
 
 ## Bridges
 

api/auth/context.go πŸ”—

@@ -0,0 +1,28 @@
+// Package auth contains helpers for managing identities within the GraphQL API.
+package auth
+
+import (
+	"context"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/entity"
+)
+
+// identityCtxKey is a unique context key, accessible only in this package.
+var identityCtxKey = &struct{}{}
+
+// CtxWithUser attaches an Identity to a context.
+func CtxWithUser(ctx context.Context, userId entity.Id) context.Context {
+	return context.WithValue(ctx, identityCtxKey, userId)
+}
+
+// UserFromCtx retrieves an IdentityCache from the context.
+// If there is no identity in the context, ErrNotAuthenticated is returned.
+// If an error occurs while resolving the identity (e.g. I/O error), then it will be returned.
+func UserFromCtx(ctx context.Context, r *cache.RepoCache) (*cache.IdentityCache, error) {
+	id, ok := ctx.Value(identityCtxKey).(entity.Id)
+	if !ok {
+		return nil, ErrNotAuthenticated
+	}
+	return r.ResolveIdentity(id)
+}

api/auth/errors.go πŸ”—

@@ -0,0 +1,6 @@
+package auth
+
+import "errors"
+
+// ErrNotAuthenticated is returned to the client if the user requests an action requiring authentication, and they are not authenticated.
+var ErrNotAuthenticated = errors.New("not authenticated or read-only")

api/auth/middleware.go πŸ”—

@@ -0,0 +1,16 @@
+package auth
+
+import (
+	"net/http"
+
+	"github.com/MichaelMure/git-bug/entity"
+)
+
+func Middleware(fixedUserId entity.Id) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			ctx := CtxWithUser(r.Context(), fixedUserId)
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
+}

graphql/connections/connection_template.go β†’ api/graphql/connections/connection_template.go πŸ”—

@@ -5,7 +5,7 @@ import (
 
 	"github.com/cheekybits/genny/generic"
 
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 )
 
 // Name define the name of the connection

graphql/connections/gen_comment.go β†’ api/graphql/connections/gen_comment.go πŸ”—

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugCommentEdgeMaker define a function that take a bug.Comment and an offset and

graphql/connections/gen_identity.go β†’ api/graphql/connections/gen_identity.go πŸ”—

@@ -7,7 +7,7 @@ package connections
 import (
 	"fmt"
 
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 )
 
 // ModelsIdentityWrapperEdgeMaker define a function that take a models.IdentityWrapper and an offset and

graphql/connections/gen_label.go β†’ api/graphql/connections/gen_label.go πŸ”—

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugLabelEdgeMaker define a function that take a bug.Label and an offset and

graphql/connections/gen_lazy_bug.go β†’ api/graphql/connections/gen_lazy_bug.go πŸ”—

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // EntityIdEdgeMaker define a function that take a entity.Id and an offset and

graphql/connections/gen_lazy_identity.go β†’ api/graphql/connections/gen_lazy_identity.go πŸ”—

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // EntityIdEdgeMaker define a function that take a entity.Id and an offset and

graphql/connections/gen_operation.go β†’ api/graphql/connections/gen_operation.go πŸ”—

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugOperationEdgeMaker define a function that take a bug.Operation and an offset and

graphql/connections/gen_timeline.go β†’ api/graphql/connections/gen_timeline.go πŸ”—

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugTimelineItemEdgeMaker define a function that take a bug.TimelineItem and an offset and

graphql/graph/gen_graph.go β†’ api/graphql/graph/gen_graph.go πŸ”—

@@ -15,11 +15,12 @@ import (
 
 	"github.com/99designs/gqlgen/graphql"
 	"github.com/99designs/gqlgen/graphql/introspection"
-	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
-	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/vektah/gqlparser"
 	"github.com/vektah/gqlparser/ast"
+
+	"github.com/MichaelMure/git-bug/api/graphql/models"
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/util/git"
 )
 
 // region    ************************** generated!.gotpl **************************

graphql/graphql_test.go β†’ api/graphql/graphql_test.go πŸ”—

@@ -4,8 +4,11 @@ import (
 	"testing"
 
 	"github.com/99designs/gqlgen/client"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
+	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/misc/random_bugs"
 	"github.com/MichaelMure/git-bug/repository"
 )
@@ -16,10 +19,11 @@ func TestQueries(t *testing.T) {
 
 	random_bugs.FillRepoWithSeed(repo, 10, 42)
 
-	handler, err := NewHandler(repo)
-	if err != nil {
-		t.Fatal(err)
-	}
+	mrc := cache.NewMultiRepoCache()
+	_, err := mrc.RegisterDefaultRepository(repo)
+	require.NoError(t, err)
+
+	handler := NewHandler(mrc)
 
 	c := client.New(handler)
 
@@ -211,5 +215,6 @@ func TestQueries(t *testing.T) {
 		}
 	}
 
-	c.MustPost(query, &resp)
+	err = c.Post(query, &resp)
+	assert.NoError(t, err)
 }

api/graphql/handler.go πŸ”—

@@ -0,0 +1,32 @@
+//go:generate go run gen_graphql.go
+
+// Package graphql contains the root GraphQL http handler
+package graphql
+
+import (
+	"io"
+	"net/http"
+
+	"github.com/99designs/gqlgen/graphql/handler"
+
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/resolvers"
+	"github.com/MichaelMure/git-bug/cache"
+)
+
+// Handler is the root GraphQL http handler
+type Handler struct {
+	http.Handler
+	io.Closer
+}
+
+func NewHandler(mrc *cache.MultiRepoCache) Handler {
+	rootResolver := resolvers.NewRootResolver(mrc)
+	config := graph.Config{Resolvers: rootResolver}
+	h := handler.NewDefaultServer(graph.NewExecutableSchema(config))
+
+	return Handler{
+		Handler: h,
+		Closer:  rootResolver,
+	}
+}

graphql/resolvers/bug.go β†’ api/graphql/resolvers/bug.go πŸ”—

@@ -3,10 +3,10 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/graphql/connections"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/connections"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.BugResolver = &bugResolver{}

graphql/resolvers/color.go β†’ api/graphql/resolvers/color.go πŸ”—

@@ -4,7 +4,7 @@ import (
 	"context"
 	"image/color"
 
-	"github.com/MichaelMure/git-bug/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
 )
 
 var _ graph.ColorResolver = &colorResolver{}

graphql/resolvers/comment.go β†’ api/graphql/resolvers/comment.go πŸ”—

@@ -3,9 +3,9 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.CommentResolver = &commentResolver{}

graphql/resolvers/identity.go β†’ api/graphql/resolvers/identity.go πŸ”—

@@ -3,8 +3,8 @@ package resolvers
 import (
 	"context"
 
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 )
 
 var _ graph.IdentityResolver = &identityResolver{}

graphql/resolvers/label.go β†’ api/graphql/resolvers/label.go πŸ”—

@@ -5,9 +5,9 @@ import (
 	"fmt"
 	"image/color"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.LabelResolver = &labelResolver{}

graphql/resolvers/mutation.go β†’ api/graphql/resolvers/mutation.go πŸ”—

@@ -2,11 +2,13 @@ package resolvers
 
 import (
 	"context"
+	"time"
 
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.MutationResolver = &mutationResolver{}
@@ -23,22 +25,31 @@ func (r mutationResolver) getRepo(ref *string) (*cache.RepoCache, error) {
 	return r.cache.DefaultRepo()
 }
 
-func (r mutationResolver) getBug(repoRef *string, bugPrefix string) (*cache.BugCache, error) {
+func (r mutationResolver) getBug(repoRef *string, bugPrefix string) (*cache.RepoCache, *cache.BugCache, error) {
 	repo, err := r.getRepo(repoRef)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
-	return repo.ResolveBugPrefix(bugPrefix)
+	b, err := repo.ResolveBugPrefix(bugPrefix)
+	if err != nil {
+		return nil, nil, err
+	}
+	return repo, b, nil
 }
 
-func (r mutationResolver) NewBug(_ context.Context, input models.NewBugInput) (*models.NewBugPayload, error) {
+func (r mutationResolver) NewBug(ctx context.Context, input models.NewBugInput) (*models.NewBugPayload, error) {
 	repo, err := r.getRepo(input.RepoRef)
 	if err != nil {
 		return nil, err
 	}
 
-	b, op, err := repo.NewBugWithFiles(input.Title, input.Message, input.Files)
+	author, err := auth.UserFromCtx(ctx, repo)
+	if err != nil {
+		return nil, err
+	}
+
+	b, op, err := repo.NewBugRaw(author, time.Now().Unix(), input.Title, input.Message, input.Files, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -50,13 +61,18 @@ func (r mutationResolver) NewBug(_ context.Context, input models.NewBugInput) (*
 	}, nil
 }
 
-func (r mutationResolver) AddComment(_ context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error) {
-	b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) AddComment(ctx context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error) {
+	repo, b, err := r.getBug(input.RepoRef, input.Prefix)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.AddCommentWithFiles(input.Message, input.Files)
+	author, err := auth.UserFromCtx(ctx, repo)
+	if err != nil {
+		return nil, err
+	}
+
+	op, err := b.AddCommentRaw(author, time.Now().Unix(), input.Message, input.Files, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -73,13 +89,18 @@ func (r mutationResolver) AddComment(_ context.Context, input models.AddCommentI
 	}, nil
 }
 
-func (r mutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
-	b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
+	repo, b, err := r.getBug(input.RepoRef, input.Prefix)
 	if err != nil {
 		return nil, err
 	}
 
-	results, op, err := b.ChangeLabels(input.Added, input.Removed)
+	author, err := auth.UserFromCtx(ctx, repo)
+	if err != nil {
+		return nil, err
+	}
+
+	results, op, err := b.ChangeLabelsRaw(author, time.Now().Unix(), input.Added, input.Removed, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -102,13 +123,18 @@ func (r mutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLa
 	}, nil
 }
 
-func (r mutationResolver) OpenBug(_ context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error) {
-	b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) OpenBug(ctx context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error) {
+	repo, b, err := r.getBug(input.RepoRef, input.Prefix)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.Open()
+	author, err := auth.UserFromCtx(ctx, repo)
+	if err != nil {
+		return nil, err
+	}
+
+	op, err := b.OpenRaw(author, time.Now().Unix(), nil)
 	if err != nil {
 		return nil, err
 	}
@@ -125,13 +151,18 @@ func (r mutationResolver) OpenBug(_ context.Context, input models.OpenBugInput)
 	}, nil
 }
 
-func (r mutationResolver) CloseBug(_ context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error) {
-	b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) CloseBug(ctx context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error) {
+	repo, b, err := r.getBug(input.RepoRef, input.Prefix)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.Close()
+	author, err := auth.UserFromCtx(ctx, repo)
+	if err != nil {
+		return nil, err
+	}
+
+	op, err := b.CloseRaw(author, time.Now().Unix(), nil)
 	if err != nil {
 		return nil, err
 	}
@@ -148,13 +179,18 @@ func (r mutationResolver) CloseBug(_ context.Context, input models.CloseBugInput
 	}, nil
 }
 
-func (r mutationResolver) SetTitle(_ context.Context, input models.SetTitleInput) (*models.SetTitlePayload, error) {
-	b, err := r.getBug(input.RepoRef, input.Prefix)
+func (r mutationResolver) SetTitle(ctx context.Context, input models.SetTitleInput) (*models.SetTitlePayload, error) {
+	repo, b, err := r.getBug(input.RepoRef, input.Prefix)
+	if err != nil {
+		return nil, err
+	}
+
+	author, err := auth.UserFromCtx(ctx, repo)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.SetTitle(input.Title)
+	op, err := b.SetTitleRaw(author, time.Now().Unix(), input.Title, nil)
 	if err != nil {
 		return nil, err
 	}

graphql/resolvers/operations.go β†’ api/graphql/resolvers/operations.go πŸ”—

@@ -5,9 +5,9 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.CreateOperationResolver = createOperationResolver{}

graphql/resolvers/query.go β†’ api/graphql/resolvers/query.go πŸ”—

@@ -3,9 +3,9 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.QueryResolver = &rootQueryResolver{}

graphql/resolvers/repo.go β†’ api/graphql/resolvers/repo.go πŸ”—

@@ -3,11 +3,12 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/api/graphql/connections"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/graphql/connections"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 	"github.com/MichaelMure/git-bug/query"
 )
 
@@ -149,13 +150,14 @@ func (repoResolver) Identity(_ context.Context, obj *models.Repository, prefix s
 	return models.NewLazyIdentity(obj.Repo, excerpt), nil
 }
 
-func (repoResolver) UserIdentity(_ context.Context, obj *models.Repository) (models.IdentityWrapper, error) {
-	excerpt, err := obj.Repo.GetUserIdentityExcerpt()
-	if err != nil {
+func (repoResolver) UserIdentity(ctx context.Context, obj *models.Repository) (models.IdentityWrapper, error) {
+	id, err := auth.UserFromCtx(ctx, obj.Repo)
+	if err == auth.ErrNotAuthenticated {
+		return nil, nil
+	} else if err != nil {
 		return nil, err
 	}
-
-	return models.NewLazyIdentity(obj.Repo, excerpt), nil
+	return models.NewLoadedIdentity(id.Identity), nil
 }
 
 func (repoResolver) ValidLabels(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.LabelConnection, error) {

graphql/resolvers/root.go β†’ api/graphql/resolvers/root.go πŸ”—

@@ -2,31 +2,31 @@
 package resolvers
 
 import (
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/graphql/graph"
 )
 
 var _ graph.ResolverRoot = &RootResolver{}
 
 type RootResolver struct {
-	cache.MultiRepoCache
+	*cache.MultiRepoCache
 }
 
-func NewRootResolver() *RootResolver {
+func NewRootResolver(mrc *cache.MultiRepoCache) *RootResolver {
 	return &RootResolver{
-		MultiRepoCache: cache.NewMultiRepoCache(),
+		MultiRepoCache: mrc,
 	}
 }
 
 func (r RootResolver) Query() graph.QueryResolver {
 	return &rootQueryResolver{
-		cache: &r.MultiRepoCache,
+		cache: r.MultiRepoCache,
 	}
 }
 
 func (r RootResolver) Mutation() graph.MutationResolver {
 	return &mutationResolver{
-		cache: &r.MultiRepoCache,
+		cache: r.MultiRepoCache,
 	}
 }
 

graphql/resolvers/timeline.go β†’ api/graphql/resolvers/timeline.go πŸ”—

@@ -4,9 +4,9 @@ import (
 	"context"
 	"time"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.CommentHistoryStepResolver = commentHistoryStepResolver{}

api/http/git_file_handler.go πŸ”—

@@ -0,0 +1,61 @@
+package http
+
+import (
+	"bytes"
+	"net/http"
+	"time"
+
+	"github.com/gorilla/mux"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/util/git"
+)
+
+// implement a http.Handler that will read and server git blob.
+//
+// Expected gorilla/mux parameters:
+//   - "repo" : the ref of the repo or "" for the default one
+//   - "hash" : the git hash of the file to retrieve
+type gitFileHandler struct {
+	mrc *cache.MultiRepoCache
+}
+
+func NewGitFileHandler(mrc *cache.MultiRepoCache) http.Handler {
+	return &gitFileHandler{mrc: mrc}
+}
+
+func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	var repo *cache.RepoCache
+	var err error
+
+	repoVar := mux.Vars(r)["repo"]
+	switch repoVar {
+	case "":
+		repo, err = gfh.mrc.DefaultRepo()
+	default:
+		repo, err = gfh.mrc.ResolveRepo(repoVar)
+	}
+
+	if err != nil {
+		http.Error(rw, "invalid repo reference", http.StatusBadRequest)
+		return
+	}
+
+	hash := git.Hash(mux.Vars(r)["hash"])
+	if !hash.IsValid() {
+		http.Error(rw, "invalid git hash", http.StatusBadRequest)
+		return
+	}
+
+	// TODO: this mean that the whole file will he buffered in memory
+	// This can be a problem for big files. There might be a way around
+	// that by implementing a io.ReadSeeker that would read and discard
+	// data when a seek is called.
+	data, err := repo.ReadData(hash)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	http.ServeContent(rw, r, "", time.Now(), bytes.NewReader(data))
+}

api/http/git_file_handlers_test.go πŸ”—

@@ -0,0 +1,91 @@
+package http
+
+import (
+	"bytes"
+	"image"
+	"image/png"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gorilla/mux"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+func TestGitFileHandlers(t *testing.T) {
+	repo := repository.CreateTestRepo(false)
+	defer repository.CleanupTestRepos(repo)
+
+	mrc := cache.NewMultiRepoCache()
+	repoCache, err := mrc.RegisterDefaultRepository(repo)
+	require.NoError(t, err)
+
+	author, err := repoCache.NewIdentity("test identity", "test@test.org")
+	require.NoError(t, err)
+
+	err = repoCache.SetUserIdentity(author)
+	require.NoError(t, err)
+
+	// UPLOAD
+
+	uploadHandler := NewGitUploadFileHandler(mrc)
+
+	img := image.NewNRGBA(image.Rect(0, 0, 50, 50))
+	data := &bytes.Buffer{}
+	err = png.Encode(data, img)
+	require.NoError(t, err)
+
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+	part, err := writer.CreateFormFile("uploadfile", "noname")
+	assert.NoError(t, err)
+
+	_, err = part.Write(data.Bytes())
+	assert.NoError(t, err)
+
+	err = writer.Close()
+	assert.NoError(t, err)
+
+	w := httptest.NewRecorder()
+	r, _ := http.NewRequest("GET", "/", body)
+	r.Header.Add("Content-Type", writer.FormDataContentType())
+
+	// Simulate auth
+	r = r.WithContext(auth.CtxWithUser(r.Context(), author.Id()))
+
+	// Handler's params
+	r = mux.SetURLVars(r, map[string]string{
+		"repo": "",
+	})
+
+	uploadHandler.ServeHTTP(w, r)
+
+	assert.Equal(t, http.StatusOK, w.Code)
+	assert.Equal(t, `{"hash":"3426a1488292d8f3f3c59ca679681336542b986f"}`, w.Body.String())
+	// DOWNLOAD
+
+	downloadHandler := NewGitFileHandler(mrc)
+
+	w = httptest.NewRecorder()
+	r, _ = http.NewRequest("GET", "/", nil)
+
+	// Simulate auth
+	r = r.WithContext(auth.CtxWithUser(r.Context(), author.Id()))
+
+	// Handler's params
+	r = mux.SetURLVars(r, map[string]string{
+		"repo": "",
+		"hash": "3426a1488292d8f3f3c59ca679681336542b986f",
+	})
+
+	downloadHandler.ServeHTTP(w, r)
+	assert.Equal(t, http.StatusOK, w.Code)
+
+	assert.Equal(t, data.Bytes(), w.Body.Bytes())
+}

api/http/git_file_upload_handler.go πŸ”—

@@ -0,0 +1,104 @@
+package http
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/gorilla/mux"
+
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/cache"
+)
+
+// implement a http.Handler that will accept and store content into git blob.
+//
+// Expected gorilla/mux parameters:
+//   - "repo" : the ref of the repo or "" for the default one
+type gitUploadFileHandler struct {
+	mrc *cache.MultiRepoCache
+}
+
+func NewGitUploadFileHandler(mrc *cache.MultiRepoCache) http.Handler {
+	return &gitUploadFileHandler{mrc: mrc}
+}
+
+func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	var repo *cache.RepoCache
+	var err error
+
+	repoVar := mux.Vars(r)["repo"]
+	switch repoVar {
+	case "":
+		repo, err = gufh.mrc.DefaultRepo()
+	default:
+		repo, err = gufh.mrc.ResolveRepo(repoVar)
+	}
+
+	if err != nil {
+		http.Error(rw, "invalid repo reference", http.StatusBadRequest)
+		return
+	}
+
+	_, err = auth.UserFromCtx(r.Context(), repo)
+	if err == auth.ErrNotAuthenticated {
+		http.Error(rw, "read-only mode or not logged in", http.StatusForbidden)
+		return
+	} else if err != nil {
+		http.Error(rw, fmt.Sprintf("loading identity: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	// 100MB (github limit)
+	var maxUploadSize int64 = 100 * 1000 * 1000
+	r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)
+	if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+		http.Error(rw, "file too big (100MB max)", http.StatusBadRequest)
+		return
+	}
+
+	file, _, err := r.FormFile("uploadfile")
+	if err != nil {
+		http.Error(rw, "invalid file", http.StatusBadRequest)
+		return
+	}
+	defer file.Close()
+	fileBytes, err := ioutil.ReadAll(file)
+	if err != nil {
+		http.Error(rw, "invalid file", http.StatusBadRequest)
+		return
+	}
+
+	filetype := http.DetectContentType(fileBytes)
+	if filetype != "image/jpeg" && filetype != "image/jpg" &&
+		filetype != "image/gif" && filetype != "image/png" {
+		http.Error(rw, "invalid file type", http.StatusBadRequest)
+		return
+	}
+
+	hash, err := repo.StoreData(fileBytes)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	type response struct {
+		Hash string `json:"hash"`
+	}
+
+	resp := response{Hash: string(hash)}
+
+	js, err := json.Marshal(resp)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	rw.Header().Set("Content-Type", "application/json")
+	_, err = rw.Write(js)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}

cache/multi_repo_cache.go πŸ”—

@@ -13,32 +13,32 @@ type MultiRepoCache struct {
 	repos map[string]*RepoCache
 }
 
-func NewMultiRepoCache() MultiRepoCache {
-	return MultiRepoCache{
+func NewMultiRepoCache() *MultiRepoCache {
+	return &MultiRepoCache{
 		repos: make(map[string]*RepoCache),
 	}
 }
 
 // RegisterRepository register a named repository. Use this for multi-repo setup
-func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) error {
+func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) (*RepoCache, error) {
 	r, err := NewRepoCache(repo)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	c.repos[ref] = r
-	return nil
+	return r, nil
 }
 
 // RegisterDefaultRepository register a unnamed repository. Use this for mono-repo setup
-func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) error {
+func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) (*RepoCache, error) {
 	r, err := NewRepoCache(repo)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	c.repos[""] = r
-	return nil
+	return r, nil
 }
 
 // DefaultRepo retrieve the default repository

cache/repo_cache.go πŸ”—

@@ -142,6 +142,16 @@ func (c *RepoCache) GetUserEmail() (string, error) {
 	return c.repo.GetUserEmail()
 }
 
+// ReadData will attempt to read arbitrary data from the given hash
+func (c *RepoCache) ReadData(hash git.Hash) ([]byte, error) {
+	return c.repo.ReadData(hash)
+}
+
+// StoreData will store arbitrary data and return the corresponding hash
+func (c *RepoCache) StoreData(data []byte) (git.Hash, error) {
+	return c.repo.StoreData(data)
+}
+
 func (c *RepoCache) lock() error {
 	lockPath := repoLockFilePath(c.repo)
 

commands/webui.go πŸ”—

@@ -1,11 +1,8 @@
 package commands
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"log"
 	"net/http"
 	"os"
@@ -18,16 +15,20 @@ import (
 	"github.com/skratchdot/open-golang/open"
 	"github.com/spf13/cobra"
 
-	"github.com/MichaelMure/git-bug/graphql"
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/api/graphql"
+	httpapi "github.com/MichaelMure/git-bug/api/http"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
-	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/webui"
 )
 
 var (
-	webUIPort   int
-	webUIOpen   bool
-	webUINoOpen bool
+	webUIPort     int
+	webUIOpen     bool
+	webUINoOpen   bool
+	webUIReadOnly bool
 )
 
 const webUIOpenConfigKey = "git-bug.webui.open"
@@ -46,22 +47,31 @@ func runWebUI(cmd *cobra.Command, args []string) error {
 
 	router := mux.NewRouter()
 
-	graphqlHandler, err := graphql.NewHandler(repo)
+	// If the webUI is not read-only, use an authentication middleware with a
+	// fixed identity: the default user of the repo
+	// TODO: support dynamic authentication with OAuth
+	if !webUIReadOnly {
+		author, err := identity.GetUserIdentity(repo)
+		if err != nil {
+			return err
+		}
+		router.Use(auth.Middleware(author.Id()))
+	}
+
+	mrc := cache.NewMultiRepoCache()
+	_, err := mrc.RegisterDefaultRepository(repo)
 	if err != nil {
 		return err
 	}
 
-	assetsHandler := &fileSystemWithDefault{
-		FileSystem:  webui.WebUIAssets,
-		defaultFile: "index.html",
-	}
+	graphqlHandler := graphql.NewHandler(mrc)
 
 	// Routes
 	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
 	router.Path("/graphql").Handler(graphqlHandler)
-	router.Path("/gitfile/{hash}").Handler(newGitFileHandler(repo))
-	router.Path("/upload").Methods("POST").Handler(newGitUploadFileHandler(repo))
-	router.PathPrefix("/").Handler(http.FileServer(assetsHandler))
+	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
+	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
+	router.PathPrefix("/").Handler(webui.NewHandler())
 
 	srv := &http.Server{
 		Addr:    addr,
@@ -128,119 +138,6 @@ func runWebUI(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-// implement a http.FileSystem that will serve a default file when the looked up
-// file doesn't exist. Useful for Single-Page App that implement routing client
-// side, where the server has to return the root index.html file for every route.
-type fileSystemWithDefault struct {
-	http.FileSystem
-	defaultFile string
-}
-
-func (fswd *fileSystemWithDefault) Open(name string) (http.File, error) {
-	f, err := fswd.FileSystem.Open(name)
-	if os.IsNotExist(err) {
-		return fswd.FileSystem.Open(fswd.defaultFile)
-	}
-	return f, err
-}
-
-// implement a http.Handler that will read and server git blob.
-type gitFileHandler struct {
-	repo repository.Repo
-}
-
-func newGitFileHandler(repo repository.Repo) http.Handler {
-	return &gitFileHandler{
-		repo: repo,
-	}
-}
-
-func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
-	hash := git.Hash(mux.Vars(r)["hash"])
-
-	if !hash.IsValid() {
-		http.Error(rw, "invalid git hash", http.StatusBadRequest)
-		return
-	}
-
-	// TODO: this mean that the whole file will he buffered in memory
-	// This can be a problem for big files. There might be a way around
-	// that by implementing a io.ReadSeeker that would read and discard
-	// data when a seek is called.
-	data, err := gfh.repo.ReadData(git.Hash(hash))
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	http.ServeContent(rw, r, "", time.Now(), bytes.NewReader(data))
-}
-
-// implement a http.Handler that will accept and store content into git blob.
-type gitUploadFileHandler struct {
-	repo repository.Repo
-}
-
-func newGitUploadFileHandler(repo repository.Repo) http.Handler {
-	return &gitUploadFileHandler{
-		repo: repo,
-	}
-}
-
-func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
-	// 100MB (github limit)
-	var maxUploadSize int64 = 100 * 1000 * 1000
-	r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)
-	if err := r.ParseMultipartForm(maxUploadSize); err != nil {
-		http.Error(rw, "file too big (100MB max)", http.StatusBadRequest)
-		return
-	}
-
-	file, _, err := r.FormFile("uploadfile")
-	if err != nil {
-		http.Error(rw, "invalid file", http.StatusBadRequest)
-		return
-	}
-	defer file.Close()
-	fileBytes, err := ioutil.ReadAll(file)
-	if err != nil {
-		http.Error(rw, "invalid file", http.StatusBadRequest)
-		return
-	}
-
-	filetype := http.DetectContentType(fileBytes)
-	if filetype != "image/jpeg" && filetype != "image/jpg" &&
-		filetype != "image/gif" && filetype != "image/png" {
-		http.Error(rw, "invalid file type", http.StatusBadRequest)
-		return
-	}
-
-	hash, err := gufh.repo.StoreData(fileBytes)
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	type response struct {
-		Hash string `json:"hash"`
-	}
-
-	resp := response{Hash: string(hash)}
-
-	js, err := json.Marshal(resp)
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	rw.Header().Set("Content-Type", "application/json")
-	_, err = rw.Write(js)
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-}
-
 var webUICmd = &cobra.Command{
 	Use:   "webui",
 	Short: "Launch the web UI.",
@@ -249,7 +146,7 @@ var webUICmd = &cobra.Command{
 Available git config:
   git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser
 `,
-	PreRunE: loadRepoEnsureUser,
+	PreRunE: loadRepo,
 	RunE:    runWebUI,
 }
 
@@ -261,5 +158,5 @@ func init() {
 	webUICmd.Flags().BoolVar(&webUIOpen, "open", false, "Automatically open the web UI in the default browser")
 	webUICmd.Flags().BoolVar(&webUINoOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
 	webUICmd.Flags().IntVarP(&webUIPort, "port", "p", 0, "Port to listen to (default is random)")
-
+	webUICmd.Flags().BoolVar(&webUIReadOnly, "read-only", false, "Whether to run the web UI in read-only mode")
 }

doc/man/git-bug-webui.1 πŸ”—

@@ -34,6 +34,10 @@ Available git config:
 \fB\-p\fP, \fB\-\-port\fP=0
 	Port to listen to (default is random)
 
+.PP
+\fB\-\-read\-only\fP[=false]
+	Whether to run the web UI in read\-only mode
+
 .PP
 \fB\-h\fP, \fB\-\-help\fP[=false]
 	help for webui

doc/md/git-bug_webui.md πŸ”—

@@ -17,10 +17,11 @@ git-bug webui [flags]
 ### Options
 
 ```
-      --open       Automatically open the web UI in the default browser
-      --no-open    Prevent the automatic opening of the web UI in the default browser
-  -p, --port int   Port to listen to (default is random)
-  -h, --help       help for webui
+      --open        Automatically open the web UI in the default browser
+      --no-open     Prevent the automatic opening of the web UI in the default browser
+  -p, --port int    Port to listen to (default is random)
+      --read-only   Whether to run the web UI in read-only mode
+  -h, --help        help for webui
 ```
 
 ### SEE ALSO

graphql/handler.go πŸ”—

@@ -1,39 +0,0 @@
-//go:generate go run gen_graphql.go
-
-// Package graphql contains the root GraphQL http handler
-package graphql
-
-import (
-	"net/http"
-
-	"github.com/99designs/gqlgen/graphql/handler"
-
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/resolvers"
-	"github.com/MichaelMure/git-bug/repository"
-)
-
-// Handler is the root GraphQL http handler
-type Handler struct {
-	http.Handler
-	*resolvers.RootResolver
-}
-
-func NewHandler(repo repository.ClockedRepo) (Handler, error) {
-	h := Handler{
-		RootResolver: resolvers.NewRootResolver(),
-	}
-
-	err := h.RootResolver.RegisterDefaultRepository(repo)
-	if err != nil {
-		return Handler{}, err
-	}
-
-	config := graph.Config{
-		Resolvers: h.RootResolver,
-	}
-
-	h.Handler = handler.NewDefaultServer(graph.NewExecutableSchema(config))
-
-	return h, nil
-}

misc/bash_completion/git-bug πŸ”—

@@ -1213,6 +1213,8 @@ _git-bug_webui()
     two_word_flags+=("--port")
     two_word_flags+=("-p")
     local_nonpersistent_flags+=("--port=")
+    flags+=("--read-only")
+    local_nonpersistent_flags+=("--read-only")
 
     must_have_one_flag=()
     must_have_one_noun=()

misc/powershell_completion/git-bug πŸ”—

@@ -240,6 +240,7 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
             [CompletionResult]::new('--no-open', 'no-open', [CompletionResultType]::ParameterName, 'Prevent the automatic opening of the web UI in the default browser')
             [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
             [CompletionResult]::new('--port', 'port', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
+            [CompletionResult]::new('--read-only', 'read-only', [CompletionResultType]::ParameterName, 'Whether to run the web UI in read-only mode')
             break
         }
     })

misc/zsh_completion/git-bug πŸ”—

@@ -459,6 +459,7 @@ function _git-bug_webui {
   _arguments \
     '--open[Automatically open the web UI in the default browser]' \
     '--no-open[Prevent the automatic opening of the web UI in the default browser]' \
-    '(-p --port)'{-p,--port}'[Port to listen to (default is random)]:'
+    '(-p --port)'{-p,--port}'[Port to listen to (default is random)]:' \
+    '--read-only[Whether to run the web UI in read-only mode]'
 }
 

webui/codegen.yaml πŸ”—

@@ -1,4 +1,4 @@
-schema: '../graphql/schema/*.graphql'
+schema: '../api/graphql/schema/*.graphql'
 overwrite: true
 documents: src/**/*.graphql
 generates:

webui/handler.go πŸ”—

@@ -0,0 +1,31 @@
+package webui
+
+import (
+	"net/http"
+	"os"
+)
+
+// implement a http.FileSystem that will serve a default file when the looked up
+// file doesn't exist. Useful for Single-Page App that implement routing client
+// side, where the server has to return the root index.html file for every route.
+type fileSystemWithDefault struct {
+	http.FileSystem
+	defaultFile string
+}
+
+func (fswd *fileSystemWithDefault) Open(name string) (http.File, error) {
+	f, err := fswd.FileSystem.Open(name)
+	if os.IsNotExist(err) {
+		return fswd.FileSystem.Open(fswd.defaultFile)
+	}
+	return f, err
+}
+
+func NewHandler() http.Handler {
+	assetsHandler := &fileSystemWithDefault{
+		FileSystem:  WebUIAssets,
+		defaultFile: "index.html",
+	}
+
+	return http.FileServer(assetsHandler)
+}

webui/packed_assets.go πŸ”—

@@ -21,92 +21,92 @@ var WebUIAssets = func() http.FileSystem {
 	fs := vfsgenΫ°FS{
 		"/": &vfsgenΫ°DirInfo{
 			name:    "/",
-			modTime: time.Date(2020, 2, 23, 15, 30, 55, 841734699, time.UTC),
+			modTime: time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
 		},
 		"/asset-manifest.json": &vfsgenΫ°CompressedFileInfo{
 			name:             "asset-manifest.json",
-			modTime:          time.Date(2020, 2, 23, 15, 30, 55, 841734699, time.UTC),
+			modTime:          time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
 			uncompressedSize: 849,
 

webui/src/layout/IfLoggedIn.tsx πŸ”—

@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
+
+type Props = { children: () => React.ReactNode };
+const IfLoggedIn = ({ children }: Props) => {
+  const { loading, error, data } = useCurrentIdentityQuery();
+
+  if (error || loading || !data?.repository?.userIdentity) return null;
+
+  return <>{children()}</>;
+};
+
+export default IfLoggedIn;

webui/src/pages/bug/Bug.tsx πŸ”—

@@ -6,6 +6,7 @@ import { makeStyles } from '@material-ui/core/styles';
 import Author from 'src/components/Author';
 import Date from 'src/components/Date';
 import Label from 'src/components/Label';
+import IfLoggedIn from 'src/layout/IfLoggedIn';
 
 import { BugFragment } from './Bug.generated';
 import CommentForm from './CommentForm';
@@ -88,9 +89,13 @@ function Bug({ bug }: Props) {
       <div className={classes.container}>
         <div className={classes.timeline}>
           <TimelineQuery id={bug.id} />
-          <div className={classes.commentForm}>
-            <CommentForm bugId={bug.id} />
-          </div>
+          <IfLoggedIn>
+            {() => (
+              <div className={classes.commentForm}>
+                <CommentForm bugId={bug.id} />
+              </div>
+            )}
+          </IfLoggedIn>
         </div>
         <div className={classes.sidebar}>
           <span className={classes.sidebarTitle}>Labels</span>