Figure out user manager for account user

This commit is contained in:
binwiederhier 2022-12-26 21:27:07 -05:00
parent 3492558e06
commit 95a8e64fbb
16 changed files with 152 additions and 106 deletions

View file

@ -36,14 +36,19 @@ import (
/* /*
TODO TODO
use token auth in "SubscribeDialog"
upload files based on user limit
database migration database migration
publishXHR + poll should pick current user, not from userManager
reserve topics reserve topics
purge accounts that were not logged into in X purge accounts that were not logged into in X
reset daily limits for users reset daily limits for users
store users "user list" shows * twice
"ntfy access everyone user4topic <bla>" twice -> UNIQUE constraint error
Account usage not updated "in real time"
Sync:
- "mute" setting
- figure out what settings are "web" or "phone"
UI:
- Subscription dotmenu dropdown: Move to nav bar, or make same as profile dropdown
- "Logout and delete local storage" option
Pages: Pages:
- Home - Home
- Password reset - Password reset
@ -52,7 +57,6 @@ import (
- -
Polishing: Polishing:
aria-label for everything aria-label for everything
Tests: Tests:
- APIs - APIs
- CRUD tokens - CRUD tokens

View file

@ -84,10 +84,10 @@ const (
` `
selectTopicPermsQuery = ` selectTopicPermsQuery = `
SELECT read, write SELECT read, write
FROM user_access FROM user_access a
JOIN user ON user.user = '*' OR user.user = ? JOIN user u ON u.id = a.user_id
WHERE ? LIKE user_access.topic WHERE (u.user = '*' OR u.user = ?) AND ? LIKE a.topic
ORDER BY user.user DESC ORDER BY u.user DESC
` `
) )

View file

@ -175,6 +175,7 @@
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month", "prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
"prefs_users_title": "Manage users", "prefs_users_title": "Manage users",
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.", "prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
"prefs_users_description_no_sync": "Users and passwords are not synchronized to your account.",
"prefs_users_table": "Users table", "prefs_users_table": "Users table",
"prefs_users_add_button": "Add user", "prefs_users_add_button": "Add user",
"prefs_users_edit_button": "Edit user", "prefs_users_edit_button": "Edit user",

View file

@ -6,8 +6,8 @@ import {
accountTokenUrl, accountTokenUrl,
accountUrl, accountUrl,
fetchLinesIterator, fetchLinesIterator,
maybeWithBasicAuth, withBasicAuth,
maybeWithBearerAuth, withBearerAuth,
topicShortUrl, topicShortUrl,
topicUrl, topicUrl,
topicUrlAuth, topicUrlAuth,
@ -31,7 +31,7 @@ class AccountApi {
console.log(`[AccountApi] Checking auth for ${url}`); console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: maybeWithBasicAuth({}, user) headers: withBasicAuth({}, user.username, user.password)
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();
@ -50,7 +50,7 @@ class AccountApi {
console.log(`[AccountApi] Logging out from ${url} using token ${token}`); console.log(`[AccountApi] Logging out from ${url} using token ${token}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
headers: maybeWithBearerAuth({}, token) headers: withBearerAuth({}, token)
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();
@ -83,7 +83,7 @@ class AccountApi {
const url = accountUrl(config.baseUrl); const url = accountUrl(config.baseUrl);
console.log(`[AccountApi] Fetching user account ${url}`); console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
headers: maybeWithBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token())
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();
@ -100,7 +100,7 @@ class AccountApi {
console.log(`[AccountApi] Deleting user account ${url}`); console.log(`[AccountApi] Deleting user account ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
headers: maybeWithBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token())
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();
@ -114,7 +114,7 @@ class AccountApi {
console.log(`[AccountApi] Changing account password ${url}`); console.log(`[AccountApi] Changing account password ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: maybeWithBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
password: newPassword password: newPassword
}) })
@ -131,7 +131,7 @@ class AccountApi {
console.log(`[AccountApi] Extending user access token ${url}`); console.log(`[AccountApi] Extending user access token ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "PATCH", method: "PATCH",
headers: maybeWithBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token())
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();
@ -146,7 +146,7 @@ class AccountApi {
console.log(`[AccountApi] Updating user account ${url}: ${body}`); console.log(`[AccountApi] Updating user account ${url}: ${body}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "PATCH", method: "PATCH",
headers: maybeWithBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
@ -162,7 +162,7 @@ class AccountApi {
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: maybeWithBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
@ -181,7 +181,7 @@ class AccountApi {
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "PATCH", method: "PATCH",
headers: maybeWithBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
@ -199,7 +199,7 @@ class AccountApi {
console.log(`[AccountApi] Removing user subscription ${url}`); console.log(`[AccountApi] Removing user subscription ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
headers: maybeWithBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token())
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();
@ -208,6 +208,10 @@ class AccountApi {
} }
} }
sync() {
// TODO
}
startWorker() { startWorker() {
if (this.timer !== null) { if (this.timer !== null) {
return; return;

View file

@ -5,9 +5,9 @@ import {
accountSubscriptionUrl, accountSubscriptionUrl,
accountTokenUrl, accountTokenUrl,
accountUrl, accountUrl,
fetchLinesIterator, fetchLinesIterator, maybeWithAuth,
maybeWithBasicAuth, withBasicAuth,
maybeWithBearerAuth, withBearerAuth,
topicShortUrl, topicShortUrl,
topicUrl, topicUrl,
topicUrlAuth, topicUrlAuth,
@ -24,7 +24,7 @@ class Api {
? topicUrlJsonPollWithSince(baseUrl, topic, since) ? topicUrlJsonPollWithSince(baseUrl, topic, since)
: topicUrlJsonPoll(baseUrl, topic); : topicUrlJsonPoll(baseUrl, topic);
const messages = []; const messages = [];
const headers = maybeWithBasicAuth({}, user); const headers = maybeWithAuth({}, user);
console.log(`[Api] Polling ${url}`); console.log(`[Api] Polling ${url}`);
for await (let line of fetchLinesIterator(url, headers)) { for await (let line of fetchLinesIterator(url, headers)) {
console.log(`[Api, ${shortUrl}] Received message ${line}`); console.log(`[Api, ${shortUrl}] Received message ${line}`);
@ -45,7 +45,7 @@ class Api {
const response = await fetch(baseUrl, { const response = await fetch(baseUrl, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
headers: maybeWithBasicAuth(headers, user) headers: maybeWithAuth(headers, user)
}); });
if (response.status < 200 || response.status > 299) { if (response.status < 200 || response.status > 299) {
throw new Error(`Unexpected response: ${response.status}`); throw new Error(`Unexpected response: ${response.status}`);
@ -111,7 +111,7 @@ class Api {
const url = topicUrlAuth(baseUrl, topic); const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`); console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
headers: maybeWithBasicAuth({}, user) headers: maybeWithAuth({}, user)
}); });
if (response.status >= 200 && response.status <= 299) { if (response.status >= 200 && response.status <= 299) {
return true; return true;

View file

@ -1,4 +1,4 @@
import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
const retryBackoffSeconds = [5, 10, 15, 20, 30]; const retryBackoffSeconds = [5, 10, 15, 20, 30];
@ -96,12 +96,18 @@ class Connection {
params.push(`since=${this.since}`); params.push(`since=${this.since}`);
} }
if (this.user) { if (this.user) {
const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password)); params.push(`auth=${this.authParam()}`);
params.push(`auth=${auth}`);
} }
const wsUrl = topicUrlWs(this.baseUrl, this.topic); const wsUrl = topicUrlWs(this.baseUrl, this.topic);
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`; return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
} }
authParam() {
if (this.user.password) {
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
}
return encodeBase64Url(bearerAuth(this.user.token));
}
} }
export class ConnectionState { export class ConnectionState {

View file

@ -109,7 +109,7 @@ class ConnectionManager {
const makeConnectionId = async (subscription, user) => { const makeConnectionId = async (subscription, user) => {
return (user) return (user)
? hashCode(`${subscription.id}|${user.username}|${user.password}`) ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`)
: hashCode(`${subscription.id}`); : hashCode(`${subscription.id}`);
} }

View file

@ -1,21 +1,46 @@
import db from "./db"; import db from "./db";
import session from "./Session";
class UserManager { class UserManager {
async all() { async all() {
return db.users.toArray(); const users = await db.users.toArray();
if (session.exists()) {
users.unshift(this.localUser());
}
return users;
} }
async get(baseUrl) { async get(baseUrl) {
if (session.exists() && baseUrl === config.baseUrl) {
return this.localUser();
}
return db.users.get(baseUrl); return db.users.get(baseUrl);
} }
async save(user) { async save(user) {
if (user.baseUrl === config.baseUrl) {
return;
}
await db.users.put(user); await db.users.put(user);
} }
async delete(baseUrl) { async delete(baseUrl) {
if (session.exists() && baseUrl === config.baseUrl) {
return;
}
await db.users.delete(baseUrl); await db.users.delete(baseUrl);
} }
localUser() {
if (!session.exists()) {
return null;
}
return {
baseUrl: config.baseUrl,
username: session.username(),
token: session.token() // Not "password"!
};
}
} }
const userManager = new UserManager(); const userManager = new UserManager();

View file

@ -99,17 +99,17 @@ 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) => { export const maybeWithAuth = (headers, user) => {
if (user) { if (user && user.password) {
headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`; return withBasicAuth(headers, user.username, user.password);
} else if (user && user.token) {
return withBearerAuth(headers, user.token);
} }
return headers; return headers;
} }
export const maybeWithBearerAuth = (headers, token) => { export const withBasicAuth = (headers, username, password) => {
if (token) { headers['Authorization'] = basicAuth(username, password);
headers['Authorization'] = `Bearer ${token}`;
}
return headers; return headers;
} }
@ -117,6 +117,15 @@ export const basicAuth = (username, password) => {
return `Basic ${encodeBase64(`${username}:${password}`)}`; return `Basic ${encodeBase64(`${username}:${password}`)}`;
} }
export const withBearerAuth = (headers, token) => {
headers['Authorization'] = bearerAuth(token);
return headers;
}
export const bearerAuth = (token) => {
return `Bearer ${token}`;
}
export const encodeBase64 = (s) => { export const encodeBase64 = (s) => {
return Base64.encode(s); return Base64.encode(s);
} }

View file

@ -88,7 +88,7 @@ const Stats = () => {
</div> </div>
<LinearProgress variant="determinate" value={account.limits.emails > 0 ? normalize(account.stats.emails, account.limits.emails) : 100} /> <LinearProgress variant="determinate" value={account.limits.emails > 0 ? normalize(account.stats.emails, account.limits.emails) : 100} />
</Pref> </Pref>
<Pref labelId={"attachments"} title={t("Attachment storage")} subtitle={t("5 MB per file")}> <Pref labelId={"attachments"} title={t("Attachment storage")} subtitle={t("{{filesize}} per file", { filesize: formatBytes(account.limits.attachment_file_size) })}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography> <Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("of {{limit}}", { limit: formatBytes(account.limits.attachment_total_size) }) : t("Unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("of {{limit}}", { limit: formatBytes(account.limits.attachment_total_size) }) : t("Unlimited")}</Typography>
@ -153,8 +153,7 @@ const ChangePassword = () => {
} catch (e) { } catch (e) {
console.log(`[Account] Error changing password`, e); console.log(`[Account] Error changing password`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.reset(); session.resetAndRedirect(routes.login);
window.location.href = routes.login;
} }
// TODO show error // TODO show error
} }
@ -238,13 +237,11 @@ const DeleteAccount = () => {
setDialogOpen(false); setDialogOpen(false);
console.debug(`[Account] Account deleted`); console.debug(`[Account] Account deleted`);
// TODO delete local storage // TODO delete local storage
session.reset(); session.resetAndRedirect(routes.app);
window.location.href = routes.app;
} catch (e) { } catch (e) {
console.log(`[Account] Error deleting account`, e); console.log(`[Account] Error deleting account`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.reset(); session.resetAndRedirect(routes.login);
window.location.href = routes.login;
} }
// TODO show error // TODO show error
} }

View file

@ -124,8 +124,7 @@ const SettingsIcons = (props) => {
} catch (e) { } catch (e) {
console.log(`[ActionBar] Error unsubscribing`, e); console.log(`[ActionBar] Error unsubscribing`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.reset(); session.resetAndRedirect(routes.login);
window.location.href = routes.login;
} }
} }
} }
@ -272,8 +271,7 @@ const ProfileIcon = (props) => {
try { try {
await accountApi.logout(); await accountApi.logout();
} finally { } finally {
session.reset(); session.resetAndRedirect(routes.app);
window.location.href = routes.app;
} }
}; };
return ( return (

View file

@ -123,8 +123,7 @@ const Layout = () => {
} catch (e) { } catch (e) {
console.log(`[App] Error fetching account`, e); console.log(`[App] Error fetching account`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.reset(); session.resetAndRedirect(routes.login);
window.location.href = routes.login;
} }
} }
})(); })();

View file

@ -270,6 +270,7 @@ const Users = () => {
</Typography> </Typography>
<Paragraph> <Paragraph>
{t("prefs_users_description")} {t("prefs_users_description")}
{session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>}
</Paragraph> </Paragraph>
{users?.length > 0 && <UserTable users={users}/>} {users?.length > 0 && <UserTable users={users}/>}
</CardContent> </CardContent>
@ -319,7 +320,6 @@ const UserTable = (props) => {
} }
}; };
return ( return (
<div>
<Table size="small" aria-label={t("prefs_users_table")}> <Table size="small" aria-label={t("prefs_users_table")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
@ -338,6 +338,8 @@ const UserTable = (props) => {
aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell> aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell> <TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right"> <TableCell align="right">
{user.baseUrl !== config.baseUrl &&
<>
<IconButton onClick={() => handleEditClick(user)} <IconButton onClick={() => handleEditClick(user)}
aria-label={t("prefs_users_edit_button")}> aria-label={t("prefs_users_edit_button")}>
<EditIcon/> <EditIcon/>
@ -346,6 +348,8 @@ const UserTable = (props) => {
aria-label={t("prefs_users_delete_button")}> aria-label={t("prefs_users_delete_button")}>
<CloseIcon/> <CloseIcon/>
</IconButton> </IconButton>
</>
}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -359,12 +363,6 @@ const UserTable = (props) => {
onSubmit={handleDialogSubmit} onSubmit={handleDialogSubmit}
/> />
</Table> </Table>
{session.exists() &&
<Typography>
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</Typography>
}
</div>
); );
}; };

View file

@ -17,7 +17,15 @@ import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon'; import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {Close} from "@mui/icons-material"; import {Close} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import {formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; import {
formatBytes,
maybeWithAuth,
withBasicAuth,
topicShortUrl,
topicUrl,
validTopic,
validUrl
} from "../app/utils";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
@ -132,7 +140,7 @@ const PublishDialog = (props) => {
const body = (attachFile) ? attachFile : message; const body = (attachFile) ? attachFile : message;
try { try {
const user = await userManager.get(baseUrl); const user = await userManager.get(baseUrl);
const headers = maybeWithBasicAuth({}, user); const headers = maybeWithAuth({}, user);
const progressFn = (ev) => { const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) { if (ev.loaded > 0 && ev.total > 0) {
setStatus(t("publish_dialog_progress_uploading_detail", { setStatus(t("publish_dialog_progress_uploading_detail", {
@ -180,8 +188,7 @@ const PublishDialog = (props) => {
} catch (e) { } catch (e) {
console.log(`[PublishDialog] Retrieving attachment limits failed`, e); console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.reset(); session.resetAndRedirect(routes.login);
window.location.href = routes.login;
} else { } else {
setAttachFileError(""); // Reset error (rely on server-side checking) setAttachFileError(""); // Reset error (rely on server-side checking)
} }

View file

@ -40,8 +40,7 @@ const SubscribeDialog = (props) => {
} catch (e) { } catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.reset(); session.resetAndRedirect(routes.login);
window.location.href = routes.login;
} }
} }
} }

View file

@ -74,8 +74,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
} catch (e) { } catch (e) {
console.log(`[App] Auto-subscribing failed`, e); console.log(`[App] Auto-subscribing failed`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.reset(); session.resetAndRedirect(routes.login);
window.location.href = routes.login;
} }
} }
} }