Add support for read-only mode for web UI.

Luke Granger-Brown created

Fixes #402.

Change summary

commands/webui.go                           | 11 +++++---
doc/man/git-bug-webui.1                     |  4 +++
doc/md/git-bug_webui.md                     |  9 +++---
graphql/config/config.go                    |  7 +++++
graphql/graphql_test.go                     |  3 +
graphql/handler.go                          |  5 ++-
graphql/resolvers/mutation.go               | 24 ++++++++++++++++++
graphql/resolvers/repo.go                   |  8 ++++-
graphql/resolvers/root.go                   | 14 +++++++---
misc/bash_completion/git-bug                |  2 +
misc/powershell_completion/git-bug          |  1 
misc/zsh_completion/git-bug                 |  3 +
webui/src/layout/CurrentIdentity.tsx        | 30 ++++++++++++++--------
webui/src/layout/CurrentIdentityContext.tsx |  6 ++++
webui/src/layout/ReadonlyHidden.tsx         | 19 ++++++++++++++
webui/src/layout/index.tsx                  |  6 +++-
webui/src/pages/bug/Bug.tsx                 |  9 ++++--
webui/src/pages/bug/CommentForm.graphql     |  4 ++
18 files changed, 130 insertions(+), 35 deletions(-)

Detailed changes

commands/webui.go 🔗

@@ -19,15 +19,17 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/MichaelMure/git-bug/graphql"
+	"github.com/MichaelMure/git-bug/graphql/config"
 	"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,7 +48,7 @@ func runWebUI(cmd *cobra.Command, args []string) error {
 
 	router := mux.NewRouter()
 
-	graphqlHandler, err := graphql.NewHandler(repo)
+	graphqlHandler, err := graphql.NewHandler(repo, config.Config{ReadOnly: webUIReadOnly})
 	if err != nil {
 		return err
 	}
@@ -261,5 +263,6 @@ 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/config/config.go 🔗

@@ -0,0 +1,7 @@
+// Package config contains configuration for GraphQL stuff.
+package config
+
+// Config holds configuration elements.
+type Config struct {
+	ReadOnly bool
+}

graphql/graphql_test.go 🔗

