Settings Import/export (#2223)

* Add UI for import/export of settings

* Make settings update after import without requiring manual browser refresh

* Address PR feedback

* Add translations
This commit is contained in:
SleeplessOne1917 2023-11-17 03:47:33 +00:00 committed by GitHub
parent 9fcd5ef54f
commit 4be7310441
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 191 additions and 2 deletions

@ -1 +1 @@
Subproject commit b0d80808047eb547aa995e923e9c742e87cd8be7
Subproject commit 277e3c335bbb9c00c4a600bd4d655b273393f84a

View file

@ -17,7 +17,7 @@ import { capitalizeFirstLetter, debounce } from "@utils/helpers";
import { Choice, RouteDataResponse } from "@utils/types";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import { Component, createRef, linkEvent } from "inferno";
import {
BlockCommunityResponse,
BlockInstanceResponse,
@ -60,6 +60,7 @@ import { CommunityLink } from "../community/community-link";
import { PersonListing } from "./person-listing";
import { InitialFetchRequest } from "../../interfaces";
import TotpModal from "../common/totp-modal";
import { LoadingEllipses } from "../common/loading-ellipses";
type SettingsData = RouteDataResponse<{
instancesRes: GetFederatedInstancesResponse;
@ -119,6 +120,9 @@ interface SettingsState {
searchInstanceOptions: Choice[];
isIsomorphic: boolean;
show2faModal: boolean;
importSettingsRes: RequestState<any>;
exportSettingsRes: RequestState<any>;
settingsFile?: File;
}
type FilterType = "user" | "community" | "instance";
@ -183,6 +187,8 @@ function handleClose2faModal(i: Settings) {
export class Settings extends Component<any, SettingsState> {
private isoData = setIsoData<SettingsData>(this.context);
exportSettingsLink = createRef<HTMLAnchorElement>();
state: SettingsState = {
saveRes: EMPTY_REQUEST,
deleteAccountRes: EMPTY_REQUEST,
@ -207,6 +213,8 @@ export class Settings extends Component<any, SettingsState> {
generateTotpRes: EMPTY_REQUEST,
updateTotpRes: EMPTY_REQUEST,
show2faModal: false,
importSettingsRes: EMPTY_REQUEST,
exportSettingsRes: EMPTY_REQUEST,
};
constructor(props: any, context: any) {
@ -251,6 +259,7 @@ export class Settings extends Component<any, SettingsState> {
show_read_posts,
send_notifications_to_email,
email,
open_links_in_new_tab,
},
person: {
avatar,
@ -288,6 +297,7 @@ export class Settings extends Component<any, SettingsState> {
bio,
send_notifications_to_email,
matrix_user_id,
open_links_in_new_tab,
},
};
}
@ -332,8 +342,18 @@ export class Settings extends Component<any, SettingsState> {
}
render() {
/* eslint-disable jsx-a11y/anchor-has-content, jsx-a11y/anchor-is-valid */
return (
<div className="person-settings container-lg">
<a
ref={this.exportSettingsLink}
download={`${I18NextService.i18n.t("export_file_name")}_${new Date()
.toISOString()
.replace(/:|-/g, "")}.json`}
className="d-none"
href="javascript:void(0)"
aria-hidden="true"
/>
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
@ -356,6 +376,7 @@ export class Settings extends Component<any, SettingsState> {
/>
</div>
);
/* eslint-enable jsx-a11y/anchor-has-content, jsx-a11y/anchor-is-valid */
}
userSettings(isSelected: boolean) {
@ -377,6 +398,9 @@ export class Settings extends Component<any, SettingsState> {
<div className="card border-secondary mb-3">
<div className="card-body">{this.changePasswordHtmlForm()}</div>
</div>
<div className="card border-secondary mb-3">
<div className="card-body">{this.importExport()}</div>
</div>
</div>
</div>
</div>
@ -595,6 +619,58 @@ export class Settings extends Component<any, SettingsState> {
);
}
importExport() {
return (
<>
<h2 className="h5">
{I18NextService.i18n.t("import_export_section_title")}
</h2>
<p>{I18NextService.i18n.t("import_export_section_description")}</p>
{!(
this.state.importSettingsRes.state === "loading" ||
this.state.exportSettingsRes.state === "loading"
) ? (
<>
<button
className="btn btn-secondary w-100 mb-4"
onClick={linkEvent(this, this.handleExportSettings)}
type="button"
>
{I18NextService.i18n.t("export")}
</button>
<fieldset className="border border-secondary rounded p-3 bg-dark bg-opacity-25">
<input
type="file"
accept="application/json"
className="form-control"
aria-label="Import settings file input"
onChange={linkEvent(this, this.handleImportFileChange)}
/>
<button
className="btn btn-secondary w-100 mt-3"
onClick={linkEvent(this, this.handleImportSettings)}
type="button"
disabled={!this.state.settingsFile}
>
{I18NextService.i18n.t("import")}
</button>
</fieldset>
</>
) : (
<div>
<div className="text-center">
{this.state.exportSettingsRes.state === "loading"
? I18NextService.i18n.t("exporting")
: I18NextService.i18n.t("importing")}
<LoadingEllipses />
</div>
<Spinner large />
</div>
)}
</>
);
}
saveUserSettingsHtmlForm() {
const selectedLangs = this.state.saveUserSettingsForm.discussion_languages;
@ -1096,6 +1172,7 @@ export class Settings extends Component<any, SettingsState> {
this.setState({ show2faModal: false });
const siteRes = await HttpService.client.getSite();
UserService.Instance.myUserInfo!.local_user_view.local_user.totp_2fa_enabled =
enabled;
@ -1474,6 +1551,118 @@ export class Settings extends Component<any, SettingsState> {
}
}
handleImportFileChange(i: Settings, event) {
i.setState({ settingsFile: event.target.files?.item(0) });
}
async handleExportSettings(i: Settings) {
i.setState({ exportSettingsRes: LOADING_REQUEST });
const res = await HttpService.client.exportSettings();
if (res.state === "success") {
i.exportSettingsLink.current!.href = encodeURI(
`data:application/json,${JSON.stringify(res.data)}`,
);
i.exportSettingsLink.current?.click();
} else if (res.state === "failed") {
toast(
res.err.message === "rate_limit_error"
? I18NextService.i18n.t("import_export_rate_limit_error")
: I18NextService.i18n.t("export_error"),
"danger",
);
}
i.setState({ exportSettingsRes: EMPTY_REQUEST });
}
async handleImportSettings(i: Settings) {
i.setState({ importSettingsRes: LOADING_REQUEST });
const res = await HttpService.client.importSettings(
JSON.parse(await i.state.settingsFile!.text()),
);
if (res.state === "success") {
toast(I18NextService.i18n.t("import_success"), "success");
const saveRes = i.state.saveRes;
i.setState({ saveRes: LOADING_REQUEST });
const siteRes = await HttpService.client.getSite();
i.setState({ saveRes });
if (siteRes.state === "success") {
const {
local_user: {
show_nsfw,
blur_nsfw,
auto_expand,
theme,
default_sort_type,
default_listing_type,
interface_language,
show_avatars,
show_bot_accounts,
show_scores,
show_read_posts,
send_notifications_to_email,
email,
open_links_in_new_tab,
},
person: {
avatar,
banner,
display_name,
bot_account,
bio,
matrix_user_id,
},
} = siteRes.data.my_user!.local_user_view;
UserService.Instance.myUserInfo = siteRes.data.my_user;
i.setState(prev => ({
...prev,
siteRes: siteRes.data,
saveUserSettingsForm: {
...prev.saveUserSettingsForm,
show_avatars,
show_bot_accounts,
show_nsfw,
teme: theme ?? "browser",
avatar,
banner,
display_name,
bio,
matrix_user_id,
auto_expand,
blur_nsfw,
bot_account,
default_listing_type,
default_sort_type,
discussion_languages: siteRes.data.my_user?.discussion_languages,
email,
interface_language,
open_links_in_new_tab,
send_notifications_to_email,
show_read_posts,
show_scores,
},
}));
}
} else if (res.state === "failed") {
toast(
res.err.message === "rate_limit_error"
? I18NextService.i18n.t("import_export_rate_limit_error")
: I18NextService.i18n.t("import_error"),
"danger",
);
}
i.setState({ importSettingsRes: EMPTY_REQUEST, settingsFile: undefined });
}
handleDeleteAccountShowConfirmToggle(i: Settings) {
i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
}