WIPWIPWIP
This commit is contained in:
parent
84dca41008
commit
2772a38dae
16 changed files with 644 additions and 66 deletions
|
@ -18,30 +18,62 @@ const (
|
||||||
const (
|
const (
|
||||||
createAuthTablesQueries = `
|
createAuthTablesQueries = `
|
||||||
BEGIN;
|
BEGIN;
|
||||||
CREATE TABLE IF NOT EXISTS user (
|
CREATE TABLE IF NOT EXISTS plan (
|
||||||
user TEXT NOT NULL PRIMARY KEY,
|
id INT NOT NULL,
|
||||||
pass TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
role TEXT NOT NULL
|
limit_messages INT,
|
||||||
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS access (
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INT,
|
||||||
user TEXT NOT NULL,
|
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,
|
topic TEXT NOT NULL,
|
||||||
read INT NOT NULL,
|
read INT NOT NULL,
|
||||||
write 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 (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
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;
|
COMMIT;
|
||||||
`
|
`
|
||||||
selectUserQuery = `SELECT pass, role FROM user WHERE user = ?`
|
selectUserQuery = `SELECT pass, role FROM user WHERE user = ?`
|
||||||
selectTopicPermsQuery = `
|
selectTopicPermsQuery = `
|
||||||
SELECT read, write
|
SELECT read, write
|
||||||
FROM access
|
FROM user_access
|
||||||
WHERE user IN ('*', ?) AND ? LIKE topic
|
JOIN user ON user.user = '*' OR user.user = ?
|
||||||
ORDER BY user DESC
|
WHERE ? LIKE user_access.topic
|
||||||
|
ORDER BY user.user DESC
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -53,15 +85,11 @@ const (
|
||||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
|
|
||||||
upsertUserAccessQuery = `
|
upsertUserAccessQuery = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)`
|
||||||
INSERT INTO access (user, topic, read, write)
|
selectUserAccessQuery = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
|
||||||
VALUES (?, ?, ?, ?)
|
deleteAllAccessQuery = `DELETE FROM user_access`
|
||||||
ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write
|
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 = ?`
|
||||||
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 = ?`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
|
|
148
server/server.go
148
server/server.go
|
@ -43,7 +43,7 @@ type Server struct {
|
||||||
smtpServerBackend *smtpBackend
|
smtpServerBackend *smtpBackend
|
||||||
smtpSender mailer
|
smtpSender mailer
|
||||||
topics map[string]*topic
|
topics map[string]*topic
|
||||||
visitors map[netip.Addr]*visitor
|
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||||
firebaseClient *firebaseClient
|
firebaseClient *firebaseClient
|
||||||
messages int64
|
messages int64
|
||||||
auth auth.Auther
|
auth auth.Auther
|
||||||
|
@ -69,7 +69,9 @@ var (
|
||||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||||
|
|
||||||
webConfigPath = "/config.js"
|
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"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
|
@ -151,7 +153,7 @@ func New(conf *Config) (*Server, error) {
|
||||||
smtpSender: mailer,
|
smtpSender: mailer,
|
||||||
topics: topics,
|
topics: topics,
|
||||||
auth: auther,
|
auth: auther,
|
||||||
visitors: make(map[netip.Addr]*visitor),
|
visitors: make(map[string]*visitor),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -255,12 +257,15 @@ func (s *Server) Stop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
v := s.visitor(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))
|
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
|
||||||
if log.IsTrace() {
|
if log.IsTrace() {
|
||||||
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
|
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
|
||||||
}
|
}
|
||||||
if err := s.handleInternal(w, r, v); err != nil {
|
err = s.handleInternal(w, r, v)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
if websocket.IsWebSocketUpgrade(r) {
|
if websocket.IsWebSocketUpgrade(r) {
|
||||||
isNormalError := strings.Contains(err.Error(), "i/o timeout")
|
isNormalError := strings.Contains(err.Error(), "i/o timeout")
|
||||||
if isNormalError {
|
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)
|
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
|
||||||
return s.handleUserStats(w, r, v)
|
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 {
|
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||||
return s.handleMatrixDiscovery(w)
|
return s.handleMatrixDiscovery(w)
|
||||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
} 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
|
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 {
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
r.URL.Path = webSiteDir + r.URL.Path
|
r.URL.Path = webSiteDir + r.URL.Path
|
||||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||||
|
@ -1221,7 +1296,7 @@ func (s *Server) runFirebaseKeepaliver() {
|
||||||
if s.firebaseClient == nil {
|
if s.firebaseClient == nil {
|
||||||
return
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
||||||
|
@ -1253,7 +1328,7 @@ func (s *Server) sendDelayedMessages() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, m := range messages {
|
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 {
|
if err := s.sendDelayedMessage(v, m); err != nil {
|
||||||
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
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())
|
log.Info("unauthorized: %s", err.Error())
|
||||||
return errHTTPForbidden
|
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.
|
// 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).
|
// Note that this function will always return a visitor, even if an error occurs.
|
||||||
func (s *Server) visitor(r *http.Request) *visitor {
|
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
|
remoteAddr := r.RemoteAddr
|
||||||
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
||||||
ip := addrPort.Addr()
|
ip := addrPort.Addr()
|
||||||
|
@ -1461,17 +1559,5 @@ func (s *Server) visitor(r *http.Request) *visitor {
|
||||||
ip = realIP
|
ip = realIP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.visitorFromIP(ip)
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
@ -26,6 +27,7 @@ type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
messageCache *messageCache
|
messageCache *messageCache
|
||||||
ip netip.Addr
|
ip netip.Addr
|
||||||
|
user *auth.User
|
||||||
requests *rate.Limiter
|
requests *rate.Limiter
|
||||||
emails *rate.Limiter
|
emails *rate.Limiter
|
||||||
subscriptions util.Limiter
|
subscriptions util.Limiter
|
||||||
|
@ -42,11 +44,12 @@ type visitorStats struct {
|
||||||
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
|
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{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
ip: ip,
|
ip: ip,
|
||||||
|
user: user,
|
||||||
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
||||||
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
||||||
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||||
|
|
|
@ -4,6 +4,6 @@
|
||||||
// The actual config is dynamically generated server-side.
|
// The actual config is dynamically generated server-side.
|
||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
appRoot: "/",
|
appRoot: "/app",
|
||||||
disallowedTopics: ["docs", "static", "file", "app", "settings"]
|
disallowedTopics: ["docs", "static", "file", "app", "settings"]
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
topicUrl,
|
topicUrl,
|
||||||
topicUrlAuth,
|
topicUrlAuth,
|
||||||
topicUrlJsonPoll,
|
topicUrlJsonPoll,
|
||||||
topicUrlJsonPollWithSince,
|
topicUrlJsonPollWithSince, userAuthUrl,
|
||||||
userStatsUrl
|
userStatsUrl
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import userManager from "./UserManager";
|
import userManager from "./UserManager";
|
||||||
|
@ -101,7 +101,7 @@ class Api {
|
||||||
return send;
|
return send;
|
||||||
}
|
}
|
||||||
|
|
||||||
async auth(baseUrl, topic, user) {
|
async topicAuth(baseUrl, topic, user) {
|
||||||
const url = topicUrlAuth(baseUrl, topic);
|
const url = topicUrlAuth(baseUrl, topic);
|
||||||
console.log(`[Api] Checking auth for ${url}`);
|
console.log(`[Api] Checking auth for ${url}`);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
@ -117,6 +117,22 @@ class Api {
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
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) {
|
async userStats(baseUrl) {
|
||||||
const url = userStatsUrl(baseUrl);
|
const url = userStatsUrl(baseUrl);
|
||||||
console.log(`[Api] Fetching user stats ${url}`);
|
console.log(`[Api] Fetching user stats ${url}`);
|
||||||
|
|
22
web/src/app/Session.js
Normal file
22
web/src/app/Session.js
Normal file
|
@ -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;
|
|
@ -1,4 +1,5 @@
|
||||||
import Dexie from 'dexie';
|
import Dexie from 'dexie';
|
||||||
|
import session from "./Session";
|
||||||
|
|
||||||
// Uses Dexie.js
|
// Uses Dexie.js
|
||||||
// https://dexie.org/docs/API-Reference#quick-reference
|
// https://dexie.org/docs/API-Reference#quick-reference
|
||||||
|
@ -6,7 +7,8 @@ import Dexie from 'dexie';
|
||||||
// Notes:
|
// Notes:
|
||||||
// - As per docs, we only declare the indexable columns, not all columns
|
// - 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({
|
db.version(1).stores({
|
||||||
subscriptions: '&id,baseUrl',
|
subscriptions: '&id,baseUrl',
|
||||||
|
|
|
@ -19,6 +19,7 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ
|
||||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
||||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||||
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
||||||
|
export const userAuthUrl = (baseUrl) => `${baseUrl}/user/auth`;
|
||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||||
export const expandSecureUrl = (url) => `https://${url}`;
|
export const expandSecureUrl = (url) => `https://${url}`;
|
||||||
|
|
|
@ -25,10 +25,14 @@ import logo from "../img/ntfy.svg";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
import {Portal, Snackbar} from "@mui/material";
|
import {Portal, Snackbar} from "@mui/material";
|
||||||
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
|
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 ActionBar = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const username = session.username();
|
||||||
let title = "ntfy";
|
let title = "ntfy";
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
title = topicDisplayName(props.selected);
|
title = topicDisplayName(props.selected);
|
||||||
|
@ -69,6 +73,7 @@ const ActionBar = (props) => {
|
||||||
subscription={props.selected}
|
subscription={props.selected}
|
||||||
onUnsubscribe={props.onUnsubscribe}
|
onUnsubscribe={props.onUnsubscribe}
|
||||||
/>}
|
/>}
|
||||||
|
<ProfileIcon/>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
);
|
);
|
||||||
|
@ -114,7 +119,7 @@ const SettingsIcons = (props) => {
|
||||||
if (newSelected) {
|
if (newSelected) {
|
||||||
navigate(routes.forSubscription(newSelected));
|
navigate(routes.forSubscription(newSelected));
|
||||||
} else {
|
} 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;
|
export default ActionBar;
|
||||||
|
|
|
@ -23,6 +23,8 @@ import PublishDialog from "./PublishDialog";
|
||||||
import Messaging from "./Messaging";
|
import Messaging from "./Messaging";
|
||||||
import "./i18n"; // Translations!
|
import "./i18n"; // Translations!
|
||||||
import {Backdrop, CircularProgress} from "@mui/material";
|
import {Backdrop, CircularProgress} from "@mui/material";
|
||||||
|
import Home from "./Home";
|
||||||
|
import Login from "./Login";
|
||||||
|
|
||||||
// TODO races when two tabs are open
|
// TODO races when two tabs are open
|
||||||
// TODO investigate service workers
|
// TODO investigate service workers
|
||||||
|
@ -35,8 +37,10 @@ const App = () => {
|
||||||
<CssBaseline/>
|
<CssBaseline/>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path={routes.home} element={<Home/>}/>
|
||||||
|
<Route path={routes.login} element={<Login/>}/>
|
||||||
<Route element={<Layout/>}>
|
<Route element={<Layout/>}>
|
||||||
<Route path={routes.root} element={<AllSubscriptions/>}/>
|
<Route path={routes.app} element={<AllSubscriptions/>}/>
|
||||||
<Route path={routes.settings} element={<Preferences/>}/>
|
<Route path={routes.settings} element={<Preferences/>}/>
|
||||||
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
||||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
||||||
|
|
49
web/src/components/Home.js
Normal file
49
web/src/components/Home.js
Normal file
|
@ -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;
|
113
web/src/components/Login.js
Normal file
113
web/src/components/Login.js
Normal file
|
@ -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;
|
|
@ -104,14 +104,14 @@ const NavList = (props) => {
|
||||||
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
|
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
|
||||||
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
|
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
|
||||||
{!showSubscriptionsList &&
|
{!showSubscriptionsList &&
|
||||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}>
|
||||||
<ListItemIcon><ChatBubble/></ListItemIcon>
|
<ListItemIcon><ChatBubble/></ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_all_notifications")}/>
|
<ListItemText primary={t("nav_button_all_notifications")}/>
|
||||||
</ListItemButton>}
|
</ListItemButton>}
|
||||||
{showSubscriptionsList &&
|
{showSubscriptionsList &&
|
||||||
<>
|
<>
|
||||||
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
|
<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>
|
<ListItemIcon><ChatBubble/></ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_all_notifications")}/>
|
<ListItemText primary={t("nav_button_all_notifications")}/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
|
|
@ -32,7 +32,7 @@ import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
import userManager from "../app/UserManager";
|
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";
|
import {useTranslation} from "react-i18next";
|
||||||
|
|
||||||
const Preferences = () => {
|
const Preferences = () => {
|
||||||
|
@ -42,6 +42,7 @@ const Preferences = () => {
|
||||||
<Notifications/>
|
<Notifications/>
|
||||||
<Appearance/>
|
<Appearance/>
|
||||||
<Users/>
|
<Users/>
|
||||||
|
<AccessControl/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</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;
|
export default Preferences;
|
||||||
|
|
|
@ -63,7 +63,7 @@ const SubscribePage = (props) => {
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
const user = await userManager.get(baseUrl); // May be undefined
|
const user = await userManager.get(baseUrl); // May be undefined
|
||||||
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
|
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) {
|
if (!success) {
|
||||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
||||||
if (user) {
|
if (user) {
|
||||||
|
@ -163,7 +163,7 @@ const LoginPage = (props) => {
|
||||||
const topic = props.topic;
|
const topic = props.topic;
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
const user = {baseUrl, username, password};
|
const user = {baseUrl, username, password};
|
||||||
const success = await api.auth(baseUrl, topic, user);
|
const success = await api.topicAuth(baseUrl, topic, user);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
||||||
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
||||||
|
|
|
@ -2,7 +2,9 @@ import config from "../app/config";
|
||||||
import {shortUrl} from "../app/utils";
|
import {shortUrl} from "../app/utils";
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
root: config.appRoot,
|
home: "/",
|
||||||
|
login: "/login",
|
||||||
|
app: config.appRoot,
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
subscription: "/:topic",
|
subscription: "/:topic",
|
||||||
subscriptionExternal: "/:baseUrl/:topic",
|
subscriptionExternal: "/:baseUrl/:topic",
|
||||||
|
|
Loading…
Reference in a new issue