Fully support auth in Web UI; persist users in localStorage (for now); add ugly ?auth=... param

This commit is contained in:
Philipp Heckel 2022-02-25 23:25:04 -05:00
parent 6d343c0f1a
commit 530f55c234
16 changed files with 237 additions and 72 deletions

View file

@ -862,7 +862,7 @@ func parseSince(r *http.Request, poll bool) (sinceTime, error) {
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error { func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth via JS
return nil return nil
} }
@ -1091,7 +1091,7 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
return err return err
} }
var user *auth.User // may stay nil if no auth header! var user *auth.User // may stay nil if no auth header!
username, password, ok := r.BasicAuth() username, password, ok := extractUserPass(r)
if ok { if ok {
if user, err = s.auth.Authenticate(username, password); err != nil { if user, err = s.auth.Authenticate(username, password); err != nil {
log.Printf("authentication failed: %s", err.Error()) log.Printf("authentication failed: %s", err.Error())
@ -1108,6 +1108,27 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
} }
} }
// extractUserPass reads the username/password from the basic auth header (Authorization: Basic ...),
// or from the ?auth=... query param. The latter is required only to support the WebSocket JavaScript
// class, which does not support passing headers during the initial request. The auth query param
// is effectively double base64 encoded. Its format is base64(Basic base64(user:pass)).
func extractUserPass(r *http.Request) (username string, password string, ok bool) {
username, password, ok = r.BasicAuth()
if ok {
return
}
authParam := readQueryParam(r, "authorization", "auth")
if authParam != "" {
a, err := base64.RawURLEncoding.DecodeString(authParam)
if err != nil {
return
}
r.Header.Set("Authorization", string(a))
return r.BasicAuth()
}
return
}
// visitor creates or retrieves a rate.Limiter for the given visitor. // visitor creates or retrieves a rate.Limiter for the given visitor.
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). // 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 { func (s *Server) visitor(r *http.Request) *visitor {

View file

