mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
Updated Sync Process + Bug Fixes
Reworked Sync Process as old process only fetched once and only added items that did not exist. Now data is refreshed accordingly (Does not apply to item information for now) Changed Log format to specify number of items updated/inserted (Does not apply to item information for now) Added censor toggle to password fields Added Change Password settings Added toggle to make login optional after first time setup Added Error boundaries to components that rely on raw Jellyfin Data (Should help with UI errors) Tweaked Device icon identification process to cater for ios.svg-->apple.svg + opera.svg Changed proxy process to throw error 500 when response from urls are not of expected type
This commit is contained in:
@@ -25,7 +25,7 @@ router.get("/test", async (req, res) => {
|
||||
|
||||
router.get("/getconfig", async (req, res) => {
|
||||
try{
|
||||
const { rows } = await db.query('SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1');
|
||||
const { rows } = await db.query('SELECT "JF_HOST","JF_API_KEY","APP_USER","REQUIRE_LOGIN" FROM app_config where "ID"=1');
|
||||
res.send(rows);
|
||||
|
||||
}catch(error)
|
||||
@@ -62,6 +62,33 @@ router.post("/setconfig", async (req, res) => {
|
||||
console.log(`ENDPOINT CALLED: /setconfig: `);
|
||||
});
|
||||
|
||||
router.post("/setRequireLogin", async (req, res) => {
|
||||
try{
|
||||
const { REQUIRE_LOGIN } = req.body;
|
||||
|
||||
if(REQUIRE_LOGIN===undefined)
|
||||
{
|
||||
res.status(503);
|
||||
res.send(rows);
|
||||
}
|
||||
|
||||
let query='UPDATE app_config SET "REQUIRE_LOGIN"=$1 where "ID"=1';
|
||||
|
||||
console.log(`ENDPOINT CALLED: /setRequireLogin: `+REQUIRE_LOGIN);
|
||||
|
||||
const { rows } = await db.query(
|
||||
query,
|
||||
[REQUIRE_LOGIN]
|
||||
);
|
||||
res.send(rows);
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
router.get("/CheckForUpdates", async (req, res) => {
|
||||
try{
|
||||
|
||||
@@ -457,6 +484,45 @@ router.post("/validateSettings", async (req, res) => {
|
||||
|
||||
});
|
||||
|
||||
router.post("/updatePassword", async (req, res) => {
|
||||
const { current_password,new_password } = req.body;
|
||||
|
||||
let result={isValid:true,errorMessage:""};
|
||||
|
||||
|
||||
try{
|
||||
const { rows } = await db.query(`SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1 AND "APP_PASSWORD"='${current_password}' `);
|
||||
|
||||
if(rows && rows.length>0)
|
||||
{
|
||||
if(current_password===new_password)
|
||||
{
|
||||
result.isValid=false;
|
||||
result.errorMessage = "New Password cannot be the same as Old Password";
|
||||
}else{
|
||||
|
||||
await db.query(`UPDATE app_config SET "APP_PASSWORD"='${new_password}' where "ID"=1 AND "APP_PASSWORD"='${current_password}' `);
|
||||
|
||||
|
||||
}
|
||||
|
||||
}else{
|
||||
result.isValid=false;
|
||||
result.errorMessage = "Old Password is Invalid";
|
||||
}
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
result.errorMessage = error;
|
||||
}
|
||||
|
||||
|
||||
|
||||
res.send(result);
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ router.post('/login', async (req, res) => {
|
||||
try{
|
||||
const { username, password } = req.body;
|
||||
|
||||
const { rows : login } = await db.query(`SELECT * FROM app_config where "APP_USER"='${username}' and "APP_PASSWORD"='${password}'`);
|
||||
const { rows : login } = await db.query(`SELECT * FROM app_config where ("APP_USER"='${username}' and "APP_PASSWORD"='${password}') OR "REQUIRE_LOGIN"=false`);
|
||||
|
||||
if(login.length>0)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { Pool } = require('pg');
|
||||
const pgp = require("pg-promise")();
|
||||
const {update_query : update_query_map} = require("./models/bulk_insert_update_handler");
|
||||
|
||||
|
||||
const _POSTGRES_USER=process.env.POSTGRES_USER;
|
||||
@@ -71,17 +72,11 @@ async function insertBulk(table_name, data,columns) {
|
||||
let message='';
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const query = pgp.helpers.insert(
|
||||
data,
|
||||
columns,
|
||||
table_name
|
||||
);
|
||||
await client.query(query);
|
||||
|
||||
const update_query= update_query_map.find(query => query.table === table_name).query;
|
||||
await client.query("COMMIT");
|
||||
|
||||
message=((data.length||1) + " Rows Inserted.");
|
||||
const cs = new pgp.helpers.ColumnSet(columns, { table: table_name });
|
||||
const query = pgp.helpers.insert(data, cs) + update_query; // Update the column names accordingly
|
||||
await client.query(query);
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
23
backend/migrations/032_app_config_table_add_auth_flag.js
Normal file
23
backend/migrations/032_app_config_table_add_auth_flag.js
Normal file
@@ -0,0 +1,23 @@
|
||||
exports.up = async function(knex) {
|
||||
try
|
||||
{
|
||||
const hasTable = await knex.schema.hasTable('app_config');
|
||||
if (hasTable) {
|
||||
await knex.schema.alterTable('app_config', function(table) {
|
||||
table.boolean('REQUIRE_LOGIN').defaultTo(true);
|
||||
});
|
||||
}
|
||||
}catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
try {
|
||||
await knex.schema.alterTable('app_config', function(table) {
|
||||
table.dropColumn('REQUIRE_LOGIN');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
17
backend/models/bulk_insert_update_handler.js
Normal file
17
backend/models/bulk_insert_update_handler.js
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
|
||||
const update_query = [
|
||||
{table:'jf_activity_watchdog',query:' ON CONFLICT DO NOTHING'},
|
||||
{table:'jf_item_info',query:' ON CONFLICT ("Id") DO UPDATE SET "Path" = EXCLUDED."Path", "Name" = EXCLUDED."Name", "Size" = EXCLUDED."Size", "Bitrate" = EXCLUDED."Bitrate", "MediaStreams" = EXCLUDED."MediaStreams"'},
|
||||
{table:'jf_libraries',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "Type" = EXCLUDED."Type", "CollectionType" = EXCLUDED."CollectionType", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary"'},
|
||||
{table:'jf_library_episodes',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "OfficialRating" = EXCLUDED."OfficialRating", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "IndexNumber" = EXCLUDED."IndexNumber", "ParentIndexNumber" = EXCLUDED."ParentIndexNumber", "Type" = EXCLUDED."Type", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesId" = EXCLUDED."SeriesId", "SeasonId" = EXCLUDED."SeasonId", "SeasonName" = EXCLUDED."SeasonName", "SeriesName" = EXCLUDED."SeriesName"'},
|
||||
{table:'jf_library_items',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "EndDate" = EXCLUDED."EndDate", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "Type" = EXCLUDED."Type", "Status" = EXCLUDED."Status", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary", "ImageTagsBanner" = EXCLUDED."ImageTagsBanner", "ImageTagsLogo" = EXCLUDED."ImageTagsLogo", "ImageTagsThumb" = EXCLUDED."ImageTagsThumb", "BackdropImageTags" = EXCLUDED."BackdropImageTags", "ParentId" = EXCLUDED."ParentId", "PrimaryImageHash" = EXCLUDED."PrimaryImageHash"'},
|
||||
{table:'jf_library_seasons',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesPrimaryImageTag" = EXCLUDED."SeriesPrimaryImageTag"'},
|
||||
{table:'jf_logging',query:' ON CONFLICT DO NOTHING'},
|
||||
{table:'jf_playback_activity',query:' ON CONFLICT DO NOTHING'},
|
||||
{table:'jf_playback_reporting_plugin_data',query:' ON CONFLICT DO NOTHING'},
|
||||
{table:'jf_users',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PrimaryImageTag" = EXCLUDED."PrimaryImageTag", "LastLoginDate" = EXCLUDED."LastLoginDate", "LastActivityDate" = EXCLUDED."LastActivityDate"'}
|
||||
];
|
||||
module.exports = {
|
||||
update_query
|
||||
};
|
||||
@@ -35,14 +35,14 @@ router.get('/web/assets/img/devices/', async(req, res) => {
|
||||
if (response.headers['content-type'].startsWith('image/')) {
|
||||
res.send(response.data);
|
||||
} else {
|
||||
res.send(response.data.toString());
|
||||
res.status(500).send('Error fetching image');
|
||||
}
|
||||
|
||||
return; // Add this line
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
res.status(500).send('Error fetching image');
|
||||
res.status(500).send('Error fetching image: '+error);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -72,12 +72,12 @@ router.get('/Items/Images/Backdrop/', async(req, res) => {
|
||||
if (response.headers['content-type'].startsWith('image/')) {
|
||||
res.send(response.data);
|
||||
} else {
|
||||
res.send(response.data.toString());
|
||||
res.status(500).send('Error fetching image');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// console.error(error);
|
||||
res.status(500).send('Error fetching image');
|
||||
res.status(500).send('Error fetching image: '+error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,12 +103,12 @@ router.get('/Items/Images/Backdrop/', async(req, res) => {
|
||||
if (response.headers['content-type'].startsWith('image/')) {
|
||||
res.send(response.data);
|
||||
} else {
|
||||
res.send(response.data.toString());
|
||||
res.status(500).send('Error fetching image');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// console.error(error);
|
||||
res.status(500).send('Error fetching image');
|
||||
res.status(500).send('Error fetching image: '+error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,12 +135,12 @@ router.get('/Items/Images/Backdrop/', async(req, res) => {
|
||||
if (response.headers['content-type'].startsWith('image/')) {
|
||||
res.send(response.data);
|
||||
} else {
|
||||
res.send(response.data.toString());
|
||||
res.status(500).send('Error fetching image');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// console.error(error);
|
||||
res.status(500).send('Error fetching image');
|
||||
res.status(500).send('Error fetching image: '+error);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ async function syncLibraryItems(refLog)
|
||||
const userid = admins[0].Id;
|
||||
const libraries = await _sync.getItem(undefined,userid);
|
||||
const data = [];
|
||||
let insertCounter = 0;
|
||||
let insertMessage='';
|
||||
let deleteCounter = 0;
|
||||
//for each item in library run get item using that id as the ParentId (This gets the children of the parent id)
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
@@ -321,21 +321,21 @@ async function syncLibraryItems(refLog)
|
||||
let dataToInsert = [];
|
||||
//filter fix if jf_libraries is empty
|
||||
|
||||
|
||||
if (existingIds.length === 0) {
|
||||
dataToInsert = await data.map(jf_library_items_mapping);
|
||||
} else {
|
||||
dataToInsert = await data
|
||||
.filter((row) => !existingIds.includes(row.Id))
|
||||
.map(jf_library_items_mapping);
|
||||
}
|
||||
dataToInsert = await data.map(jf_library_items_mapping);
|
||||
// if (existingIds.length === 0) {
|
||||
// dataToInsert = await data.map(jf_library_items_mapping);
|
||||
// } else {
|
||||
// dataToInsert = await data
|
||||
// .filter((row) => !existingIds.includes(row.Id))
|
||||
// .map(jf_library_items_mapping);
|
||||
// }
|
||||
|
||||
|
||||
if (dataToInsert.length !== 0) {
|
||||
|
||||
let result = await db.insertBulk("jf_library_items",dataToInsert,jf_library_items_columns);
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertCounter += dataToInsert.length;
|
||||
insertMessage = `${dataToInsert.length-existingIds.length} Rows Inserted. ${existingIds.length} Rows Updated.`;
|
||||
} else {
|
||||
refLog.loggedData.push({
|
||||
color: "red",
|
||||
@@ -356,7 +356,7 @@ async function syncLibraryItems(refLog)
|
||||
}
|
||||
}
|
||||
|
||||
refLog.loggedData.push({color: "dodgerblue",Message: insertCounter + " Library Items Inserted.",});
|
||||
refLog.loggedData.push({color: "dodgerblue",Message: insertMessage,});
|
||||
refLog.loggedData.push({color: "orange",Message: deleteCounter + " Library Items Removed.",});
|
||||
refLog.loggedData.push({ color: "yellow", Message: "Item Sync Complete" });
|
||||
|
||||
@@ -389,16 +389,17 @@ async function syncShowItems(refLog)
|
||||
|
||||
let insertSeasonsCount = 0;
|
||||
let insertEpisodeCount = 0;
|
||||
let updateSeasonsCount = 0;
|
||||
let updateEpisodeCount = 0;
|
||||
|
||||
|
||||
let deleteSeasonsCount = 0;
|
||||
let deleteEpisodeCount = 0;
|
||||
|
||||
//loop for each show
|
||||
let show_counter=0;
|
||||
for (const show of shows) {
|
||||
const allSeasons = await _sync.getSeasonsAndEpisodes(show.Id,'Seasons');
|
||||
const allEpisodes =await _sync.getSeasonsAndEpisodes(show.Id,'Episodes');
|
||||
show_counter++;
|
||||
refLog.loggedData.push({ Message: "Syncing shows " + (show_counter/shows.length*100).toFixed(2) +"%" ,key:'show_sync'});
|
||||
|
||||
const existingIdsSeasons = await db.query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`).then((res) => res.rows.map((row) => row.Id));
|
||||
|
||||
@@ -414,37 +415,21 @@ async function syncShowItems(refLog)
|
||||
.then((res) => res.rows.map((row) => row.EpisodeId));
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
|
||||
let seasonsToInsert = [];
|
||||
let episodesToInsert = [];
|
||||
//filter fix if jf_libraries is empty
|
||||
|
||||
if (existingIdsSeasons.length === 0) {
|
||||
// if there are no existing Ids in the table, map all items in the data array to the expected format
|
||||
seasonsToInsert = await allSeasons.map(jf_library_seasons_mapping);
|
||||
} else {
|
||||
// otherwise, filter only new data to insert
|
||||
seasonsToInsert = await allSeasons
|
||||
.filter((row) => !existingIdsSeasons.includes(row.Id))
|
||||
.map(jf_library_seasons_mapping);
|
||||
}
|
||||
seasonsToInsert = await allSeasons.map(jf_library_seasons_mapping);
|
||||
episodesToInsert = await allEpisodes.map(jf_library_episodes_mapping);
|
||||
|
||||
if (existingIdsEpisodes.length === 0) {
|
||||
// if there are no existing Ids in the table, map all items in the data array to the expected format
|
||||
episodesToInsert = await allEpisodes.map(jf_library_episodes_mapping);
|
||||
} else {
|
||||
// otherwise, filter only new data to insert
|
||||
episodesToInsert = await allEpisodes.filter((row) => !existingIdsEpisodes.includes(row.Id)).map(jf_library_episodes_mapping);
|
||||
}
|
||||
|
||||
///insert delete seasons
|
||||
//Bulkinsert new data not on db
|
||||
if (seasonsToInsert.length !== 0) {
|
||||
let result = await db.insertBulk("jf_library_seasons",seasonsToInsert,jf_library_seasons_columns);
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertSeasonsCount += seasonsToInsert.length;
|
||||
} else {
|
||||
insertSeasonsCount+=seasonsToInsert.length-existingIdsSeasons.length;
|
||||
updateSeasonsCount+=existingIdsSeasons.length;
|
||||
} else {
|
||||
refLog.loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
@@ -469,7 +454,8 @@ async function syncShowItems(refLog)
|
||||
if (episodesToInsert.length !== 0) {
|
||||
let result = await db.insertBulk("jf_library_episodes",episodesToInsert,jf_library_episodes_columns);
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertEpisodeCount += episodesToInsert.length;
|
||||
insertEpisodeCount+=episodesToInsert.length-existingIdsEpisodes.length;
|
||||
updateEpisodeCount+=existingIdsEpisodes.length;
|
||||
} else {
|
||||
refLog.loggedData.push({
|
||||
color: "red",
|
||||
@@ -495,9 +481,9 @@ async function syncShowItems(refLog)
|
||||
|
||||
}
|
||||
|
||||
refLog.loggedData.push({color: "dodgerblue",Message: insertSeasonsCount + " Seasons inserted.",});
|
||||
refLog.loggedData.push({color: "dodgerblue",Message: `Seasons: ${insertSeasonsCount} Rows Inserted. ${updateSeasonsCount} Rows Updated.`});
|
||||
refLog.loggedData.push({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",});
|
||||
refLog.loggedData.push({color: "dodgerblue",Message: insertEpisodeCount + " Episodes inserted.",});
|
||||
refLog.loggedData.push({color: "dodgerblue",Message: `Episodes: ${insertEpisodeCount} Rows Inserted. ${updateEpisodeCount} Rows Updated.`});
|
||||
refLog.loggedData.push({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",});
|
||||
refLog.loggedData.push({ color: "yellow", Message: "Sync Complete" });
|
||||
}catch(error)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jfstat",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
|
||||
@@ -11,10 +11,10 @@ async function Config() {
|
||||
|
||||
if(response.data.length>0)
|
||||
{
|
||||
const { JF_HOST, JF_API_KEY, APP_USER } = response.data[0];
|
||||
return { hostUrl: JF_HOST, apiKey: JF_API_KEY, username: APP_USER, token:token };
|
||||
const { JF_HOST, JF_API_KEY, APP_USER,REQUIRE_LOGIN } = response.data[0];
|
||||
return { hostUrl: JF_HOST, apiKey: JF_API_KEY, username: APP_USER, token:token, requireLogin:REQUIRE_LOGIN };
|
||||
}
|
||||
return { hostUrl: null, apiKey: null, username: null, token:token };
|
||||
return { hostUrl: null, apiKey: null, username: null, token:token,requireLogin:true };
|
||||
|
||||
} catch (error) {
|
||||
// console.log(error);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
|
||||
export const clientData = ["android","ios","safari","chrome","firefox","edge"]
|
||||
export const clientData = ["android","ios","safari","chrome","firefox","edge","opera"]
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ function formatTotalWatchTime(seconds) {
|
||||
function Row(data) {
|
||||
const { row } = data;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
// const classes = useRowStyles();
|
||||
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
|
||||
|
||||
const options = {
|
||||
|
||||
28
src/pages/components/general/ErrorBoundary.js
Normal file
28
src/pages/components/general/ErrorBoundary.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state to indicate an error has occurred
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// You can log the error or perform other actions here
|
||||
console.error(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Render an error message or fallback UI
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// Render the child components as normal
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import LibraryLastWatched from './library/last-watched';
|
||||
import RecentlyAdded from './library/recently-added';
|
||||
import LibraryActivity from './library/library-activity';
|
||||
import LibraryItems from './library/library-items';
|
||||
import ErrorBoundary from './general/ErrorBoundary';
|
||||
|
||||
import { Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
|
||||
|
||||
@@ -84,7 +85,10 @@ function LibraryInfo() {
|
||||
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
|
||||
<Tab eventKey="tabOverview" className='bg-transparent'>
|
||||
<LibraryGlobalStats LibraryId={LibraryId}/>
|
||||
<RecentlyAdded LibraryId={LibraryId}/>
|
||||
<ErrorBoundary>
|
||||
<RecentlyAdded LibraryId={LibraryId}/>
|
||||
</ErrorBoundary>
|
||||
|
||||
<LibraryLastWatched LibraryId={LibraryId}/>
|
||||
</Tab>
|
||||
<Tab eventKey="tabActivity" className='bg-transparent'>
|
||||
|
||||
@@ -84,9 +84,9 @@ function sessionCard(props) {
|
||||
"/proxy/web/assets/img/devices/?devicename="
|
||||
+
|
||||
(props.data.session.Client.toLowerCase().includes("web") ?
|
||||
( clientData.find(item => props.data.session.DeviceName.toLowerCase().includes(item)) || "other")
|
||||
( clientData.find(item => props.data.session.DeviceName.toLowerCase().includes(item)).replace('ios','apple') || "other")
|
||||
:
|
||||
( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)) || "other")
|
||||
( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)).replace('ios','apple') || "other")
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
209
src/pages/components/settings/security.js
Normal file
209
src/pages/components/settings/security.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState,useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import Alert from 'react-bootstrap/Alert';
|
||||
import ToggleButton from 'react-bootstrap/ToggleButton';
|
||||
import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import EyeFillIcon from 'remixicon-react/EyeFillIcon';
|
||||
import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon';
|
||||
|
||||
import Config from "../../../lib/config";
|
||||
|
||||
|
||||
|
||||
|
||||
import "../../css/settings/settings.css";
|
||||
import { InputGroup } from "react-bootstrap";
|
||||
|
||||
export default function SettingsConfig() {
|
||||
const [use_password, setuse_password] = useState(true);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [formValues, setFormValues] = useState({});
|
||||
const [isSubmitted, setisSubmitted] = useState("");
|
||||
const [config, setConfig] = useState(null);
|
||||
|
||||
const [submissionMessage, setsubmissionMessage] = useState("");
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const newConfig = await Config();
|
||||
setConfig(newConfig);
|
||||
setuse_password(newConfig.requireLogin);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
fetchConfig();
|
||||
|
||||
const intervalId = setInterval(fetchConfig, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
|
||||
async function updatePassword(_current_password, _new_password) {
|
||||
const result = await axios
|
||||
.post("/api/updatePassword", {
|
||||
current_password:_current_password,
|
||||
new_password: _new_password
|
||||
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
// let errorMessage= `Error : ${error}`;
|
||||
});
|
||||
|
||||
let data=result.data;
|
||||
return { isValid:data.isValid, errorMessage:data.errorMessage} ;
|
||||
}
|
||||
|
||||
async function setRequireLogin(requireLogin) {
|
||||
await axios
|
||||
.post("/api/setRequireLogin", {
|
||||
REQUIRE_LOGIN:requireLogin
|
||||
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((data)=>
|
||||
{
|
||||
setuse_password(requireLogin);
|
||||
}
|
||||
)
|
||||
.catch((error) => {
|
||||
// let errorMessage= `Error : ${error}`;
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function handleFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
setisSubmitted("");
|
||||
if(!formValues.JS_PASSWORD || formValues.JS_PASSWORD.length<6)
|
||||
{
|
||||
setisSubmitted("Failed");
|
||||
setsubmissionMessage("Unable to update password: New Password Must be at least 6 characters long");
|
||||
return;
|
||||
}
|
||||
let hashedOldPassword= CryptoJS.SHA3(formValues.JS_C_PASSWORD).toString();
|
||||
let hashedNewPassword= CryptoJS.SHA3(formValues.JS_PASSWORD).toString();
|
||||
let result = await updatePassword(
|
||||
hashedOldPassword,
|
||||
hashedNewPassword
|
||||
);
|
||||
|
||||
if (result.isValid) {
|
||||
setisSubmitted("Success");
|
||||
setsubmissionMessage("Successfully updated password");
|
||||
return;
|
||||
}else
|
||||
{
|
||||
setisSubmitted("Failed");
|
||||
setsubmissionMessage("Unable to update password: "+ result.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleFormChange(event) {
|
||||
setFormValues({ ...formValues, [event.target.name]: event.target.value });
|
||||
}
|
||||
|
||||
|
||||
function togglePasswordRequired(isRequired){
|
||||
// console.log(isRequired);
|
||||
setRequireLogin(isRequired);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Security</h1>
|
||||
<Form onSubmit={handleFormSubmit} className="settings-form">
|
||||
<Form.Group as={Row} className="mb-3" >
|
||||
<Form.Label column className="">
|
||||
Current Password
|
||||
</Form.Label>
|
||||
<Col sm="10">
|
||||
<InputGroup>
|
||||
<Form.Control id="JS_C_PASSWORD" name="JS_C_PASSWORD" value={formValues.JS_C_PASSWORD || ""} onChange={handleFormChange} type={showCurrentPassword ? "text" : "password"}/>
|
||||
<Button variant="outline-primary" type="button" onClick={() => setShowCurrentPassword(!showCurrentPassword)}>{showCurrentPassword?<EyeFillIcon/>:<EyeOffFillIcon/>}</Button>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group as={Row} className="mb-3" >
|
||||
<Form.Label column className="">
|
||||
New Password
|
||||
</Form.Label>
|
||||
<Col sm="10">
|
||||
<InputGroup>
|
||||
<Form.Control id="JS_PASSWORD" name="JS_PASSWORD" value={formValues.JS_PASSWORD || ""} onChange={handleFormChange} type={showPassword ? "text" : "password"} />
|
||||
<Button variant="outline-primary" type="button" onClick={() => setShowPassword(!showPassword)}>{showPassword?<EyeFillIcon/>:<EyeOffFillIcon/>}</Button>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
{isSubmitted !== "" ? (
|
||||
|
||||
isSubmitted === "Failed" ?
|
||||
<Alert variant="danger">
|
||||
{submissionMessage}
|
||||
</Alert>
|
||||
:
|
||||
<Alert variant="success" >
|
||||
{submissionMessage}
|
||||
</Alert>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
|
||||
<Button variant="outline-success" type="submit"> Update </Button>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
|
||||
<Form className="settings-form">
|
||||
<Form.Group as={Row} className="mb-3">
|
||||
<Form.Label column className="">Require Login</Form.Label>
|
||||
<Col >
|
||||
<ToggleButtonGroup type="checkbox" className="d-flex" >
|
||||
<ToggleButton variant="outline-primary" active={use_password} onClick={()=> {togglePasswordRequired(true);}}>Yes</ToggleButton>
|
||||
<ToggleButton variant="outline-primary" active={!use_password} onClick={()=>{togglePasswordRequired(false);}}>No</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
</Form>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -10,10 +10,13 @@ import Alert from 'react-bootstrap/Alert';
|
||||
import ToggleButton from 'react-bootstrap/ToggleButton';
|
||||
import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup';
|
||||
|
||||
import EyeFillIcon from 'remixicon-react/EyeFillIcon';
|
||||
import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon';
|
||||
|
||||
|
||||
|
||||
import "../../css/settings/settings.css";
|
||||
import { ButtonGroup } from "react-bootstrap";
|
||||
import { InputGroup } from "react-bootstrap";
|
||||
|
||||
export default function SettingsConfig() {
|
||||
const [config, setConfig] = useState(null);
|
||||
@@ -142,7 +145,10 @@ export default function SettingsConfig() {
|
||||
API Key
|
||||
</Form.Label>
|
||||
<Col sm="10">
|
||||
<InputGroup>
|
||||
<Form.Control id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ""} onChange={handleFormChange} type={showKey ? "text" : "password"} />
|
||||
<Button variant="outline-primary" type="button" onClick={() => setKeyState(!showKey)}>{showKey?<EyeFillIcon/>:<EyeOffFillIcon/>}</Button>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
{isSubmitted !== "" ? (
|
||||
@@ -159,10 +165,7 @@ export default function SettingsConfig() {
|
||||
<></>
|
||||
)}
|
||||
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
|
||||
<ButtonGroup >
|
||||
<Button variant="outline-success" type="submit"> Save </Button>
|
||||
<Button variant="outline-secondary" type="button" onClick={() => setKeyState(!showKey)}>Show Key</Button>
|
||||
</ButtonGroup>
|
||||
<Button variant="outline-success" type="submit"> Save </Button>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
|
||||
@@ -59,15 +59,22 @@
|
||||
|
||||
|
||||
|
||||
.settings-form > div> div> .form-control
|
||||
.settings-form > div> div> .form-control,
|
||||
.settings-form > div> div> .input-group> .form-control
|
||||
{
|
||||
color: white !important;
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--background-color) !important;
|
||||
}
|
||||
|
||||
.settings-form > div> div> .input-group> .btn
|
||||
{
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
|
||||
.settings-form > div> div> .form-control:focus
|
||||
.settings-form > div> div> .form-control:focus,
|
||||
.settings-form > div> div> .input-group> .form-control:focus
|
||||
{
|
||||
box-shadow: none !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500&display=swap');
|
||||
@import './variables.module.css';
|
||||
/* *{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'poppins',sans-serif;
|
||||
} */
|
||||
|
||||
.login-show-password
|
||||
{
|
||||
background-color: transparent !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
section{
|
||||
display: flex;
|
||||
@@ -12,8 +19,6 @@ section{
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
|
||||
/* background: url('background6.jpg')no-repeat; */
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
@@ -41,36 +46,47 @@ h2{
|
||||
width: 310px;
|
||||
border-bottom: 2px solid #fff;
|
||||
}
|
||||
.inputbox label{
|
||||
.inputbox .form-label{
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 5px;
|
||||
transform: translateY(-50%);
|
||||
color: #fff;
|
||||
color: #fff !important;
|
||||
font-size: 1em;
|
||||
pointer-events: none;
|
||||
transition: .2s;
|
||||
}
|
||||
.form-box> form> .inputbox> input:focus ~ label,
|
||||
.form-box> form> .inputbox> input:valid ~ label{
|
||||
top: -15px;
|
||||
.inputbox input:focus ~ .form-label,
|
||||
.inputbox input:not(:placeholder-shown) ~ .form-label
|
||||
{
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
|
||||
.inputbox input:hover {
|
||||
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
}
|
||||
.inputbox input:focus {
|
||||
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.inputbox input {
|
||||
width: 100%;
|
||||
|
||||
height: 50px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1em;
|
||||
|
||||
color: #fff;
|
||||
}
|
||||
.inputbox ion-icon{
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.forget{
|
||||
margin: -15px 0 15px ;
|
||||
font-size: .9em;
|
||||
@@ -91,15 +107,27 @@ top: -15px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.setup-button{
|
||||
color: white !important;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 40px;
|
||||
background: #fff;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
border-radius: 40px !important;
|
||||
background: var(--primary-color) !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
font-size: 1em !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.setup-button:hover{
|
||||
color: black !important;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 40px !important;
|
||||
background: var(--secondary-color) !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
font-size: 1em !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.register{
|
||||
font-size: .9em;
|
||||
|
||||
@@ -6,13 +6,19 @@ import Sessions from './components/sessions/sessions'
|
||||
import HomeStatisticCards from './components/HomeStatisticCards'
|
||||
import LibraryOverView from './components/libraryOverview'
|
||||
import RecentlyAdded from './components/library/recently-added'
|
||||
import ErrorBoundary from './components/general/ErrorBoundary'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className='Home'>
|
||||
|
||||
<Sessions />
|
||||
<RecentlyAdded/>
|
||||
|
||||
<ErrorBoundary>
|
||||
<Sessions/>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<RecentlyAdded/>
|
||||
</ErrorBoundary>
|
||||
<HomeStatisticCards/>
|
||||
<LibraryOverView/>
|
||||
|
||||
|
||||
@@ -3,13 +3,22 @@ import axios from "axios";
|
||||
import Config from "../lib/config";
|
||||
import CryptoJS from 'crypto-js';
|
||||
import "./css/setup.css";
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import { InputGroup,Row } from "react-bootstrap";
|
||||
|
||||
import EyeFillIcon from 'remixicon-react/EyeFillIcon';
|
||||
import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon';
|
||||
|
||||
// import LibrarySync from "./components/settings/librarySync";
|
||||
|
||||
import Loading from './components/general/loading';
|
||||
|
||||
|
||||
function Login() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [formValues, setFormValues] = useState({});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [submitButtonText, setsubmitButtonText] = useState("Login");
|
||||
|
||||
@@ -22,41 +31,60 @@ function Login() {
|
||||
setProcessing(true);
|
||||
event.preventDefault();
|
||||
|
||||
let hashedPassword= CryptoJS.SHA3(formValues.JS_PASSWORD).toString();
|
||||
|
||||
let hashedPassword= CryptoJS.SHA3(formValues.password).toString();
|
||||
beginLogin(formValues.JS_USERNAME,hashedPassword);
|
||||
|
||||
}
|
||||
|
||||
async function beginLogin(JS_USERNAME,hashedPassword)
|
||||
{
|
||||
|
||||
axios
|
||||
.post("/auth/login", {
|
||||
username:formValues.username,
|
||||
password: hashedPassword
|
||||
.post("/auth/login", {
|
||||
username:JS_USERNAME,
|
||||
password: hashedPassword
|
||||
|
||||
}, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
}, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
|
||||
localStorage.setItem('token',response.data.token);
|
||||
|
||||
localStorage.setItem('token',response.data.token);
|
||||
setProcessing(false);
|
||||
if(JS_USERNAME)
|
||||
{
|
||||
setsubmitButtonText("Success");
|
||||
setProcessing(false);
|
||||
window.location.reload();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
let errorMessage= `Error : ${error.response.status}`;
|
||||
if (error.code === "ERR_NETWORK") {
|
||||
errorMessage = `Unable to connect to Jellyfin Server`;
|
||||
} else if (error.response.status === 401) {
|
||||
errorMessage = `Invalid Username or Password`;
|
||||
} else if (error.response.status === 404) {
|
||||
errorMessage = `Error ${error.response.status}: The requested URL was not found.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
let errorMessage= `Error : ${error.response.status}`;
|
||||
if (error.code === "ERR_NETWORK") {
|
||||
errorMessage = `Unable to connect to Jellyfin Server`;
|
||||
} else if (error.response.status === 401) {
|
||||
errorMessage = `Invalid Username or Password`;
|
||||
} else if (error.response.status === 404) {
|
||||
errorMessage = `Error ${error.response.status}: The requested URL was not found.`;
|
||||
}
|
||||
if(JS_USERNAME)
|
||||
{
|
||||
setsubmitButtonText(errorMessage);
|
||||
setProcessing(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
setProcessing(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
@@ -64,13 +92,20 @@ function Login() {
|
||||
setConfig(newConfig);
|
||||
} catch (error) {
|
||||
if (error.code === "ERR_NETWORK") {
|
||||
console.log(error);
|
||||
if (error.response.status !== 401 && error.response.status !== 403) {
|
||||
// console.log(error);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!config) {
|
||||
fetchConfig();
|
||||
beginLogin();
|
||||
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -81,38 +116,38 @@ function Login() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="form-box">
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
<h2>Login</h2>
|
||||
<div className="inputbox">
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autocomplete="on"
|
||||
value={formValues.username || ""}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
/>
|
||||
<label htmlFor="username">Username</label>
|
||||
</div>
|
||||
<div className="inputbox">
|
||||
<input
|
||||
type="text"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="on"
|
||||
value={formValues.password || ""}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
</div>
|
||||
<div className="form-box d-flex flex-column">
|
||||
<h2>Login</h2>
|
||||
|
||||
<Form onSubmit={handleFormSubmit} className="mt-5">
|
||||
<Form.Group as={Row} className="inputbox" >
|
||||
|
||||
|
||||
<Form.Control id="JS_USERNAME" name="JS_USERNAME" value={formValues.JS_USERNAME || ""} onChange={handleFormChange} placeholder=" "/>
|
||||
|
||||
<Form.Label column>
|
||||
Username
|
||||
</Form.Label>
|
||||
</Form.Group>
|
||||
|
||||
<button type="submit" className="setup-button">
|
||||
<Form.Group as={Row} className="inputbox" >
|
||||
|
||||
<InputGroup >
|
||||
<Form.Control className="px-0" id="JS_PASSWORD" name="JS_PASSWORD" value={formValues.JS_PASSWORD || ""} onChange={handleFormChange} type={showPassword ? "text" : "password"} placeholder=" " />
|
||||
<Button className="login-show-password" type="button" onClick={() => setShowPassword(!showPassword)}>{showPassword?<EyeFillIcon/>:<EyeOffFillIcon/>}</Button>
|
||||
<Form.Label column >
|
||||
Password
|
||||
</Form.Label>
|
||||
</InputGroup>
|
||||
|
||||
</Form.Group>
|
||||
|
||||
|
||||
<Button type="submit" className="setup-button">
|
||||
{processing ? "Validating..." : submitButtonText}
|
||||
</button>
|
||||
</form>
|
||||
</Button>
|
||||
|
||||
</Form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Tabs, Tab } from 'react-bootstrap';
|
||||
import SettingsConfig from "./components/settings/settingsConfig";
|
||||
import Tasks from "./components/settings/Tasks";
|
||||
import BackupFiles from "./components/settings/backupfiles";
|
||||
import SecuritySettings from "./components/settings/security";
|
||||
|
||||
import Logs from "./components/settings/logs";
|
||||
|
||||
@@ -22,9 +23,11 @@ export default function Settings() {
|
||||
|
||||
<Tab eventKey="tabGeneral" className='bg-transparent my-2' title='Settings' style={{minHeight:'500px'}}>
|
||||
<SettingsConfig/>
|
||||
<SecuritySettings/>
|
||||
|
||||
<Tasks/>
|
||||
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab eventKey="tabBackup" className='bg-transparent my-2' title='Backup' style={{minHeight:'500px'}}>
|
||||
|
||||
Reference in New Issue
Block a user