mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-12 06:45:20 +00:00
Merge branch 'main' of github.com:binwiederhier/ntfy into ng/pwa-token-extend
This commit is contained in:
@@ -2,6 +2,8 @@ import i18n from "i18next";
|
||||
import {
|
||||
accountBillingPortalUrl,
|
||||
accountBillingSubscriptionUrl,
|
||||
accountEmailUrl,
|
||||
accountEmailVerifyUrl,
|
||||
accountPasswordUrl,
|
||||
accountPhoneUrl,
|
||||
accountPhoneVerifyUrl,
|
||||
@@ -135,8 +137,8 @@ class AccountApi {
|
||||
token,
|
||||
label,
|
||||
};
|
||||
if (expires > 0) {
|
||||
body.expires = Math.floor(Date.now() / 1000) + expires;
|
||||
if (expires >= 0) {
|
||||
body.expires = expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0;
|
||||
}
|
||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
@@ -340,6 +342,43 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
async verifyEmail(email) {
|
||||
const url = accountEmailVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Sending email verification ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async addEmail(email, code) {
|
||||
const url = accountEmailUrl(config.base_url);
|
||||
console.log(`[AccountApi] Adding email with verification code ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmail(email) {
|
||||
const url = accountEmailUrl(config.base_url);
|
||||
console.log(`[AccountApi] Deleting email ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
try {
|
||||
if (!session.token()) {
|
||||
|
||||
@@ -47,6 +47,14 @@ export class IncorrectPasswordError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailVerificationCodeInvalidError extends Error {
|
||||
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
|
||||
constructor() {
|
||||
super("Email verification code invalid or expired");
|
||||
}
|
||||
}
|
||||
|
||||
export const throwAppError = async (response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.log(`[Error] HTTP ${response.status}`, response);
|
||||
@@ -63,6 +71,8 @@ export const throwAppError = async (response) => {
|
||||
throw new AccountCreateLimitReachedError();
|
||||
} else if (error.code === IncorrectPasswordError.CODE) {
|
||||
throw new IncorrectPasswordError();
|
||||
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
|
||||
throw new EmailVerificationCodeInvalidError();
|
||||
} else if (error?.error) {
|
||||
throw new Error(`Error ${error.code}: ${error.error}`);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const formatMessage = (m) => {
|
||||
return m.message || "";
|
||||
};
|
||||
|
||||
const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
|
||||
export const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
|
||||
export const isImage = (attachment) => {
|
||||
if (!attachment) return false;
|
||||
|
||||
|
||||
+38
-5
@@ -1,4 +1,3 @@
|
||||
import { Base64 } from "js-base64";
|
||||
import beep from "../sounds/beep.mp3";
|
||||
import juntos from "../sounds/juntos.mp3";
|
||||
import pristine from "../sounds/pristine.mp3";
|
||||
@@ -34,6 +33,8 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
|
||||
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
||||
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
||||
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
||||
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
|
||||
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
|
||||
|
||||
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
|
||||
|
||||
@@ -61,9 +62,14 @@ export const unmatchedTags = (tags) => {
|
||||
return tags.filter((tag) => !(tag in emojisMapped));
|
||||
};
|
||||
|
||||
export const encodeBase64 = (s) => Base64.encode(s);
|
||||
export const encodeBase64 = (s) => {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
export const encodeBase64Url = (s) => Base64.encodeURI(s);
|
||||
export const encodeBase64Url = (s) => encodeBase64(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
||||
export const bearerAuth = (token) => `Bearer ${token}`;
|
||||
|
||||
@@ -136,9 +142,10 @@ export const hashCode = (s) => {
|
||||
|
||||
/**
|
||||
* convert `i18n.language` style str (e.g.: `en_US`) to kebab-case (e.g.: `en-US`),
|
||||
* which is expected by `<html lang>` and `Intl.DateTimeFormat`
|
||||
* which is expected by `<html lang>` and `Intl.DateTimeFormat`. Falls back to "en"
|
||||
* if the input is missing or not a string.
|
||||
*/
|
||||
export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-");
|
||||
export const getKebabCaseLangStr = (language) => (typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en");
|
||||
|
||||
export const formatShortDateTime = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
|
||||
@@ -149,6 +156,32 @@ export const formatShortDateTime = (timestamp, language) =>
|
||||
export const formatShortDate = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000));
|
||||
|
||||
export const formatShortDuration = (ms, language) => {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const units = [
|
||||
{ unit: "year", s: 31536000 },
|
||||
{ unit: "month", s: 2592000 },
|
||||
{ unit: "week", s: 604800 },
|
||||
{ unit: "day", s: 86400 },
|
||||
{ unit: "hour", s: 3600 },
|
||||
{ unit: "minute", s: 60 },
|
||||
{ unit: "second", s: 1 },
|
||||
];
|
||||
const match = units.find((u) => seconds >= u.s) ?? units[units.length - 1];
|
||||
const value = Math.round(seconds / match.s);
|
||||
// [lang, "en"] makes Intl fall back to English for well-formed-but-unsupported tags;
|
||||
// the try/catch covers malformed tags (RangeError) so the web app never crashes here.
|
||||
try {
|
||||
return new Intl.NumberFormat([getKebabCaseLangStr(language), "en"], {
|
||||
style: "unit",
|
||||
unit: match.unit,
|
||||
unitDisplay: "long",
|
||||
}).format(value);
|
||||
} catch {
|
||||
return new Intl.NumberFormat("en", { style: "unit", unit: match.unit, unitDisplay: "long" }).format(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return "0 bytes";
|
||||
const k = 1024;
|
||||
|
||||
+286
-86
@@ -39,13 +39,12 @@ import EditIcon from "@mui/icons-material/Edit";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { ContentCopy, Public } from "@mui/icons-material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import routes from "./routes";
|
||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
|
||||
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import db from "../app/db";
|
||||
@@ -53,7 +52,7 @@ import UpgradeDialog from "./UpgradeDialog";
|
||||
import { AccountContext } from "./App";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import { Paragraph } from "./styles";
|
||||
import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
import { ProChip } from "./SubscriptionPopup";
|
||||
import session from "../app/Session";
|
||||
|
||||
@@ -84,6 +83,7 @@ const Basics = () => {
|
||||
<PrefGroup>
|
||||
<Username />
|
||||
<ChangePassword />
|
||||
<Emails />
|
||||
<PhoneNumbers />
|
||||
<AccountType />
|
||||
</PrefGroup>
|
||||
@@ -354,6 +354,200 @@ const AccountType = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Emails = () => {
|
||||
const { t } = useTranslation();
|
||||
const { account } = useContext(AccountContext);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const labelId = "prefVerifiedEmails";
|
||||
|
||||
const handleDialogOpen = () => {
|
||||
setDialogKey((prev) => prev + 1);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = (email) => {
|
||||
copyToClipboard(email);
|
||||
setSnackOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (email) => {
|
||||
try {
|
||||
await accountApi.deleteEmail(email);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error deleting email`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!config.enable_email_verify) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (account?.limits.emails === 0) {
|
||||
return (
|
||||
<Pref
|
||||
title={
|
||||
<>
|
||||
{t("account_basics_emails_title")}
|
||||
{config.enable_payments && <ProChip />}
|
||||
</>
|
||||
}
|
||||
description={t("account_basics_emails_description")}
|
||||
>
|
||||
<em>{t("account_usage_emails_none")}</em>
|
||||
</Pref>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
{account?.emails?.map((email) => (
|
||||
<Chip
|
||||
key={email}
|
||||
label={
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<span>{email}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={() => handleCopy(email)}
|
||||
onDelete={() => handleDelete(email)}
|
||||
/>
|
||||
))}
|
||||
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||
<IconButton onClick={handleDialogOpen}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("account_basics_emails_copied_to_clipboard")}
|
||||
/>
|
||||
</Portal>
|
||||
</Pref>
|
||||
);
|
||||
};
|
||||
|
||||
const AddEmailDialog = (props) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const verifyEmail = async () => {
|
||||
try {
|
||||
setSending(true);
|
||||
await accountApi.verifyEmail(email);
|
||||
setVerificationCodeSent(true);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error sending email verification`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkVerifyEmail = async () => {
|
||||
try {
|
||||
setSending(true);
|
||||
await accountApi.addEmail(email, code);
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error confirming email verification`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else if (e instanceof EmailVerificationCodeInvalidError) {
|
||||
setError(t("account_basics_emails_dialog_code_invalid"));
|
||||
} else {
|
||||
setError(e.message);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogSubmit = async () => {
|
||||
if (!verificationCodeSent) {
|
||||
await verifyEmail();
|
||||
} else {
|
||||
await checkVerifyEmail();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (verificationCodeSent) {
|
||||
setVerificationCodeSent(false);
|
||||
setCode("");
|
||||
} else {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
||||
{!verificationCodeSent && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("account_basics_emails_dialog_email_label")}
|
||||
aria-label={t("account_basics_emails_dialog_email_label")}
|
||||
placeholder={t("account_basics_emails_dialog_email_placeholder")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
)}
|
||||
{verificationCodeSent && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label={t("account_basics_emails_dialog_code_label")}
|
||||
aria-label={t("account_basics_emails_dialog_code_label")}
|
||||
placeholder={t("account_basics_emails_dialog_code_placeholder")}
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(ev) => setCode(ev.target.value)}
|
||||
fullWidth
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
variant="standard"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
|
||||
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
|
||||
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
|
||||
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const PhoneNumbers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { account } = useContext(AccountContext);
|
||||
@@ -701,10 +895,7 @@ const Stats = () => {
|
||||
title={t("account_usage_attachment_storage_title")}
|
||||
description={t("account_usage_attachment_storage_description", {
|
||||
filesize: formatBytes(account.limits.attachment_file_size),
|
||||
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
|
||||
language: i18n.resolvedLanguage,
|
||||
fallbacks: ["en"],
|
||||
}),
|
||||
expiry: formatShortDuration(account.limits.attachment_expiry_duration * 1000, i18n.resolvedLanguage),
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
@@ -750,7 +941,9 @@ const Stats = () => {
|
||||
)}
|
||||
</PrefGroup>
|
||||
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
|
||||
<Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography>
|
||||
<Typography variant="body1" sx={{ pt: 3 }}>
|
||||
{t("account_usage_basis_ip_description")}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
@@ -859,87 +1052,94 @@ const TokensTable = (props) => {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tokens.map((token) => (
|
||||
<TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
|
||||
aria-label={t("account_tokens_table_token_header")}
|
||||
>
|
||||
<span>
|
||||
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
|
||||
...
|
||||
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
|
||||
<IconButton onClick={() => handleCopy(token.token)}>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell aria-label={t("account_tokens_table_label_header")}>
|
||||
{token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
|
||||
{token.token !== session.token() && (token.label || "-")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
|
||||
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span>{formatShortDateTime(token.last_access, i18n.language)}</span>
|
||||
<Tooltip
|
||||
title={t("account_tokens_table_last_origin_tooltip", {
|
||||
ip: token.last_origin,
|
||||
})}
|
||||
>
|
||||
<IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
|
||||
<Public />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{token.token !== session.token() && !token.provisioned && (
|
||||
<>
|
||||
<Tooltip title={t("account_tokens_dialog_title_edit")}>
|
||||
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
|
||||
<EditIcon />
|
||||
{tokens.map((token) => {
|
||||
const hasLastAccess = Number.isFinite(token.last_access) && token.last_access > 0;
|
||||
const hasLastOrigin = !!token.last_origin;
|
||||
|
||||
return (
|
||||
<TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
|
||||
aria-label={t("account_tokens_table_token_header")}
|
||||
>
|
||||
<span>
|
||||
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
|
||||
...
|
||||
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
|
||||
<IconButton onClick={() => handleCopy(token.token)}>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_tokens_dialog_title_delete")}>
|
||||
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell aria-label={t("account_tokens_table_label_header")}>
|
||||
{token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
|
||||
{token.token !== session.token() && (token.label || "-")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
|
||||
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{hasLastAccess ? <span>{formatShortDateTime(token.last_access, i18n.language)}</span> : <em>-</em>}
|
||||
{hasLastOrigin && (
|
||||
<Tooltip
|
||||
title={t("account_tokens_table_last_origin_tooltip", {
|
||||
ip: token.last_origin,
|
||||
})}
|
||||
>
|
||||
<IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
|
||||
<Public />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{token.token !== session.token() && !token.provisioned && (
|
||||
<>
|
||||
<Tooltip title={t("account_tokens_dialog_title_edit")}>
|
||||
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_tokens_dialog_title_delete")}>
|
||||
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{token.token === session.token() && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{token.token === session.token() && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{token.provisioned && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
{token.provisioned && (
|
||||
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
|
||||
<span>
|
||||
<IconButton disabled>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton disabled>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
<Portal>
|
||||
<Snackbar
|
||||
|
||||
@@ -31,18 +31,24 @@ const AttachmentIcon = (props) => {
|
||||
imageFile = fileDocument;
|
||||
imageLabel = t("notifications_attachment_file_document");
|
||||
}
|
||||
const icon = (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (!props.href) {
|
||||
return icon;
|
||||
}
|
||||
return (
|
||||
<Link href={props.href} target="_blank">
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
}}
|
||||
/>
|
||||
<Link href={props.href} target="_blank" rel="noopener noreferrer">
|
||||
{icon}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import StackTrace from "stacktrace-js";
|
||||
import { CircularProgress, Link, Button } from "@mui/material";
|
||||
import { Link, Button } from "@mui/material";
|
||||
import { Trans, withTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "../app/utils";
|
||||
|
||||
@@ -9,8 +8,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: false,
|
||||
originalStack: null,
|
||||
niceStack: null,
|
||||
stack: null,
|
||||
unsupportedIndexedDB: false,
|
||||
};
|
||||
}
|
||||
@@ -32,23 +30,17 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
}
|
||||
|
||||
handleError(error, info) {
|
||||
// Immediately render original stack trace
|
||||
const prettierOriginalStack = info.componentStack
|
||||
const componentStack = info.componentStack
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => ` at ${line}`)
|
||||
.join("\n");
|
||||
const parts = [error.toString()];
|
||||
if (error.stack) parts.push(error.stack);
|
||||
parts.push(componentStack);
|
||||
this.setState({
|
||||
error: true,
|
||||
originalStack: `${error.toString()}\n${prettierOriginalStack}`,
|
||||
});
|
||||
|
||||
// Fetch additional info and a better stack trace
|
||||
StackTrace.fromError(error).then((stack) => {
|
||||
console.error("[ErrorBoundary] Stacktrace fetched", stack);
|
||||
const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
|
||||
const niceStack = `${error.toString()}\n${stackString}`;
|
||||
this.setState({ niceStack });
|
||||
stack: parts.join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,12 +52,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
}
|
||||
|
||||
copyStack() {
|
||||
let stack = "";
|
||||
if (this.state.niceStack) {
|
||||
stack += `${this.state.niceStack}\n\n`;
|
||||
}
|
||||
stack += `${this.state.originalStack}\n`;
|
||||
copyToClipboard(stack);
|
||||
copyToClipboard(`${this.state.stack}\n`);
|
||||
}
|
||||
|
||||
renderUnsupportedIndexedDB() {
|
||||
@@ -112,14 +99,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
</Button>
|
||||
</div>
|
||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||
{this.state.niceStack ? (
|
||||
<pre>{this.state.niceStack}</pre>
|
||||
) : (
|
||||
<>
|
||||
<CircularProgress size="20px" sx={{ verticalAlign: "text-bottom" }} /> {t("error_boundary_gathering_info")}
|
||||
</>
|
||||
)}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
<pre>{this.state.stack}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ const NavList = (props) => {
|
||||
|
||||
const isAdmin = account?.role === Role.ADMIN;
|
||||
const isPaid = account?.billing?.subscription;
|
||||
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
|
||||
const hasTier = !!account?.tier;
|
||||
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid && !hasTier;
|
||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
||||
const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());
|
||||
const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());
|
||||
|
||||
@@ -30,6 +30,7 @@ import priority3 from "../img/priority-3.svg";
|
||||
import priority4 from "../img/priority-4.svg";
|
||||
import priority5 from "../img/priority-5.svg";
|
||||
import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
|
||||
import { imageRegex } from "../app/notificationUtils";
|
||||
import AttachmentIcon from "./AttachmentIcon";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import api from "../app/Api";
|
||||
@@ -805,7 +806,7 @@ const AttachmentBox = (props) => {
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<AttachmentIcon type={file.type} href={URL.createObjectURL(file)} />
|
||||
<AttachmentIcon type={file.type} href={imageRegex.test(file.name) ? URL.createObjectURL(file) : undefined} />
|
||||
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
|
||||
<ExpandingTextField
|
||||
minWidth={140}
|
||||
|
||||
Reference in New Issue
Block a user