@ -657,6 +657,25 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
require.Equal(t, 403, response.Code) // Anonymous read not allowed require.Equal(t, 403, response.Code) // Anonymous read not allowed
} }
func TestServer_Auth_ViaQuery(t *testing.T) {
c := newTestConfig(t)
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
c.AuthDefaultRead = false
c.AuthDefaultWrite = false
s := newTestServer(t, c)
manager := s.auth.(auth.Manager)
require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin))
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass"))))
response := request(t, s, "GET", u, "", nil)
require.Equal(t, 200, response.Code)
u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG"))))
response = request(t, s, "GET", u, "", nil)
require.Equal(t, 401, response.Code)
}
/* /*
func TestServer_Curl_Publish_Poll(t *testing.T) { func TestServer_Curl_Publish_Poll(t *testing.T) {
s, port := test.StartServer(t) s, port := test.StartServer(t)

View file

@ -14,12 +14,24 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
} }
func readParam(r *http.Request, names ...string) string { func readParam(r *http.Request, names ...string) string {
value := readHeaderParam(r, names...)
if value != "" {
return value
}
return readQueryParam(r, names...)
}
func readHeaderParam(r *http.Request, names ...string) string {
for _, name := range names { for _, name := range names {
value := r.Header.Get(name) value := r.Header.Get(name)
if value != "" { if value != "" {
return strings.TrimSpace(value) return strings.TrimSpace(value)
} }
} }
return ""
}
func readQueryParam(r *http.Request, names ...string) string {
for _, name := range names { for _, name := range names {
value := r.URL.Query().Get(strings.ToLower(name)) value := r.URL.Query().Get(strings.ToLower(name))
if value != "" { if value != "" {

View file

@ -1,31 +1,32 @@
import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth} from "./utils"; import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth, maybeWithBasicAuth} from "./utils";
class Api { class Api {
async poll(baseUrl, topic) { async poll(baseUrl, topic, user) {
const url = topicUrlJsonPoll(baseUrl, topic); const url = topicUrlJsonPoll(baseUrl, topic);
const messages = []; const messages = [];
const headers = maybeWithBasicAuth({}, user);
console.log(`[Api] Polling ${url}`); console.log(`[Api] Polling ${url}`);
for await (let line of fetchLinesIterator(url)) { for await (let line of fetchLinesIterator(url, headers)) {
messages.push(JSON.parse(line)); messages.push(JSON.parse(line));
} }
return messages; return messages;
} }
async publish(baseUrl, topic, message) { async publish(baseUrl, topic, user, message) {
const url = topicUrl(baseUrl, topic); const url = topicUrl(baseUrl, topic);
console.log(`[Api] Publishing message to ${url}`); console.log(`[Api] Publishing message to ${url}`);
await fetch(url, { await fetch(url, {
method: 'PUT', method: 'PUT',
body: message body: message,
headers: maybeWithBasicAuth({}, user)
}); });
} }
async auth(baseUrl, topic, user) { async auth(baseUrl, topic, user) {
const url = topicUrlAuth(baseUrl, topic); const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`); console.log(`[Api] Checking auth for ${url}`);
const headers = this.maybeAddAuthorization({}, user);
const response = await fetch(url, { const response = await fetch(url, {
headers: headers headers: maybeWithBasicAuth({}, user)
}); });
if (response.status >= 200 && response.status <= 299) { if (response.status >= 200 && response.status <= 299) {
return true; return true;
@ -36,14 +37,6 @@ class Api {
} }
throw new Error(`Unexpected server response ${response.status}`); throw new Error(`Unexpected server response ${response.status}`);
} }
maybeAddAuthorization(headers, user) {
if (user) {
const encoded = new Buffer(`${user.username}:${user.password}`).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
}
return headers;
}
} }
const api = new Api(); const api = new Api();

View file

@ -1,14 +1,15 @@
import {shortTopicUrl, topicUrlWs, topicUrlWsWithSince} from "./utils"; import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
const retryBackoffSeconds = [5, 10, 15, 20, 30, 45]; const retryBackoffSeconds = [5, 10, 15, 20, 30, 45];
class Connection { class Connection {
constructor(subscriptionId, baseUrl, topic, since, onNotification) { constructor(subscriptionId, baseUrl, topic, user, since, onNotification) {
this.subscriptionId = subscriptionId; this.subscriptionId = subscriptionId;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.topic = topic; this.topic = topic;
this.user = user;
this.since = since; this.since = since;
this.shortUrl = shortTopicUrl(baseUrl, topic); this.shortUrl = topicShortUrl(baseUrl, topic);
this.onNotification = onNotification; this.onNotification = onNotification;
this.ws = null; this.ws = null;
this.retryCount = 0; this.retryCount = 0;
@ -18,10 +19,10 @@ class Connection {
start() { start() {
// Don't fetch old messages; we do that as a poll() when adding a subscription; // Don't fetch old messages; we do that as a poll() when adding a subscription;
// we don't want to re-trigger the main view re-render potentially hundreds of times. // we don't want to re-trigger the main view re-render potentially hundreds of times.
const wsUrl = (this.since === 0)
? topicUrlWs(this.baseUrl, this.topic) const wsUrl = this.wsUrl();
: topicUrlWsWithSince(this.baseUrl, this.topic, this.since.toString());
console.log(`[Connection, ${this.shortUrl}] Opening connection to ${wsUrl}`); console.log(`[Connection, ${this.shortUrl}] Opening connection to ${wsUrl}`);
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => { this.ws.onopen = (event) => {
console.log(`[Connection, ${this.shortUrl}] Connection established`, event); console.log(`[Connection, ${this.shortUrl}] Connection established`, event);
@ -75,6 +76,19 @@ class Connection {
this.retryTimeout = null; this.retryTimeout = null;
this.ws = null; this.ws = null;
} }
wsUrl() {
const params = [];
if (this.since > 0) {
params.push(`since=${this.since.toString()}`);
}
if (this.user !== null) {
const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password));
params.push(`auth=${auth}`);
}
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
}
} }
export default Connection; export default Connection;

View file

@ -1,11 +1,11 @@
import Connection from "./Connection"; import Connection from "./Connection";
export class ConnectionManager { class ConnectionManager {
constructor() { constructor() {
this.connections = new Map(); this.connections = new Map();
} }
refresh(subscriptions, onNotification) { refresh(subscriptions, users, onNotification) {
console.log(`[ConnectionManager] Refreshing connections`); console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionIds = subscriptions.ids(); const subscriptionIds = subscriptions.ids();
const deletedIds = Array.from(this.connections.keys()).filter(id => !subscriptionIds.includes(id)); const deletedIds = Array.from(this.connections.keys()).filter(id => !subscriptionIds.includes(id));
@ -16,8 +16,9 @@ export class ConnectionManager {
if (added) { if (added) {
const baseUrl = subscription.baseUrl; const baseUrl = subscription.baseUrl;
const topic = subscription.topic; const topic = subscription.topic;
const user = users.get(baseUrl);
const since = 0; const since = 0;
const connection = new Connection(id, baseUrl, topic, since, onNotification); const connection = new Connection(id, baseUrl, topic, user, since, onNotification);
this.connections.set(id, connection); this.connections.set(id, connection);
console.log(`[ConnectionManager] Starting new connection ${id}`); console.log(`[ConnectionManager] Starting new connection ${id}`);
connection.start(); connection.start();

View file

@ -1,7 +1,9 @@
import Subscription from "./Subscription"; import Subscription from "./Subscription";
import Subscriptions from "./Subscriptions"; import Subscriptions from "./Subscriptions";
import Users from "./Users";
import User from "./User";
export class Repository { class Repository {
loadSubscriptions() { loadSubscriptions() {
console.log(`[Repository] Loading subscriptions from localStorage`); console.log(`[Repository] Loading subscriptions from localStorage`);
const subscriptions = new Subscriptions(); const subscriptions = new Subscriptions();
@ -10,8 +12,7 @@ export class Repository {
return subscriptions; return subscriptions;
} }
try { try {
const serializedSubscriptions = JSON.parse(serialized); JSON.parse(serialized).forEach(s => {
serializedSubscriptions.forEach(s => {
const subscription = new Subscription(s.baseUrl, s.topic); const subscription = new Subscription(s.baseUrl, s.topic);
subscription.addNotifications(s.notifications); subscription.addNotifications(s.notifications);
subscriptions.add(subscription); subscriptions.add(subscription);
@ -39,26 +40,32 @@ export class Repository {
loadUsers() { loadUsers() {
console.log(`[Repository] Loading users from localStorage`); console.log(`[Repository] Loading users from localStorage`);
const users = new Users();
const serialized = localStorage.getItem('users'); const serialized = localStorage.getItem('users');
if (serialized === null) { if (serialized === null) {
return {}; return users;
} }
try { try {
return JSON.parse(serialized); JSON.parse(serialized).forEach(u => {
users.add(new User(u.baseUrl, u.username, u.password));
});
return users;
} catch (e) { } catch (e) {
console.log(`[Repository] Unable to deserialize users: ${e.message}`); console.log(`[Repository] Unable to deserialize users: ${e.message}`);
return {}; return users;
} }
} }
saveUser(baseUrl, username, password) { saveUsers(users) {
console.log(`[Repository] Saving users to localStorage`); console.log(`[Repository] Saving users to localStorage`);
const users = this.loadUsers(); const serialized = JSON.stringify(users.map(user => {
users[baseUrl] = { return {
username: username, baseUrl: user.baseUrl,
password: password username: user.username,
}; password: user.password
localStorage.setItem('users', users); }
}));
localStorage.setItem('users', serialized);
} }
} }

View file

@ -1,6 +1,6 @@
import {shortTopicUrl, topicUrl} from './utils'; import {topicShortUrl, topicUrl} from './utils';
export default class Subscription { class Subscription {
constructor(baseUrl, topic) { constructor(baseUrl, topic) {
this.id = topicUrl(baseUrl, topic); this.id = topicUrl(baseUrl, topic);
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
@ -40,6 +40,8 @@ export default class Subscription {
} }
shortUrl() { shortUrl() {
return shortTopicUrl(this.baseUrl, this.topic); return topicShortUrl(this.baseUrl, this.topic);
} }
} }
export default Subscription;

9
web/src/app/User.js Normal file
View file

@ -0,0 +1,9 @@
class User {
constructor(baseUrl, username, password) {
this.baseUrl = baseUrl;
this.username = username;
this.password = password;
}
}
export default User;

36
web/src/app/Users.js Normal file
View file

@ -0,0 +1,36 @@
class Users {
constructor() {
this.users = new Map();
}
add(user) {
this.users.set(user.baseUrl, user);
return this;
}
get(baseUrl) {
const user = this.users.get(baseUrl);
return (user) ? user : null;
}
update(user) {
return this.add(user);
}
remove(baseUrl) {
this.users.delete(baseUrl);
return this;
}
map(cb) {
return Array.from(this.users.values()).map(cb);
}
clone() {
const c = new Users();
c.users = new Map(this.users);
return c;
}
}
export default Users;

View file

@ -4,12 +4,11 @@ export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws` export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
.replaceAll("https://", "wss://") .replaceAll("https://", "wss://")
.replaceAll("http://", "ws://"); .replaceAll("http://", "ws://");
export const topicUrlWsWithSince = (baseUrl, topic, since) => `${topicUrlWs(baseUrl, topic)}?since=${since}`;
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const shortTopicUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
// Format emojis (see emoji.js) // Format emojis (see emoji.js)
const emojis = {}; const emojis = {};
@ -51,10 +50,35 @@ export const unmatchedTags = (tags) => {
else return tags.filter(tag => !(tag in emojis)); else return tags.filter(tag => !(tag in emojis));
} }
export const maybeWithBasicAuth = (headers, user) => {
if (user) {
headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`;
}
return headers;
}
export const basicAuth = (username, password) => {
return `Basic ${encodeBase64(`${username}:${password}`)}`;
}
export const encodeBase64 = (s) => {
return new Buffer(s).toString('base64');
}
export const encodeBase64Url = (s) => {
return encodeBase64(s)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '');
}
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
export async function* fetchLinesIterator(fileURL) { export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder('utf-8'); const utf8Decoder = new TextDecoder('utf-8');
const response = await fetch(fileURL); const response = await fetch(fileURL, {
headers: headers
});
const reader = response.body.getReader(); const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read(); let { value: chunk, done: readerDone } = await reader.read();
chunk = chunk ? utf8Decoder.decode(chunk) : ''; chunk = chunk ? utf8Decoder.decode(chunk) : '';

View file

@ -25,6 +25,7 @@ const ActionBar = (props) => {
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>{title}</Typography> <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>{title}</Typography>
{props.selectedSubscription !== null && <IconSubscribeSettings {props.selectedSubscription !== null && <IconSubscribeSettings
subscription={props.selectedSubscription} subscription={props.selectedSubscription}
users={props.users}
onClearAll={props.onClearAll} onClearAll={props.onClearAll}
onUnsubscribe={props.onUnsubscribe} onUnsubscribe={props.onUnsubscribe}
/>} />}

View file

@ -12,12 +12,14 @@ import connectionManager from "../app/ConnectionManager";
import Subscriptions from "../app/Subscriptions"; import Subscriptions from "../app/Subscriptions";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
import ActionBar from "./ActionBar"; import ActionBar from "./ActionBar";
import Users from "../app/Users";
const App = () => { const App = () => {
console.log(`[App] Rendering main view`); console.log(`[App] Rendering main view`);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [subscriptions, setSubscriptions] = useState(new Subscriptions()); const [subscriptions, setSubscriptions] = useState(new Subscriptions());
const [users, setUsers] = useState(new Users());
const [selectedSubscription, setSelectedSubscription] = useState(null); const [selectedSubscription, setSelectedSubscription] = useState(null);
const handleNotification = (subscriptionId, notification) => { const handleNotification = (subscriptionId, notification) => {
setSubscriptions(prev => { setSubscriptions(prev => {
@ -25,11 +27,14 @@ const App = () => {
return prev.update(newSubscription).clone(); return prev.update(newSubscription).clone();
}); });
}; };
const handleSubscribeSubmit = (subscription) => { const handleSubscribeSubmit = (subscription, user) => {
console.log(`[App] New subscription: ${subscription.id}`); console.log(`[App] New subscription: ${subscription.id}`);
if (user !== null) {
setUsers(prev => prev.add(user).clone());
}
setSubscriptions(prev => prev.add(subscription).clone()); setSubscriptions(prev => prev.add(subscription).clone());
setSelectedSubscription(subscription); setSelectedSubscription(subscription);
api.poll(subscription.baseUrl, subscription.topic) api.poll(subscription.baseUrl, subscription.topic, user)
.then(messages => { .then(messages => {
setSubscriptions(prev => { setSubscriptions(prev => {
const newSubscription = prev.get(subscription.id).addNotifications(messages); const newSubscription = prev.get(subscription.id).addNotifications(messages);
@ -61,12 +66,13 @@ const App = () => {
}; };
useEffect(() => { useEffect(() => {
setSubscriptions(repository.loadSubscriptions()); setSubscriptions(repository.loadSubscriptions());
setUsers(repository.loadUsers());
}, [/* initial render only */]); }, [/* initial render only */]);
useEffect(() => { useEffect(() => {
connectionManager.refresh(subscriptions, handleNotification); connectionManager.refresh(subscriptions, users, handleNotification);
repository.saveSubscriptions(subscriptions); repository.saveSubscriptions(subscriptions);
}, [subscriptions]); repository.saveUsers(users);
}, [subscriptions, users]);
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline/> <CssBaseline/>
@ -74,6 +80,7 @@ const App = () => {
<CssBaseline/> <CssBaseline/>
<ActionBar <ActionBar
selectedSubscription={selectedSubscription} selectedSubscription={selectedSubscription}
users={users}
onClearAll={handleDeleteAllNotifications} onClearAll={handleDeleteAllNotifications}
onUnsubscribe={handleUnsubscribe} onUnsubscribe={handleUnsubscribe}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}

