From 2772a38daed66869e43b72969c2dfe79ae7fe70c Mon Sep 17 00:00:00 2001 From: Philipp Heckel 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: or 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} />} + ); @@ -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 && + + + + } + {!username && + <> + + + + } + + {({TransitionProps, placement}) => ( + + + + + Upgrade + Logout + + + + + )} + + + ); +}; + + 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 = () => { + }/> + }/> }> - }/> + }/> }/> }/> }/> 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 ( + + + This is the landing page + Login + + + ); +}; + +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 ( + + {'Copyright © '} + + Your Website + {' '} + {new Date().getFullYear()} + {'.'} + + ); +}; + +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 ( + <> + + + + + + Sign in + + + + + } + label="Remember me" + /> + + + + + Forgot password? + + + + + {"Don't have an account? Sign Up"} + + + + + + + + ); +} + +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 && } {showNotificationGrantBox && } {!showSubscriptionsList && - navigate(routes.root)} selected={location.pathname === config.appRoot}> + navigate(routes.app)} selected={location.pathname === config.appRoot}> } {showSubscriptionsList && <> {t("nav_topics_title")} - navigate(routes.root)} selected={location.pathname === config.appRoot}> + navigate(routes.app)} selected={location.pathname === config.appRoot}> 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 = () => { + ); @@ -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 ( + + + + Access control + + + Define read/write access to topics for this server. + + {entries?.length > 0 && } + + + + + + + ); +}; + +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 ( + + + + Topic + User + Access + + + + + {props.entries?.map(user => ( + + {user.username} + {user.baseUrl} + + handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> + + + handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> + + + + + ))} + + +
+ ); +}; + +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 ( + + Add entry + + setTopic(ev.target.value)} + type="text" + fullWidth + variant="standard" + /> + + + + + + + + + + ); +}; + + 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",