From 2772a38daed66869e43b72969c2dfe79ae7fe70c Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Fri, 2 Dec 2022 15:37:48 -0500
Subject: [PATCH] WIPWIPWIP

---
 auth/auth_sqlite.go                   |  66 ++++++++---
 server/server.go                      | 154 ++++++++++++++++++------
 server/visitor.go                     |   5 +-
 web/public/config.js                  |   2 +-
 web/src/app/Api.js                    |  20 +++-
 web/src/app/Session.js                |  22 ++++
 web/src/app/db.js                     |   4 +-
 web/src/app/utils.js                  |   1 +
 web/src/components/ActionBar.js       |  93 ++++++++++++++-
 web/src/components/App.js             |   6 +-
 web/src/components/Home.js            |  49 ++++++++
 web/src/components/Login.js           | 113 ++++++++++++++++++
 web/src/components/Navigation.js      |   4 +-
 web/src/components/Preferences.js     | 163 +++++++++++++++++++++++++-
 web/src/components/SubscribeDialog.js |   4 +-
 web/src/components/routes.js          |   4 +-
 16 files changed, 644 insertions(+), 66 deletions(-)
 create mode 100644 web/src/app/Session.js
 create mode 100644 web/src/components/Home.js
 create mode 100644 web/src/components/Login.js

diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go
index f2bad460..a91c45ef 100644
--- a/auth/auth_sqlite.go
+++ b/auth/auth_sqlite.go
@@ -18,30 +18,62 @@ const (
 const (
 	createAuthTablesQueries = `
 		BEGIN;
-		CREATE TABLE IF NOT EXISTS user (
-			user TEXT NOT NULL PRIMARY KEY,
-			pass TEXT NOT NULL,
-			role TEXT NOT NULL
+		CREATE TABLE IF NOT EXISTS plan (
+			id INT NOT NULL,		
+			name TEXT NOT NULL,	
+			limit_messages INT,
+			PRIMARY KEY (id)
 		);
-		CREATE TABLE IF NOT EXISTS access (
-			user TEXT NOT NULL,		
+		CREATE TABLE IF NOT EXISTS user (
+		    id INTEGER PRIMARY KEY AUTOINCREMENT,
+			plan_id INT,
+			user TEXT NOT NULL,
+			pass TEXT NOT NULL,
+			role TEXT NOT NULL,
+			language TEXT,
+			notification_sound TEXT,
+			notification_min_priority INT,
+			notification_delete_after INT,
+		    FOREIGN KEY (plan_id) REFERENCES plan (id)
+		);
+		CREATE UNIQUE INDEX idx_user ON user (user);
+		CREATE TABLE IF NOT EXISTS user_access (
+			user_id INT NOT NULL,		
 			topic TEXT NOT NULL,
 			read INT NOT NULL,
 			write INT NOT NULL,
-			PRIMARY KEY (topic, user)
+			PRIMARY KEY (user_id, topic),
+			FOREIGN KEY (user_id) REFERENCES user (id)
 		);
+		CREATE TABLE IF NOT EXISTS user_subscription (
+			user_id INT NOT NULL,		
+			base_url TEXT NOT NULL,	
+			topic TEXT NOT NULL,
+			PRIMARY KEY (user_id, base_url, topic)
+		);
+		CREATE TABLE IF NOT EXISTS user_token (
+			token TEXT NOT NULL,
+			user_id INT NOT NULL,
+			expires INT NOT NULL,
+			PRIMARY KEY (token)
+		);
+		CREATE INDEX idx_user_id ON user_token (user_id);  
 		CREATE TABLE IF NOT EXISTS schemaVersion (
 			id INT PRIMARY KEY,
 			version INT NOT NULL
 		);
+		CREATE INDEX IF NOT EXISTS idx_user ON user_subscription (user);
+		INSERT INTO plan (id, name) VALUES (1, 'Admin') ON CONFLICT (id) DO NOTHING;
+		INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING;
 		COMMIT;
 	`
 	selectUserQuery       = `SELECT pass, role FROM user WHERE user = ?`
 	selectTopicPermsQuery = `
 		SELECT read, write 
-		FROM access 
-		WHERE user IN ('*', ?) AND ? LIKE topic
-		ORDER BY user DESC
+		FROM user_access
+		JOIN user ON user.user = '*' OR user.user = ?
+		WHERE ? LIKE user_access.topic
+		ORDER BY user.user DESC
 	`
 )
 
@@ -53,15 +85,11 @@ const (
 	updateUserRoleQuery  = `UPDATE user SET role = ? WHERE user = ?`
 	deleteUserQuery      = `DELETE FROM user WHERE user = ?`
 
-	upsertUserAccessQuery = `
-		INSERT INTO access (user, topic, read, write) 
-		VALUES (?, ?, ?, ?)
-		ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write
-	`
-	selectUserAccessQuery  = `SELECT topic, read, write FROM access WHERE user = ?`
-	deleteAllAccessQuery   = `DELETE FROM access`
-	deleteUserAccessQuery  = `DELETE FROM access WHERE user = ?`
-	deleteTopicAccessQuery = `DELETE FROM access WHERE user = ? AND topic = ?`
+	upsertUserAccessQuery  = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)`
+	selectUserAccessQuery  = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
+	deleteAllAccessQuery   = `DELETE FROM user_access`
+	deleteUserAccessQuery  = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
+	deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?`
 )
 
 // Schema management queries