View file

@ -14,6 +14,7 @@ import api from "../app/Api";
const IconSubscribeSettings = (props) => { const IconSubscribeSettings = (props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const anchorRef = useRef(null); const anchorRef = useRef(null);
const users = props.users;
const handleToggle = () => { const handleToggle = () => {
setOpen((prevOpen) => !prevOpen); setOpen((prevOpen) => !prevOpen);
@ -39,7 +40,9 @@ const IconSubscribeSettings = (props) => {
const handleSendTestMessage = () => { const handleSendTestMessage = () => {
const baseUrl = props.subscription.baseUrl; const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic; const topic = props.subscription.topic;
api.publish(baseUrl, topic, `This is a test notification sent by the ntfy Web UI at ${new Date().toString()}.`); // FIXME result ignored const user = users.get(baseUrl); // May be null
api.publish(baseUrl, topic, user,
`This is a test notification sent by the ntfy Web UI at ${new Date().toString()}.`); // FIXME result ignored
setOpen(false); setOpen(false);
} }

View file

@ -54,10 +54,15 @@ const Navigation = (props) => {
Navigation.width = navWidth; Navigation.width = navWidth;
const NavList = (props) => { const NavList = (props) => {
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const handleSubscribeSubmit = (subscription) => { const handleSubscribeReset = () => {
setSubscribeDialogOpen(false); setSubscribeDialogOpen(false);
props.onSubscribeSubmit(subscription); setSubscribeDialogKey(prev => prev+1);
}
const handleSubscribeSubmit = (subscription, user) => {
handleSubscribeReset();
props.onSubscribeSubmit(subscription, user);
} }
return ( return (
<> <>
@ -85,13 +90,15 @@ const NavList = (props) => {
</ListItemButton> </ListItemButton>
</List> </List>
<SubscribeDialog <SubscribeDialog
key={subscribeDialogKey} // Resets dialog when canceled/closed
open={subscribeDialogOpen} open={subscribeDialogOpen}
onCancel={() => setSubscribeDialogOpen(false)} onCancel={handleSubscribeReset}
onSubmit={handleSubscribeSubmit} onSuccess={handleSubscribeSubmit}
/> />
</> </>
); );
}; };
const NavSubscriptionList = (props) => { const NavSubscriptionList = (props) => {
const subscriptions = props.subscriptions; const subscriptions = props.subscriptions;
return ( return (

View file

@ -13,6 +13,7 @@ import theme from "./theme";
import api from "../app/Api"; import api from "../app/Api";
import {topicUrl} from "../app/utils"; import {topicUrl} from "../app/utils";
import useStyles from "./styles"; import useStyles from "./styles";
import User from "../app/User";
const defaultBaseUrl = "http://127.0.0.1" const defaultBaseUrl = "http://127.0.0.1"
//const defaultBaseUrl = "https://ntfy.sh" //const defaultBaseUrl = "https://ntfy.sh"
@ -20,43 +21,50 @@ const defaultBaseUrl = "http://127.0.0.1"
const SubscribeDialog = (props) => { const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl); // FIXME const [baseUrl, setBaseUrl] = useState(defaultBaseUrl); // FIXME
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [user, setUser] = useState(null);
const [showLoginPage, setShowLoginPage] = useState(false); const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleCancel = () => { const handleCancel = () => {
setTopic(''); setTopic('');
props.onCancel(); props.onCancel();
} }
const handleSubmit = async () => { const handleSuccess = (baseUrl, topic, user) => {
const success = await api.auth(baseUrl, topic, null); const subscription = new Subscription(baseUrl, topic);
if (!success) { props.onSuccess(subscription, user);
console.log(`[SubscribeDialog] Login required for ${topicUrl(baseUrl, topic)}`)
setShowLoginPage(true);
return;
}
const subscription = new Subscription(defaultBaseUrl, topic);
props.onSubmit(subscription);
setTopic(''); setTopic('');
} }
return ( return (
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
{!showLoginPage && <SubscribePage {!showLoginPage && <SubscribePage
baseUrl={baseUrl}
topic={topic} topic={topic}
setTopic={setTopic} setTopic={setTopic}
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={handleSubmit} onNeedsLogin={() => setShowLoginPage(true)}
onSuccess={handleSuccess}
/>} />}
{showLoginPage && <LoginPage {showLoginPage && <LoginPage
baseUrl={baseUrl} baseUrl={baseUrl}
topic={topic} topic={topic}
onBack={() => setShowLoginPage(false)} onBack={() => setShowLoginPage(false)}
onSubmit={handleSubmit} onSuccess={handleSuccess}
/>} />}
</Dialog> </Dialog>
); );
}; };
const SubscribePage = (props) => { const SubscribePage = (props) => {
const baseUrl = props.baseUrl;
const topic = props.topic;
const handleSubscribe = async () => {
const success = await api.auth(baseUrl, topic, null);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for anonymous user`);
props.onNeedsLogin();
return;
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for anonymous user`);
props.onSuccess(baseUrl, topic, null);
};
return ( return (
<> <>
<DialogTitle>Subscribe to topic</DialogTitle> <DialogTitle>Subscribe to topic</DialogTitle>
@ -79,7 +87,7 @@ const SubscribePage = (props) => {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={props.onCancel}>Cancel</Button> <Button onClick={props.onCancel}>Cancel</Button>
<Button onClick={props.onSubmit} disabled={props.topic === ""}>Subscribe</Button> <Button onClick={handleSubscribe} disabled={props.topic === ""}>Subscribe</Button>
</DialogActions> </DialogActions>
</> </>
); );
@ -93,14 +101,15 @@ const LoginPage = (props) => {
const baseUrl = props.baseUrl; const baseUrl = props.baseUrl;
const topic = props.topic; const topic = props.topic;
const handleLogin = async () => { const handleLogin = async () => {
const user = {username: username, password: password}; const user = new User(baseUrl, username, password);
const success = await api.auth(baseUrl, topic, user); const success = await api.auth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setErrorText(`User ${username} not authorized`); setErrorText(`User ${username} not authorized`);
return; return;
} }
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} successful for user ${username}`); console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
props.onSuccess(baseUrl, topic, user);
}; };
return ( return (
<> <>