WIPWIPWIP

This commit is contained in:
Philipp Heckel 2022-12-02 15:37:48 -05:00
parent 84dca41008
commit 2772a38dae
16 changed files with 644 additions and 66 deletions

View file

@ -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

View file

@ -43,7 +43,7 @@ type Server struct {
smtpServerBackend *smtpBackend
smtpSender mailer
topics map[string]*topic
visitors map[netip.Addr]*visitor
visitors map[string]*visitor // ip:<ip> or user:<user>
firebaseClient *firebaseClient
messages int64
auth auth.Auther
@ -69,7 +69,9 @@ var (
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js"
userStatsPath = "/user/stats"
userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
userAuthPath = "/user/auth"
userAccountPath = "/user/account"
matrixPushPath = "/_matrix/push/v1/notify"
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
@ -151,7 +153,7 @@ func New(conf *Config) (*Server, error) {
smtpSender: mailer,
topics: topics,
auth: auther,
visitors: make(map[netip.Addr]*visitor),
visitors: make(map[string]*visitor),
}, nil
}
@ -255,12 +257,15 @@ func (s *Server) Stop() {
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
v := s.visitor(r)
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
if log.IsTrace() {
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
v, err := s.visitor(r) // Note: Always returns v, even when error is returned
if err == nil {
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
if log.IsTrace() {
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
}
err = s.handleInternal(w, r, v)
}
if err := s.handleInternal(w, r, v); err != nil {
if err != nil {
if websocket.IsWebSocketUpgrade(r) {
isNormalError := strings.Contains(err.Error(), "i/o timeout")
if isNormalError {
@ -300,6 +305,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userAuthPath {
return s.handleUserAuth(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath {
return s.handleUserAccount(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
@ -394,6 +403,72 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
return nil
}
var sessions = make(map[string]*auth.User) // token-> user
type tokenAuthResponse struct {
Token string `json:"token"`
}
func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit
if v.user == nil {
return errHTTPUnauthorized
}
token := util.RandomString(32)
sessions[token] = v.user
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
response := &tokenAuthResponse{
Token: token,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
return err
}
return nil
}
type userSubscriptionResponse struct {
BaseURL string `json:"base_url"`
Topic string `json:"topic"`
}
type userAccountResponse struct {
Username string `json:"username"`
Role string `json:"role,omitempty"`
Language string `json:"language,omitempty"`
Plan struct {
Id int `json:"id"`
Name string `json:"name"`
} `json:"plan,omitempty"`
Notification struct {
Sound string `json:"sound"`
MinPriority string `json:"min_priority"`
DeleteAfter int `json:"delete_after"`
} `json:"notification,omitempty"`
Subscriptions []*userSubscriptionResponse `json:"subscriptions,omitempty"`
}
func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error {
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
var response *userAccountResponse
if v.user != nil {
response = &userAccountResponse{
Username: v.user.Name,
Role: string(v.user.Role),
Language: "en_US",
}
} else {
response = &userAccountResponse{
Username: "anonymous",
}
}
if err := json.NewEncoder(w).Encode(response); err != nil {
return err
}
return nil
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
@ -1221,7 +1296,7 @@ func (s *Server) runFirebaseKeepaliver() {
if s.firebaseClient == nil {
return
}
v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified()) // Background process, not a real visitor, uses IP 0.0.0.0
v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0
for {
select {
case <-time.After(s.config.FirebaseKeepaliveInterval):
@ -1253,7 +1328,7 @@ func (s *Server) sendDelayedMessages() error {
return err
}
for _, m := range messages {
v := s.visitorFromIP(m.Sender)
v := s.visitorFromID(fmt.Sprintf("ip:%s", m.Sender.String()), m.Sender, nil) // FIXME: This is wrong wrong wrong
if err := s.sendDelayedMessage(v, m); err != nil {
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
}
@ -1395,16 +1470,8 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
if err != nil {
return err
}
var user *auth.User // may stay nil if no auth header!
username, password, ok := extractUserPass(r)
if ok {
if user, err = s.auth.Authenticate(username, password); err != nil {
log.Info("authentication failed: %s", err.Error())
return errHTTPUnauthorized
}
}
for _, t := range topics {
if err := s.auth.Authorize(user, t.ID, perm); err != nil {
if err := s.auth.Authorize(v.user, t.ID, perm); err != nil {
log.Info("unauthorized: %s", err.Error())
return errHTTPForbidden
}
@ -1435,8 +1502,39 @@ func extractUserPass(r *http.Request) (username string, password string, ok bool
}
// visitor creates or retrieves a rate.Limiter for the given visitor.
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
func (s *Server) visitor(r *http.Request) *visitor {
// Note that this function will always return a visitor, even if an error occurs.
func (s *Server) visitor(r *http.Request) (v *visitor, err error) {
ip := s.extractIPAddress(r)
visitorID := fmt.Sprintf("ip:%s", ip.String())
var user *auth.User // may stay nil if no auth header!
username, password, ok := extractUserPass(r)
if ok {
if user, err = s.auth.Authenticate(username, password); err != nil {
log.Debug("authentication failed: %s", err.Error())
err = errHTTPUnauthorized // Always return visitor, even when error occurs!
} else {
visitorID = fmt.Sprintf("user:%s", user.Name)
}
}
v = s.visitorFromID(visitorID, ip, user)
v.user = user // Update user -- FIXME this is ugly, do "newVisitorFromUser" instead
return v, err // Always return visitor, even when error occurs!
}
func (s *Server) visitorFromID(visitorID string, ip netip.Addr, user *auth.User) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
v, exists := s.visitors[visitorID]
if !exists {
s.visitors[visitorID] = newVisitor(s.config, s.messageCache, ip, user)
return s.visitors[visitorID]
}
v.Keepalive()
return v
}
func (s *Server) extractIPAddress(r *http.Request) netip.Addr {
remoteAddr := r.RemoteAddr
addrPort, err := netip.ParseAddrPort(remoteAddr)
ip := addrPort.Addr()
@ -1461,17 +1559,5 @@ func (s *Server) visitor(r *http.Request) *visitor {
ip = realIP
}
}
return s.visitorFromIP(ip)
}
func (s *Server) visitorFromIP(ip netip.Addr) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
v, exists := s.visitors[ip]
if !exists {
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
return s.visitors[ip]
}
v.Keepalive()
return v
return ip
}

View file

@ -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)),

View file

@ -4,6 +4,6 @@
// The actual config is dynamically generated server-side.
var config = {
appRoot: "/",
appRoot: "/app",
disallowedTopics: ["docs", "static", "file", "app", "settings"]
};

View file

@ -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
View 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;

View file

@ -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',

View file

@ -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}`;

View file

@ -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;

View file

@ -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/>}/>

View 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
View 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;

View file

@ -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>

View file

@ -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;

View file

@ -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 }));

View file

@ -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",