@@ -5,6 +5,7 @@ import (
 
 	"github.com/99designs/gqlgen/client"
 
+	"github.com/MichaelMure/git-bug/graphql/config"
 	"github.com/MichaelMure/git-bug/graphql/models"
 	"github.com/MichaelMure/git-bug/misc/random_bugs"
 	"github.com/MichaelMure/git-bug/repository"
@@ -16,7 +17,7 @@ func TestQueries(t *testing.T) {
 
 	random_bugs.FillRepoWithSeed(repo, 10, 42)
 
-	handler, err := NewHandler(repo)
+	handler, err := NewHandler(repo, config.Config{})
 	if err != nil {
 		t.Fatal(err)
 	}

graphql/handler.go 🔗

@@ -8,6 +8,7 @@ import (
 
 	"github.com/99designs/gqlgen/graphql/handler"
 
+	"github.com/MichaelMure/git-bug/graphql/config"
 	"github.com/MichaelMure/git-bug/graphql/graph"
 	"github.com/MichaelMure/git-bug/graphql/resolvers"
 	"github.com/MichaelMure/git-bug/repository"
@@ -19,9 +20,9 @@ type Handler struct {
 	*resolvers.RootResolver
 }
 
-func NewHandler(repo repository.ClockedRepo) (Handler, error) {
+func NewHandler(repo repository.ClockedRepo, cfg config.Config) (Handler, error) {
 	h := Handler{
-		RootResolver: resolvers.NewRootResolver(),
+		RootResolver: resolvers.NewRootResolver(cfg),
 	}
 
 	err := h.RootResolver.RegisterDefaultRepository(repo)

graphql/resolvers/mutation.go 🔗

@@ -7,8 +7,32 @@ import (
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/graphql/graph"
 	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/vektah/gqlparser/gqlerror"
 )
 
+var _ graph.MutationResolver = &readonlyMutationResolver{}
+
+type readonlyMutationResolver struct{}
+
+func (readonlyMutationResolver) NewBug(_ context.Context, _ models.NewBugInput) (*models.NewBugPayload, error) {
+	return nil, gqlerror.Errorf("readonly mode")
+}
+func (readonlyMutationResolver) AddComment(_ context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error) {
+	return nil, gqlerror.Errorf("readonly mode")
+}
+func (readonlyMutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
+	return nil, gqlerror.Errorf("readonly mode")
+}
+func (readonlyMutationResolver) OpenBug(_ context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error) {
+	return nil, gqlerror.Errorf("readonly mode")
+}
+func (readonlyMutationResolver) CloseBug(_ context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error) {
+	return nil, gqlerror.Errorf("readonly mode")
+}
+func (readonlyMutationResolver) SetTitle(_ context.Context, input models.SetTitleInput) (*models.SetTitlePayload, error) {
+	return nil, gqlerror.Errorf("readonly mode")
+}
+
 var _ graph.MutationResolver = &mutationResolver{}
 
 type mutationResolver struct {

graphql/resolvers/repo.go 🔗

@@ -5,6 +5,7 @@ import (
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/graphql/config"
 	"github.com/MichaelMure/git-bug/graphql/connections"
 	"github.com/MichaelMure/git-bug/graphql/graph"
 	"github.com/MichaelMure/git-bug/graphql/models"
@@ -13,7 +14,7 @@ import (
 
 var _ graph.RepositoryResolver = &repoResolver{}
 
-type repoResolver struct{}
+type repoResolver struct{ cfg config.Config }
 
 func (repoResolver) Name(_ context.Context, obj *models.Repository) (*string, error) {
 	name := obj.Repo.Name()
@@ -149,7 +150,10 @@ 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) {
+func (r repoResolver) UserIdentity(_ context.Context, obj *models.Repository) (models.IdentityWrapper, error) {
+	if r.cfg.ReadOnly {
+		return nil, nil
+	}
 	excerpt, err := obj.Repo.GetUserIdentityExcerpt()
 	if err != nil {
 		return nil, err

graphql/resolvers/root.go 🔗

@@ -3,6 +3,7 @@ package resolvers
 
 import (
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/graphql/config"
 	"github.com/MichaelMure/git-bug/graphql/graph"
 )
 
@@ -10,11 +11,13 @@ var _ graph.ResolverRoot = &RootResolver{}
 
 type RootResolver struct {
 	cache.MultiRepoCache
+	cfg config.Config
 }
 
-func NewRootResolver() *RootResolver {
+func NewRootResolver(cfg config.Config) *RootResolver {
 	return &RootResolver{
 		MultiRepoCache: cache.NewMultiRepoCache(),
+		cfg:            cfg,
 	}
 }
 
@@ -25,13 +28,16 @@ func (r RootResolver) Query() graph.QueryResolver {
 }
 
 func (r RootResolver) Mutation() graph.MutationResolver {
+	if r.cfg.ReadOnly {
+		return &readonlyMutationResolver{}
+	}
 	return &mutationResolver{
 		cache: &r.MultiRepoCache,
 	}
 }
 
-func (RootResolver) Repository() graph.RepositoryResolver {
-	return &repoResolver{}
+func (r RootResolver) Repository() graph.RepositoryResolver {
+	return &repoResolver{r.cfg}
 }
 
 func (RootResolver) Bug() graph.BugResolver {
@@ -50,7 +56,7 @@ func (RootResolver) Label() graph.LabelResolver {
 	return &labelResolver{}
 }
 
-func (r RootResolver) Identity() graph.IdentityResolver {
+func (RootResolver) Identity() graph.IdentityResolver {
 	return &identityResolver{}
 }
 

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/src/layout/CurrentIdentity.tsx 🔗

@@ -3,7 +3,7 @@ import React from 'react';
 import Avatar from '@material-ui/core/Avatar';
 import { makeStyles } from '@material-ui/core/styles';
 
-import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
+import CurrentIdentityContext from './CurrentIdentityContext';
 
 const useStyles = makeStyles(theme => ({
   displayName: {
@@ -13,18 +13,26 @@ const useStyles = makeStyles(theme => ({
 
 const CurrentIdentity = () => {
   const classes = useStyles();
-  const { loading, error, data } = useCurrentIdentityQuery();
 
-  if (error || loading || !data?.repository?.userIdentity) return null;
-
-  const user = data.repository.userIdentity;
   return (
-    <>
-      <Avatar src={user.avatarUrl ? user.avatarUrl : undefined}>
-        {user.displayName.charAt(0).toUpperCase()}
-      </Avatar>
-      <div className={classes.displayName}>{user.displayName}</div>
-    </>
+    <CurrentIdentityContext.Consumer>
+      {context => {
+        if (!context) return null;
+        const { loading, error, data } = context as any;
+
+        if (error || loading || !data?.repository?.userIdentity) return null;
+
+        const user = data.repository.userIdentity;
+        return (
+          <>
+            <Avatar src={user.avatarUrl ? user.avatarUrl : undefined}>
+              {user.displayName.charAt(0).toUpperCase()}
+            </Avatar>
+            <div className={classes.displayName}>{user.displayName}</div>
+          </>
+        );
+      }}
+    </CurrentIdentityContext.Consumer>
   );
 };
 

webui/src/layout/CurrentIdentityContext.tsx 🔗

@@ -0,0 +1,6 @@
+import React from 'react';
+
+import { CurrentIdentityQueryResult } from './CurrentIdentity.generated';
+
+const Context = React.createContext(null as CurrentIdentityQueryResult | null);
+export default Context;

webui/src/layout/ReadonlyHidden.tsx 🔗

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

webui/src/layout/index.tsx 🔗

@@ -2,16 +2,18 @@ import React from 'react';
 
 import CssBaseline from '@material-ui/core/CssBaseline';
 
+import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
+import CurrentIdentityContext from './CurrentIdentityContext';
 import Header from './Header';
 
 type Props = { children: React.ReactNode };
 function Layout({ children }: Props) {
   return (
-    <>
+    <CurrentIdentityContext.Provider value={useCurrentIdentityQuery()}>
       <CssBaseline />
       <Header />
       {children}
-    </>
+    </CurrentIdentityContext.Provider>
   );
 }
 

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 ReadonlyHidden from 'src/layout/ReadonlyHidden';
 
 import { BugFragment } from './Bug.generated';
 import CommentForm from './CommentForm';
@@ -88,9 +89,11 @@ 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>
+          <ReadonlyHidden>
+            <div className={classes.commentForm}>
+              <CommentForm bugId={bug.id} />
+            </div>
+          </ReadonlyHidden>
         </div>
         <div className={classes.sidebar}>
           <span className={classes.sidebarTitle}>Labels</span>