fix: admin key can be saved/restored inside of web ui.
This commit is contained in:
+138
-86
@@ -1,5 +1,6 @@
|
|||||||
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
|
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
|
||||||
import { DynamicForm } from "@app/components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@app/components/Form/DynamicForm.tsx";
|
||||||
|
import { useAppStore } from "@app/core/stores/appStore";
|
||||||
import {
|
import {
|
||||||
getX25519PrivateKey,
|
getX25519PrivateKey,
|
||||||
getX25519PublicKey,
|
getX25519PublicKey,
|
||||||
@@ -9,34 +10,73 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
|||||||
import { Protobuf } from "@meshtastic/js";
|
import { Protobuf } from "@meshtastic/js";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useReducer } from "react";
|
||||||
|
import { securityReducer } from "./securityReducer";
|
||||||
|
|
||||||
export const Security = (): JSX.Element => {
|
export const Security = () => {
|
||||||
const { config, nodes, hardware, setWorkingConfig, setDialogOpen } =
|
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
||||||
useDevice();
|
const {
|
||||||
|
hasErrors,
|
||||||
|
getErrorMessage,
|
||||||
|
hasFieldError,
|
||||||
|
addError,
|
||||||
|
removeError,
|
||||||
|
clearErrors,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
const [privateKey, setPrivateKey] = useState<string>(
|
const [state, dispatch] = useReducer(securityReducer, {
|
||||||
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
||||||
|
privateKeyVisible: false,
|
||||||
|
adminKeyVisible: false,
|
||||||
|
privateKeyBitCount: config.security?.privateKey.length ?? 32,
|
||||||
|
adminKeyBitCount: config.security?.adminKey[0].length ?? 32,
|
||||||
|
publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
||||||
|
adminKey: fromByteArray(config.security?.adminKey[0]),
|
||||||
|
privateKeyDialogOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateKey = (
|
||||||
|
input: string,
|
||||||
|
count: number,
|
||||||
|
fieldName: "privateKey" | "adminKey",
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
removeError(fieldName);
|
||||||
|
|
||||||
|
if (input === "") {
|
||||||
|
addError(
|
||||||
|
fieldName,
|
||||||
|
`${fieldName === "privateKey" ? "Private" : "Admin"} Key is required`,
|
||||||
);
|
);
|
||||||
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
|
return;
|
||||||
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
|
}
|
||||||
config.security?.privateKey.length ?? 32,
|
|
||||||
|
if (input.length % 4 !== 0) {
|
||||||
|
addError(
|
||||||
|
fieldName,
|
||||||
|
`${fieldName === "privateKey" ? "Private" : "Admin"} Key is required to 256 bit pre-shared key (PSK)`,
|
||||||
);
|
);
|
||||||
const [privateKeyValidationText, setPrivateKeyValidationText] =
|
return;
|
||||||
useState<string>();
|
}
|
||||||
const [publicKey, setPublicKey] = useState<string>(
|
|
||||||
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
const decoded = toByteArray(input);
|
||||||
|
if (decoded.length !== count) {
|
||||||
|
addError(fieldName, `Please enter a valid ${count * 8} bit PSK`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
addError(
|
||||||
|
fieldName,
|
||||||
|
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"} Key format`,
|
||||||
);
|
);
|
||||||
const [adminKey, setAdminKey] = useState<string>(
|
}
|
||||||
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
|
};
|
||||||
);
|
|
||||||
const [adminKeyValidationText, setAdminKeyValidationText] =
|
|
||||||
useState<string>();
|
|
||||||
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
const onSubmit = (data: SecurityValidation) => {
|
const onSubmit = (data: SecurityValidation) => {
|
||||||
if (privateKeyValidationText || adminKeyValidationText) return;
|
if (hasErrors()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
new Protobuf.Config.Config({
|
new Protobuf.Config.Config({
|
||||||
@@ -44,82 +84,56 @@ export const Security = (): JSX.Element => {
|
|||||||
case: "security",
|
case: "security",
|
||||||
value: {
|
value: {
|
||||||
...data,
|
...data,
|
||||||
adminKey: [toByteArray(adminKey)],
|
adminKey: [toByteArray(state.adminKey)],
|
||||||
privateKey: toByteArray(privateKey),
|
privateKey: toByteArray(state.privateKey),
|
||||||
publicKey: toByteArray(publicKey),
|
publicKey: toByteArray(state.publicKey),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateKey = (
|
|
||||||
input: string,
|
|
||||||
count: number,
|
|
||||||
setValidationText: (
|
|
||||||
value: React.SetStateAction<string | undefined>,
|
|
||||||
) => void,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
|
|
||||||
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
|
||||||
} else {
|
|
||||||
setValidationText(undefined);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const privateKeyClickEvent = () => {
|
|
||||||
setPrivateKeyDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkiBackupClickEvent = () => {
|
|
||||||
setDialogOpen("pkiBackup", true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkiRegenerate = () => {
|
const pkiRegenerate = () => {
|
||||||
|
clearErrors();
|
||||||
const privateKey = getX25519PrivateKey();
|
const privateKey = getX25519PrivateKey();
|
||||||
const publicKey = getX25519PublicKey(privateKey);
|
const publicKey = getX25519PublicKey(privateKey);
|
||||||
|
|
||||||
setPrivateKey(fromByteArray(privateKey));
|
dispatch({
|
||||||
setPublicKey(fromByteArray(publicKey));
|
type: "REGENERATE_PRIV_PUB_KEY",
|
||||||
|
payload: {
|
||||||
|
privateKey: fromByteArray(privateKey),
|
||||||
|
publicKey: fromByteArray(publicKey),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
validateKey(
|
validateKey(
|
||||||
fromByteArray(privateKey),
|
fromByteArray(privateKey),
|
||||||
privateKeyBitCount,
|
state.privateKeyBitCount,
|
||||||
setPrivateKeyValidationText,
|
"privateKey",
|
||||||
);
|
);
|
||||||
|
|
||||||
setPrivateKeyDialogOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const privateKeyInputChangeEvent = (
|
const privateKeyInputChangeEvent = (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
const privateKeyB64String = e.target.value;
|
const privateKeyB64String = e.target.value;
|
||||||
setPrivateKey(privateKeyB64String);
|
dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String });
|
||||||
validateKey(
|
validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey");
|
||||||
privateKeyB64String,
|
|
||||||
privateKeyBitCount,
|
|
||||||
setPrivateKeyValidationText,
|
|
||||||
);
|
|
||||||
|
|
||||||
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
|
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
|
||||||
setPublicKey(fromByteArray(publicKey));
|
dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const psk = e.currentTarget?.value;
|
const psk = e.currentTarget?.value;
|
||||||
setAdminKey(psk);
|
dispatch({ type: "SET_ADMIN_KEY", payload: psk });
|
||||||
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
|
validateKey(psk, state.privateKeyBitCount, "adminKey");
|
||||||
};
|
};
|
||||||
|
|
||||||
const privateKeySelectChangeEvent = (e: string) => {
|
const privateKeySelectChangeEvent = (e: string) => {
|
||||||
const count = Number.parseInt(e);
|
const count = Number.parseInt(e);
|
||||||
setPrivateKeyBitCount(count);
|
dispatch({ type: "SET_PRIVATE_KEY_BIT_COUNT", payload: count });
|
||||||
validateKey(privateKey, count, setPrivateKeyValidationText);
|
validateKey(state.privateKey, count, "privateKey");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,9 +144,9 @@ export const Security = (): JSX.Element => {
|
|||||||
defaultValues={{
|
defaultValues={{
|
||||||
...config.security,
|
...config.security,
|
||||||
...{
|
...{
|
||||||
adminKey: adminKey,
|
adminKey: state.adminKey,
|
||||||
privateKey: privateKey,
|
privateKey: state.privateKey,
|
||||||
publicKey: publicKey,
|
publicKey: state.publicKey,
|
||||||
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
||||||
isManaged: config.security?.isManaged ?? false,
|
isManaged: config.security?.isManaged ?? false,
|
||||||
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
||||||
@@ -150,28 +164,35 @@ export const Security = (): JSX.Element => {
|
|||||||
label: "Private Key",
|
label: "Private Key",
|
||||||
description: "Used to create a shared key with a remote device",
|
description: "Used to create a shared key with a remote device",
|
||||||
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
||||||
validationText: privateKeyValidationText,
|
validationText: hasFieldError("privateKey")
|
||||||
devicePSKBitCount: privateKeyBitCount,
|
? getErrorMessage("privateKey")
|
||||||
|
: "",
|
||||||
|
devicePSKBitCount: state.privateKeyBitCount,
|
||||||
inputChange: privateKeyInputChangeEvent,
|
inputChange: privateKeyInputChangeEvent,
|
||||||
selectChange: privateKeySelectChangeEvent,
|
selectChange: privateKeySelectChangeEvent,
|
||||||
hide: !privateKeyVisible,
|
hide: !state.privateKeyVisible,
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
{
|
{
|
||||||
text: "Generate",
|
text: "Generate",
|
||||||
onClick: privateKeyClickEvent,
|
onClick: () =>
|
||||||
|
dispatch({
|
||||||
|
type: "SHOW_PRIVATE_KEY_DIALOG",
|
||||||
|
payload: true,
|
||||||
|
}),
|
||||||
variant: "success",
|
variant: "success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Backup Key",
|
text: "Backup Key",
|
||||||
onClick: pkiBackupClickEvent,
|
onClick: () => setDialogOpen("pkiBackup", true),
|
||||||
variant: "subtle",
|
variant: "subtle",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: privateKey,
|
value: state.privateKey,
|
||||||
action: {
|
action: {
|
||||||
icon: privateKeyVisible ? EyeOff : Eye,
|
icon: state.privateKeyVisible ? EyeOff : Eye,
|
||||||
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
|
onClick: () =>
|
||||||
|
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -183,7 +204,7 @@ export const Security = (): JSX.Element => {
|
|||||||
description:
|
description:
|
||||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
||||||
properties: {
|
properties: {
|
||||||
value: publicKey,
|
value: state.publicKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -207,18 +228,47 @@ export const Security = (): JSX.Element => {
|
|||||||
"If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.",
|
"If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "passwordGenerator",
|
||||||
name: "adminKey",
|
name: "adminKey",
|
||||||
label: "Admin Key",
|
label: "Admin Key",
|
||||||
description:
|
description:
|
||||||
"The public key authorized to send admin messages to this node",
|
"The public key authorized to send admin messages to this node",
|
||||||
validationText: adminKeyValidationText,
|
validationText: hasFieldError("adminKey")
|
||||||
|
? getErrorMessage("adminKey")
|
||||||
|
: "",
|
||||||
inputChange: adminKeyInputChangeEvent,
|
inputChange: adminKeyInputChangeEvent,
|
||||||
|
selectChange: () => {},
|
||||||
|
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
||||||
|
devicePSKBitCount: state.privateKeyBitCount,
|
||||||
|
hide: !state.adminKeyVisible,
|
||||||
|
actionButtons: [
|
||||||
|
{
|
||||||
|
text: "Generate",
|
||||||
|
variant: "success",
|
||||||
|
onClick: () => {
|
||||||
|
const adminKey = getX25519PrivateKey();
|
||||||
|
dispatch({
|
||||||
|
type: "REGENERATE_ADMIN_KEY",
|
||||||
|
payload: { adminKey: fromByteArray(adminKey) },
|
||||||
|
});
|
||||||
|
validateKey(
|
||||||
|
fromByteArray(adminKey),
|
||||||
|
state.adminKeyBitCount,
|
||||||
|
"adminKey",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{ fieldName: "adminChannelEnabled", invert: true },
|
{ fieldName: "adminChannelEnabled", invert: true },
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: adminKey,
|
value: state.adminKey,
|
||||||
|
action: {
|
||||||
|
icon: state.adminKeyVisible ? EyeOff : Eye,
|
||||||
|
onClick: () =>
|
||||||
|
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -245,9 +295,11 @@ export const Security = (): JSX.Element => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PkiRegenerateDialog
|
<PkiRegenerateDialog
|
||||||
open={privateKeyDialogOpen}
|
open={state.privateKeyDialogOpen}
|
||||||
onOpenChange={() => setPrivateKeyDialogOpen(false)}
|
onOpenChange={() =>
|
||||||
onSubmit={() => pkiRegenerate()}
|
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })
|
||||||
|
}
|
||||||
|
onSubmit={pkiRegenerate}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SecurityAction, SecurityState } from "./types";
|
||||||
|
|
||||||
|
export function securityReducer(
|
||||||
|
state: SecurityState,
|
||||||
|
action: SecurityAction,
|
||||||
|
): SecurityState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_PRIVATE_KEY":
|
||||||
|
return { ...state, privateKey: action.payload };
|
||||||
|
case "TOGGLE_PRIVATE_KEY_VISIBILITY":
|
||||||
|
return { ...state, privateKeyVisible: !state.privateKeyVisible };
|
||||||
|
case "TOGGLE_ADMIN_KEY_VISIBILITY":
|
||||||
|
return { ...state, adminKeyVisible: !state.adminKeyVisible };
|
||||||
|
case "SET_PRIVATE_KEY_BIT_COUNT":
|
||||||
|
return { ...state, privateKeyBitCount: action.payload };
|
||||||
|
case "SET_PUBLIC_KEY":
|
||||||
|
return { ...state, publicKey: action.payload };
|
||||||
|
case "SET_ADMIN_KEY":
|
||||||
|
return { ...state, adminKey: action.payload };
|
||||||
|
case "SHOW_PRIVATE_KEY_DIALOG":
|
||||||
|
return { ...state, privateKeyDialogOpen: action.payload };
|
||||||
|
case "REGENERATE_PRIV_PUB_KEY":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
privateKey: action.payload.privateKey,
|
||||||
|
publicKey: action.payload.publicKey,
|
||||||
|
privateKeyDialogOpen: false,
|
||||||
|
};
|
||||||
|
case "REGENERATE_ADMIN_KEY":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
adminKey: action.payload.adminKey,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface SecurityState {
|
||||||
|
privateKey: string;
|
||||||
|
privateKeyVisible: boolean;
|
||||||
|
adminKeyVisible: boolean;
|
||||||
|
privateKeyBitCount: number;
|
||||||
|
adminKeyBitCount: number;
|
||||||
|
publicKey: string;
|
||||||
|
adminKey: string;
|
||||||
|
privateKeyDialogOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SecurityAction =
|
||||||
|
| { type: "SET_PRIVATE_KEY"; payload: string }
|
||||||
|
| { type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }
|
||||||
|
| { type: "TOGGLE_ADMIN_KEY_VISIBILITY" }
|
||||||
|
| { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number }
|
||||||
|
| { type: "SET_PUBLIC_KEY"; payload: string }
|
||||||
|
| { type: "SET_ADMIN_KEY"; payload: string }
|
||||||
|
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean }
|
||||||
|
| {
|
||||||
|
type: "REGENERATE_PRIV_PUB_KEY";
|
||||||
|
payload: { privateKey: string; publicKey: string };
|
||||||
|
}
|
||||||
|
| { type: "REGENERATE_ADMIN_KEY"; payload: { adminKey: string } };
|
||||||
@@ -17,7 +17,7 @@ const buttonVariants = cva(
|
|||||||
outline:
|
outline:
|
||||||
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
|
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
|
||||||
subtle:
|
subtle:
|
||||||
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 dark:bg-slate-700 dark:text-slate-100",
|
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
|
||||||
ghost:
|
ghost:
|
||||||
"bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
|
"bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
|
||||||
link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent",
|
link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent",
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const DialogDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-slate-500", "dark:text-slate-400", className)}
|
className={cn("text-sm text-slate-700", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
|||||||
import { Network } from "@components/PageComponents/Config/Network.tsx";
|
import { Network } from "@components/PageComponents/Config/Network.tsx";
|
||||||
import { Position } from "@components/PageComponents/Config/Position.tsx";
|
import { Position } from "@components/PageComponents/Config/Position.tsx";
|
||||||
import { Power } from "@components/PageComponents/Config/Power.tsx";
|
import { Power } from "@components/PageComponents/Config/Power.tsx";
|
||||||
import { Security } from "@components/PageComponents/Config/Security.tsx";
|
import { Security } from "@components/PageComponents/Config/Security/Security";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
|
|||||||
Reference in New Issue
Block a user