diff --git a/server/server.go b/server/server.go
index fe729b1b..ec573878 100644
--- a/server/server.go
+++ b/server/server.go
@@ -43,7 +43,7 @@ type Server struct {
 	smtpServerBackend *smtpBackend
 	smtpSender        mailer
 	topics            map[string]*topic
-	visitors          map[netip.Addr]*visitor
+	visitors          map[string]*visitor // ip:<ip> or user:<user>
 	firebaseClient    *firebaseClient
 	messages          int64
 	auth              auth.Auther
@@ -69,7 +69,9 @@ var (
 	publishPathRegex       = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
 
 	webConfigPath    = "/config.js"
-	userStatsPath    = "/user/stats"
+	userStatsPath    = "/user/stats" // FIXME get rid of this in favor of /user/account
+	userAuthPath     = "/user/auth"
+	userAccountPath  = "/user/account"
 	matrixPushPath   = "/_matrix/push/v1/notify"
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
 	docsRegex        = regexp.MustCompile(`^/docs(|/.*)$`)
@@ -151,7 +153,7 @@ func New(conf *Config) (*Server, error) {
 		smtpSender:     mailer,
 		topics:         topics,
 		auth:           auther,
-		visitors:       make(map[netip.Addr]*visitor),
+		visitors:       make(map[string]*visitor),
 	}, nil
 }
 
@@ -255,12 +257,15 @@ func (s *Server) Stop() {
 }
 
 func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
-	v := s.visitor(r)
-	log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
-	if log.IsTrace() {
-		log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
+	v, err := s.visitor(r) // Note: Always returns v, even when error is returned
+	if err == nil {
+		log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
+		if log.IsTrace() {
+			log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
+		}
+		err = s.handleInternal(w, r, v)
 	}
-	if err := s.handleInternal(w, r, v); err != nil {
+	if err != nil {
 		if websocket.IsWebSocketUpgrade(r) {
 			isNormalError := strings.Contains(err.Error(), "i/o timeout")
 			if isNormalError {
@@ -300,6 +305,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 		return s.handleUserStats(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == userAuthPath {
+		return s.handleUserAuth(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath {
+		return s.handleUserAccount(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
 		return s.handleMatrixDiscovery(w)
 	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
@@ -394,6 +403,72 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
 	return nil
 }
 
+var sessions = make(map[string]*auth.User) // token-> user
+
+type tokenAuthResponse struct {
+	Token string `json:"token"`
+}
+
+func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	// TODO rate limit
+	if v.user == nil {
+		return errHTTPUnauthorized
+	}
+	token := util.RandomString(32)
+	sessions[token] = v.user
+	w.Header().Set("Content-Type", "text/json")
+	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
+	response := &tokenAuthResponse{
+		Token: token,
+	}
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		return err
+	}
+	return nil
+}
+
+type userSubscriptionResponse struct {
+	BaseURL string `json:"base_url"`
+	Topic   string `json:"topic"`
+}
+
+type userAccountResponse struct {
+	Username string `json:"username"`
+	Role     string `json:"role,omitempty"`
+	Language string `json:"language,omitempty"`
+	Plan     struct {
+		Id   int    `json:"id"`
+		Name string `json:"name"`
+	} `json:"plan,omitempty"`
+	Notification struct {
+		Sound       string `json:"sound"`
+		MinPriority string `json:"min_priority"`
+		DeleteAfter int    `json:"delete_after"`
+	} `json:"notification,omitempty"`
+	Subscriptions []*userSubscriptionResponse `json:"subscriptions,omitempty"`
+}
+
+func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	w.Header().Set("Content-Type", "text/json")
+	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
+	var response *userAccountResponse
+	if v.user != nil {
+		response = &userAccountResponse{
+			Username: v.user.Name,
+			Role:     string(v.user.Role),
+			Language: "en_US",
+		}
+	} else {
+		response = &userAccountResponse{
+			Username: "anonymous",
+		}
+	}
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		return err
+	}
+	return nil
+}
+
 func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
 	r.URL.Path = webSiteDir + r.URL.Path
 	util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
@@ -1221,7 +1296,7 @@ func (s *Server) runFirebaseKeepaliver() {
 	if s.firebaseClient == nil {
 		return
 	}
-	v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified()) // Background process, not a real visitor, uses IP 0.0.0.0
+	v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0
 	for {
 		select {
 		case <-time.After(s.config.FirebaseKeepaliveInterval):
@@ -1253,7 +1328,7 @@ func (s *Server) sendDelayedMessages() error {
 		return err
 	}
 	for _, m := range messages {
-		v := s.visitorFromIP(m.Sender)
+		v := s.visitorFromID(fmt.Sprintf("ip:%s", m.Sender.String()), m.Sender, nil) // FIXME: This is wrong wrong wrong
 		if err := s.sendDelayedMessage(v, m); err != nil {
 			log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
 		}
@@ -1395,16 +1470,8 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
 		if err != nil {
 			return err
 		}
-		var user *auth.User // may stay nil if no auth header!
-		username, password, ok := extractUserPass(r)
-		if ok {
-			if user, err = s.auth.Authenticate(username, password); err != nil {
-				log.Info("authentication failed: %s", err.Error())
-				return errHTTPUnauthorized
-			}
-		}
 		for _, t := range topics {
-			if err := s.auth.Authorize(user, t.ID, perm); err != nil {
+			if err := s.auth.Authorize(v.user, t.ID, perm); err != nil {
 				log.Info("unauthorized: %s", err.Error())
 				return errHTTPForbidden
 			}
@@ -1435,8 +1502,39 @@ func extractUserPass(r *http.Request) (username string, password string, ok bool
 }
 
 // visitor creates or retrieves a rate.Limiter for the given visitor.
-// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
-func (s *Server) visitor(r *http.Request) *visitor {
+// Note that this function will always return a visitor, even if an error occurs.
+func (s *Server) visitor(r *http.Request) (v *visitor, err error) {
+	ip := s.extractIPAddress(r)
+	visitorID := fmt.Sprintf("ip:%s", ip.String())
+
+	var user *auth.User // may stay nil if no auth header!
+	username, password, ok := extractUserPass(r)
+	if ok {
+		if user, err = s.auth.Authenticate(username, password); err != nil {
+			log.Debug("authentication failed: %s", err.Error())
+			err = errHTTPUnauthorized // Always return visitor, even when error occurs!
+		} else {
+			visitorID = fmt.Sprintf("user:%s", user.Name)
+		}
+	}
+	v = s.visitorFromID(visitorID, ip, user)
+	v.user = user // Update user -- FIXME this is ugly, do "newVisitorFromUser" instead
+	return v, err // Always return visitor, even when error occurs!
+}
+
+func (s *Server) visitorFromID(visitorID string, ip netip.Addr, user *auth.User) *visitor {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	v, exists := s.visitors[visitorID]
+	if !exists {
+		s.visitors[visitorID] = newVisitor(s.config, s.messageCache, ip, user)
+		return s.visitors[visitorID]
+	}
+	v.Keepalive()
+	return v
+}
+
+func (s *Server) extractIPAddress(r *http.Request) netip.Addr {
 	remoteAddr := r.RemoteAddr
 	addrPort, err := netip.ParseAddrPort(remoteAddr)
 	ip := addrPort.Addr()
@@ -1461,17 +1559,5 @@ func (s *Server) visitor(r *http.Request) *visitor {
 			ip = realIP
 		}
 	}
-	return s.visitorFromIP(ip)
-}
-
-func (s *Server) visitorFromIP(ip netip.Addr) *visitor {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	v, exists := s.visitors[ip]
-	if !exists {
-		s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
-		return s.visitors[ip]
-	}
-	v.Keepalive()
-	return v
+	return ip
 }
diff --git a/server/visitor.go b/server/visitor.go
index cd120c43..f3310693 100644
--- a/server/visitor.go
+++ b/server/visitor.go
@@ -2,6 +2,7 @@ package server
 
 import (
 	"errors"
+	"heckel.io/ntfy/auth"
 	"net/netip"
 	"sync"
 	"time"
@@ -26,6 +27,7 @@ type visitor struct {
 	config        *Config
 	messageCache  *messageCache
 	ip            netip.Addr
+	user          *auth.User
 	requests      *rate.Limiter
 	emails        *rate.Limiter
 	subscriptions util.Limiter
@@ -42,11 +44,12 @@ type visitorStats struct {
 	VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
 }
 
-func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr) *visitor {
+func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr, user *auth.User) *visitor {
 	return &visitor{
 		config:        conf,
 		messageCache:  messageCache,
 		ip:            ip,
+		user:          user,
 		requests:      rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
 		emails:        rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
 		subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
diff --git a/web/public/config.js b/web/public/config.js
index cd5fbf05..76c02041 100644
--- a/web/public/config.js
+++ b/web/public/config.js
@@ -4,6 +4,6 @@
 // The actual config is dynamically generated server-side.
 
 var config = {
-    appRoot: "/",
+    appRoot: "/app",
     disallowedTopics: ["docs", "static", "file", "app", "settings"]
 };
diff --git a/web/src/app/Api.js b/web/src/app/Api.js
index a07f7a56..d2b8c7e5 100644
--- a/web/src/app/Api.js
+++ b/web/src/app/Api.js
@@ -5,7 +5,7 @@ import {
     topicUrl,
     topicUrlAuth,
     topicUrlJsonPoll,
-    topicUrlJsonPollWithSince,
+    topicUrlJsonPollWithSince, userAuthUrl,
     userStatsUrl
 } from "./utils";
 import userManager from "./UserManager";
@@ -101,7 +101,7 @@ class Api {
         return send;
     }
 
-    async auth(baseUrl, topic, user) {
+    async topicAuth(baseUrl, topic, user) {
         const url = topicUrlAuth(baseUrl, topic);
         console.log(`[Api] Checking auth for ${url}`);
         const response = await fetch(url, {
@@ -117,6 +117,22 @@ class Api {
         throw new Error(`Unexpected server response ${response.status}`);
     }
 
+    async userAuth(baseUrl, user) {
+        const url = userAuthUrl(baseUrl);
+        console.log(`[Api] Checking auth for ${url}`);
+        const response = await fetch(url, {
+            headers: maybeWithBasicAuth({}, user)
+        });
+        if (response.status !== 200) {
+            throw new Error(`Unexpected server response ${response.status}`);
+        }
+        const json = await response.json();
+        if (!json.token) {
+            throw new Error(`Unexpected server response: Cannot find token`);
+        }
+        return json.token;
+    }
+
     async userStats(baseUrl) {
         const url = userStatsUrl(baseUrl);
         console.log(`[Api] Fetching user stats ${url}`);
diff --git a/web/src/app/Session.js b/web/src/app/Session.js
new file mode 100644
index 00000000..1ae8606a
--- /dev/null
+++ b/web/src/app/Session.js
@@ -0,0 +1,22 @@
+class Session {
+    store(username, token) {
+        localStorage.setItem("user", username);
+        localStorage.setItem("token", token);
+    }
+
+    reset() {
+        localStorage.removeItem("user");
+        localStorage.removeItem("token");
+    }
+
+    username() {
+        return localStorage.getItem("user");
+    }
+
+    token() {
+        return localStorage.getItem("token");
+    }
+}
+
+const session = new Session();
+export default session;
diff --git a/web/src/app/db.js b/web/src/app/db.js
index 7c82be31..31eba294 100644
--- a/web/src/app/db.js
+++ b/web/src/app/db.js
@@ -1,4 +1,5 @@
 import Dexie from 'dexie';
+import session from "./Session";
 
 // Uses Dexie.js
 // https://dexie.org/docs/API-Reference#quick-reference
@@ -6,7 +7,8 @@ import Dexie from 'dexie';
 // Notes:
 // - As per docs, we only declare the indexable columns, not all columns
 
-const db = new Dexie('ntfy');
+const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy";
+const db = new Dexie(dbName);
 
 db.version(1).stores({
     subscriptions: '&id,baseUrl',
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index ffc359b5..24ed825f 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -19,6 +19,7 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ
 export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
 export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
 export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
+export const userAuthUrl = (baseUrl) => `${baseUrl}/user/auth`;
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
 export const expandSecureUrl = (url) => `https://${url}`;
diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js
index e284f922..5d4b5847 100644
--- a/web/src/components/ActionBar.js
+++ b/web/src/components/ActionBar.js
@@ -25,10 +25,14 @@ import logo from "../img/ntfy.svg";
 import {useTranslation} from "react-i18next";
 import {Portal, Snackbar} from "@mui/material";
 import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
+import session from "../app/Session";
+import AccountCircleIcon from '@mui/icons-material/AccountCircle';
+import Button from "@mui/material/Button";
 
 const ActionBar = (props) => {
     const { t } = useTranslation();
     const location = useLocation();
+    const username = session.username();
     let title = "ntfy";
     if (props.selected) {
         title = topicDisplayName(props.selected);
@@ -69,6 +73,7 @@ const ActionBar = (props) => {
                         subscription={props.selected}
                         onUnsubscribe={props.onUnsubscribe}
                     />}
+                <ProfileIcon/>
             </Toolbar>
         </AppBar>
     );
@@ -114,7 +119,7 @@ const SettingsIcons = (props) => {
         if (newSelected) {
             navigate(routes.forSubscription(newSelected));
         } else {
-            navigate(routes.root);
+            navigate(routes.app);
         }
     };
 
@@ -237,4 +242,90 @@ const SettingsIcons = (props) => {
     );
 };
 
+const ProfileIcon = (props) => {
+    const { t } = useTranslation();
+    const [open, setOpen] = useState(false);
+    const anchorRef = useRef(null);
+    const username = session.username();
+
+    const handleToggleOpen = () => {
+        setOpen((prevOpen) => !prevOpen);
+    };
+
+    const handleClose = (event) => {
+        if (anchorRef.current && anchorRef.current.contains(event.target)) {
+            return;
+        }
+        setOpen(false);
+    };
+
+    const handleListKeyDown = (event) => {
+        if (event.key === 'Tab') {
+            event.preventDefault();
+            setOpen(false);
+        } else if (event.key === 'Escape') {
+            setOpen(false);
+        }
+    }
+
+    const handleUpgrade = () => {
+        // TODO
+    };
+
+    const handleLogout = () => {
+        session.reset();
+        window.location.href = routes.app;
+    };
+
+    // return focus to the button when we transitioned from !open -> open
+    const prevOpen = useRef(open);
+    useEffect(() => {
+        if (prevOpen.current === true && open === false) {
+            anchorRef.current.focus();
+        }
+        prevOpen.current = open;
+    }, [open]);
+
+    return (
+        <>
+            {username &&
+                <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}>
+                    <AccountCircleIcon/>
+                </IconButton>
+            }
+            {!username &&
+                <>
+                    <Button>Sign in</Button>
+                    <Button>Sign up</Button>
+                </>
+            }
+            <Popper
+                open={open}
+                anchorEl={anchorRef.current}
+                role={undefined}
+                placement="bottom-start"
+                transition
+                disablePortal
+            >
+                {({TransitionProps, placement}) => (
+                    <Grow
+                        {...TransitionProps}
+                        style={{transformOrigin: placement === 'bottom-start' ? 'left top' : 'left bottom'}}
+                    >
+                        <Paper>
+                            <ClickAwayListener onClickAway={handleClose}>
+                                <MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
+                                    <MenuItem onClick={handleUpgrade}>Upgrade</MenuItem>
+                                    <MenuItem onClick={handleLogout}>Logout</MenuItem>
+                                </MenuList>
+                            </ClickAwayListener>
+                        </Paper>
+                    </Grow>
+                )}
+            </Popper>
+        </>
+    );
+};
+
+
 export default ActionBar;
diff --git a/web/src/components/App.js b/web/src/components/App.js
index 1ecf878c..e74aa3db 100644
--- a/web/src/components/App.js
+++ b/web/src/components/App.js
@@ -23,6 +23,8 @@ import PublishDialog from "./PublishDialog";
 import Messaging from "./Messaging";
 import "./i18n"; // Translations!
 import {Backdrop, CircularProgress} from "@mui/material";
+import Home from "./Home";
+import Login from "./Login";
 
 // TODO races when two tabs are open
 // TODO investigate service workers
@@ -35,8 +37,10 @@ const App = () => {
                     <CssBaseline/>
                     <ErrorBoundary>
                         <Routes>
+                            <Route path={routes.home} element={<Home/>}/>
+                            <Route path={routes.login} element={<Login/>}/>
                             <Route element={<Layout/>}>
-                                <Route path={routes.root} element={<AllSubscriptions/>}/>
+                                <Route path={routes.app} element={<AllSubscriptions/>}/>
                                 <Route path={routes.settings} element={<Preferences/>}/>
                                 <Route path={routes.subscription} element={<SingleSubscription/>}/>
                                 <Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
diff --git a/web/src/components/Home.js b/web/src/components/Home.js
new file mode 100644
index 00000000..b44a5e49
--- /dev/null
+++ b/web/src/components/Home.js
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import {useEffect, useState} from 'react';
+import {
+    CardActions,
+    CardContent,
+    FormControl, Link,
+    Select,
+    Stack,
+    Table,
+    TableBody,
+    TableCell,
+    TableHead,
+    TableRow,
+    useMediaQuery
+} from "@mui/material";
+import Typography from "@mui/material/Typography";
+import prefs from "../app/Prefs";
+import {Paragraph} from "./styles";
+import EditIcon from '@mui/icons-material/Edit';
+import CloseIcon from "@mui/icons-material/Close";
+import IconButton from "@mui/material/IconButton";
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import Container from "@mui/material/Container";
+import TextField from "@mui/material/TextField";
+import MenuItem from "@mui/material/MenuItem";
+import Card from "@mui/material/Card";
+import Button from "@mui/material/Button";
+import {useLiveQuery} from "dexie-react-hooks";
+import theme from "./theme";
+import Dialog from "@mui/material/Dialog";
+import DialogTitle from "@mui/material/DialogTitle";
+import DialogContent from "@mui/material/DialogContent";
+import DialogActions from "@mui/material/DialogActions";
+import userManager from "../app/UserManager";
+import {playSound, shuffle, sounds, validUrl} from "../app/utils";
+import {useTranslation} from "react-i18next";
+
+const Home = () => {
+    return (
+        <Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
+            <Stack spacing={3}>
+               This is the landing page
+                <Link href="/login">Login</Link>
+            </Stack>
+        </Container>
+    );
+};
+
+export default Home;
diff --git a/web/src/components/Login.js b/web/src/components/Login.js
new file mode 100644
index 00000000..0e195973
--- /dev/null
+++ b/web/src/components/Login.js
@@ -0,0 +1,113 @@
+import * as React from 'react';
+import {Avatar, Checkbox, FormControlLabel, Grid, Link, Stack} from "@mui/material";
+import Typography from "@mui/material/Typography";
+import Container from "@mui/material/Container";
+import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
+import TextField from "@mui/material/TextField";
+import Button from "@mui/material/Button";
+import Box from "@mui/material/Box";
+import api from "../app/Api";
+import {useNavigate} from "react-router-dom";
+import routes from "./routes";
+import session from "../app/Session";
+
+const Copyright = (props) => {
+    return (
+        <Typography variant="body2" color="text.secondary" align="center" {...props}>
+            {'Copyright © '}
+            <Link color="inherit" href="https://mui.com/">
+                Your Website
+            </Link>{' '}
+            {new Date().getFullYear()}
+            {'.'}
+        </Typography>
+    );
+};
+
+const Login = () => {
+    const handleSubmit = async (event) => {
+        event.preventDefault();
+        const data = new FormData(event.currentTarget);
+        console.log({
+            email: data.get('email'),
+            password: data.get('password'),
+        });
+        const user ={
+            username: data.get('email'),
+            password: data.get('password'),
+        }
+        const token = await api.userAuth("http://localhost:2586"/*window.location.origin*/, user);
+        console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
+        session.store(user.username, token);
+        window.location.href = routes.app;
+    };
+
+    return (
+        <>
+            <Box
+                sx={{
+                    marginTop: 8,
+                    display: 'flex',
+                    flexDirection: 'column',
+                    alignItems: 'center',
+                }}
+            >
+                <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
+                    <LockOutlinedIcon/>
+                </Avatar>
+                <Typography component="h1" variant="h5">
+                    Sign in
+                </Typography>
+                <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
+                    <TextField
+                        margin="normal"
+                        required
+                        fullWidth
+                        id="email"
+                        label="Email Address"
+                        name="email"
+                        autoComplete="email"
+                        autoFocus
+                    />
+                    <TextField
+                        margin="normal"
+                        required
+                        fullWidth
+                        name="password"
+                        label="Password"
+                        type="password"
+                        id="password"
+                        autoComplete="current-password"
+                    />
+                    <FormControlLabel
+                        control={<Checkbox value="remember" color="primary"/>}
+                        label="Remember me"
+                    />
+                    <Button
+                        type="submit"
+                        fullWidth
+                        variant="contained"
+                        sx={{mt: 3, mb: 2}}
+                    >
+                        Sign In
+                    </Button>
+                    <Grid container>
+                        <Grid item xs>
+                            <Link href="#" variant="body2">
+                                Forgot password?
+                            </Link>
+                        </Grid>
+                        <Grid item>
+                            <Link href="#" variant="body2">
+                                {"Don't have an account? Sign Up"}
+                            </Link>
+                        </Grid>
+                    </Grid>
+                </Box>
+            </Box>
+            <Copyright sx={{mt: 8, mb: 4}}/>
+        </>
+    );
+}
+
+export default Login;
diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js
index 694da59e..8a22e344 100644
--- a/web/src/components/Navigation.js
+++ b/web/src/components/Navigation.js
@@ -104,14 +104,14 @@ const NavList = (props) => {
                 {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
                 {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
                 {!showSubscriptionsList &&
-                    <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
+                    <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}>
                         <ListItemIcon><ChatBubble/></ListItemIcon>
                         <ListItemText primary={t("nav_button_all_notifications")}/>
                     </ListItemButton>}
                 {showSubscriptionsList &&
                     <>
                         <ListSubheader>{t("nav_topics_title")}</ListSubheader>
-                        <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
+                        <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}>
                             <ListItemIcon><ChatBubble/></ListItemIcon>
                             <ListItemText primary={t("nav_button_all_notifications")}/>
                         </ListItemButton>
diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js
index 2bafa7e9..f23a053d 100644
--- a/web/src/components/Preferences.js
+++ b/web/src/components/Preferences.js
@@ -32,7 +32,7 @@ import DialogTitle from "@mui/material/DialogTitle";
 import DialogContent from "@mui/material/DialogContent";
 import DialogActions from "@mui/material/DialogActions";
 import userManager from "../app/UserManager";
-import {playSound, shuffle, sounds, validUrl} from "../app/utils";
+import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils";
 import {useTranslation} from "react-i18next";
 
 const Preferences = () => {
@@ -42,6 +42,7 @@ const Preferences = () => {
                 <Notifications/>
                 <Appearance/>
                 <Users/>
+                <AccessControl/>
             </Stack>
         </Container>
     );
@@ -473,4 +474,164 @@ const Language = () => {
     )
 };
 
+const AccessControl = () => {
+    const { t } = useTranslation();
+    const [dialogKey, setDialogKey] = useState(0);
+    const [dialogOpen, setDialogOpen] = useState(false);
+    const entries = useLiveQuery(() => userManager.all());
+    const handleAddClick = () => {
+        setDialogKey(prev => prev+1);
+        setDialogOpen(true);
+    };
+    const handleDialogCancel = () => {
+        setDialogOpen(false);
+    };
+    const handleDialogSubmit = async (user) => {
+        setDialogOpen(false);
+        try {
+            await userManager.save(user);
+            console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
+        } catch (e) {
+            console.log(`[Preferences] Error adding user.`, e);
+        }
+    };
+    return (
+        <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
+            <CardContent sx={{ paddingBottom: 1 }}>
+                <Typography variant="h5" sx={{marginBottom: 2}}>
+                    Access control
+                </Typography>
+                <Paragraph>
+                    Define read/write access to topics for this server.
+                </Paragraph>
+                {entries?.length > 0 && <AccessControlTable entries={entries}/>}
+            </CardContent>
+            <CardActions>
+                <Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
+                <AccessControlDialog
+                    key={`aclDialog${dialogKey}`}
+                    open={dialogOpen}
+                    user={null}
+                    users={entries}
+                    onCancel={handleDialogCancel}
+                    onSubmit={handleDialogSubmit}
+                />
+            </CardActions>
+        </Card>
+    );
+};
+
+const AccessControlTable = (props) => {
+    const { t } = useTranslation();
+    const [dialogKey, setDialogKey] = useState(0);
+    const [dialogOpen, setDialogOpen] = useState(false);
+    const [dialogUser, setDialogUser] = useState(null);
+    const handleEditClick = (user) => {
+        setDialogKey(prev => prev+1);
+        setDialogUser(user);
+        setDialogOpen(true);
+    };
+    const handleDialogCancel = () => {
+        setDialogOpen(false);
+    };
+    const handleDialogSubmit = async (user) => {
+        setDialogOpen(false);
+        try {
+            await userManager.save(user);
+            console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
+        } catch (e) {
+            console.log(`[Preferences] Error updating user.`, e);
+        }
+    };
+    const handleDeleteClick = async (user) => {
+        try {
+            await userManager.delete(user.baseUrl);
+            console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
+        } catch (e) {
+            console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
+        }
+    };
+    return (
+        <Table size="small" aria-label={t("prefs_users_table")}>
+            <TableHead>
+                <TableRow>
+                    <TableCell sx={{paddingLeft: 0}}>Topic</TableCell>
+                    <TableCell>User</TableCell>
+                    <TableCell>Access</TableCell>
+                    <TableCell/>
+                </TableRow>
+            </TableHead>
+            <TableBody>
+                {props.entries?.map(user => (
+                    <TableRow
+                        key={user.baseUrl}
+                        sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
+                    >
+                        <TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
+                        <TableCell aria-label={t("xxxxxxxxxx")}>{user.baseUrl}</TableCell>
+                        <TableCell align="right">
+                            <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
+                                <EditIcon/>
+                            </IconButton>
+                            <IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
+                                <CloseIcon />
+                            </IconButton>
+                        </TableCell>
+                    </TableRow>
+                ))}
+            </TableBody>
+            <AccessControlDialog
+                key={`userEditDialog${dialogKey}`}
+                open={dialogOpen}
+                user={dialogUser}
+                users={props.entries}
+                onCancel={handleDialogCancel}
+                onSubmit={handleDialogSubmit}
+            />
+        </Table>
+    );
+};
+
+const AccessControlDialog = (props) => {
+    const { t } = useTranslation();
+    const [topic, setTopic] = useState("");
+    const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
+    const addButtonEnabled = (() => {
+        return validTopic(topic);
+    })();
+    const handleSubmit = async () => {
+        // TODO
+    };
+    return (
+        <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
+            <DialogTitle>Add entry</DialogTitle>
+            <DialogContent>
+                <TextField
+                    autoFocus={editMode}
+                    margin="dense"
+                    id="topic"
+                    label={"Topic"}
+                    aria-label={"Topic xx"}
+                    value={topic}
+                    onChange={ev => setTopic(ev.target.value)}
+                    type="text"
+                    fullWidth
+                    variant="standard"
+                />
+                <FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
+                    <Select value={"read-write"} onChange={() => {}}>
+                        <MenuItem value={"private"}>Read/write access only by me</MenuItem>
+                        <MenuItem value={"read-only"}>Read/write access by user, anonymous read</MenuItem>
+                    </Select>
+                </FormControl>
+            </DialogContent>
+            <DialogActions>
+                <Button onClick={props.onCancel}>Cancel</Button>
+                <Button onClick={handleSubmit} disabled={!addButtonEnabled}>Add entry</Button>
+            </DialogActions>
+        </Dialog>
+    );
+};
+
+
 export default Preferences;
diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js
index 62cfeb28..3c83e1a1 100644
--- a/web/src/components/SubscribeDialog.js
+++ b/web/src/components/SubscribeDialog.js
@@ -63,7 +63,7 @@ const SubscribePage = (props) => {
     const handleSubscribe = async () => {
         const user = await userManager.get(baseUrl); // May be undefined
         const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
-        const success = await api.auth(baseUrl, topic, user);
+        const success = await api.topicAuth(baseUrl, topic, user);
         if (!success) {
             console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
             if (user) {
@@ -163,7 +163,7 @@ const LoginPage = (props) => {
     const topic = props.topic;
     const handleLogin = async () => {
         const user = {baseUrl, username, password};
-        const success = await api.auth(baseUrl, topic, user);
+        const success = await api.topicAuth(baseUrl, topic, user);
         if (!success) {
             console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
             setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
diff --git a/web/src/components/routes.js b/web/src/components/routes.js
index 7a7a7857..299f5285 100644
--- a/web/src/components/routes.js
+++ b/web/src/components/routes.js
@@ -2,7 +2,9 @@ import config from "../app/config";
 import {shortUrl} from "../app/utils";
 
 const routes = {
-    root: config.appRoot,
+    home: "/",
+    login: "/login",
+    app: config.appRoot,
     settings: "/settings",
     subscription: "/:topic",
     subscriptionExternal: "/:baseUrl/:topic",