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 (
|
||||
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 (
|
||||
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
|
||||
|
|
148
server/server.go
148
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)
|
||||
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))
|
||||
}
|
||||
if err := s.handleInternal(w, r, v); err != nil {
|
||||
err = s.handleInternal(w, r, v)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
// The actual config is dynamically generated server-side.
|
||||
|
||||
var config = {
|
||||
appRoot: "/",
|
||||
appRoot: "/app",
|
||||
disallowedTopics: ["docs", "static", "file", "app", "settings"]
|
||||
};
|
||||
|
|
|
@ -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}`);
|
||||
|
|
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 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',
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/>}/>
|
||||
|
|
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/>}
|
||||
{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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }));
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue