feat: add navigation and user settings
This commit is contained in:
parent
71b9fc22b7
commit
ed8df13b2f
4
www/src/data/constants.js
Normal file
4
www/src/data/constants.js
Normal file
@ -0,0 +1,4 @@
|
||||
export const DEVICES_VIEW = "devices";
|
||||
export const REMOTES_VIEW = "remotes";
|
||||
export const RECORDINGS_VIEW = "recordings";
|
||||
export const SETTINGS_VIEW = "settings";
|
||||
132
www/src/modals/create-user-modal.jsx
Normal file
132
www/src/modals/create-user-modal.jsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import Modal from "./modal.jsx";
|
||||
import UserService from "../services/user-service.js";
|
||||
import EventEmitter from "../tools/event-emitter.js";
|
||||
import ValidatedTextInput from "../modules/validated-text-input.jsx";
|
||||
import ModalHandler from "./modal-handler.js";
|
||||
|
||||
const [users, setUsers] = createSignal([]);
|
||||
const eventEmitter = new EventEmitter();
|
||||
const USER_CREATED_EVENT = "success";
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
function CreateUserModal(props) {
|
||||
const [username, setUsername] = createSignal("");
|
||||
const [isUsernameValid, setUsernameValid] = createSignal(true);
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [isPasswordValid, setPasswordValid] = createSignal(true);
|
||||
const [isAdmin, setAdmin] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
|
||||
createEffect(() => setUsernameValid(checkIfUsernameValid(username())));
|
||||
createEffect(() => setPasswordValid(checkIfPasswordValid(password())));
|
||||
|
||||
function checkIfUsernameValid(name) {
|
||||
for (let user of users()) {
|
||||
if (user.getUsername() !== name) continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkIfPasswordValid(password) {
|
||||
return password.length >= MIN_PASSWORD_LENGTH;
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
let user;
|
||||
try {
|
||||
user = await UserService.createUser({
|
||||
username: username(),
|
||||
password: password(),
|
||||
isAdmin: isAdmin(),
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return;
|
||||
}
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
CreateUserModal.Handler.hide();
|
||||
eventEmitter.dispatchEvent(USER_CREATED_EVENT, user);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={props.ref}
|
||||
id="createUserModal"
|
||||
modalTitle="New User"
|
||||
centered={true}
|
||||
>
|
||||
<div class="modal-body">
|
||||
<Show when={error() !== ""}>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-3 row">
|
||||
<label for="new_username" class="col-form-label col-sm-2">
|
||||
Username
|
||||
</label>
|
||||
<ValidatedTextInput
|
||||
class="col-sm-10"
|
||||
id="new_username"
|
||||
valid={isUsernameValid()}
|
||||
value={username()}
|
||||
onInput={(e) => setUsername(e.target.value)}
|
||||
errorText={"Username already exists"}
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 row">
|
||||
<label for="user_password" class="col-form-label col-sm-2">
|
||||
Password
|
||||
</label>
|
||||
<ValidatedTextInput
|
||||
class="col-sm-10"
|
||||
id="user_password"
|
||||
type="password"
|
||||
valid={isPasswordValid()}
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.target.value)}
|
||||
errorText={"Invalid password"}
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 row">
|
||||
<label for="is_admin" class="col-form-label col-sm-2">
|
||||
Admin
|
||||
</label>
|
||||
<div class="col-sm-10 d-flex align-items-center">
|
||||
<input
|
||||
id="is_admin"
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
checked={isAdmin()}
|
||||
onChange={() => setAdmin(!isAdmin())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateUser}
|
||||
class="btn btn-primary"
|
||||
disabled={!isUsernameValid() && !isPasswordValid()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CreateUserModal.Handler = new ModalHandler();
|
||||
CreateUserModal.setUsers = setUsers;
|
||||
CreateUserModal.onUserCreated = (callback) =>
|
||||
eventEmitter.on(USER_CREATED_EVENT, callback);
|
||||
|
||||
export default CreateUserModal;
|
||||
88
www/src/modals/delete-user-modal.jsx
Normal file
88
www/src/modals/delete-user-modal.jsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import Modal from "./modal.jsx";
|
||||
import UserService from "../services/user-service.js";
|
||||
import EventEmitter from "../tools/event-emitter.js";
|
||||
import User from "../data/user.js";
|
||||
import ValidatedTextInput from "../modules/validated-text-input.jsx";
|
||||
import ModalHandler from "./modal-handler.js";
|
||||
|
||||
const [user, setUser] = createSignal(new User());
|
||||
const eventEmitter = new EventEmitter();
|
||||
const USER_DELETED_EVENT = "success";
|
||||
|
||||
function DeleteUserModal(props) {
|
||||
const [username, setUsername] = createSignal("");
|
||||
const [isUsernameValid, setUsernameValid] = createSignal(true);
|
||||
const [error, setError] = createSignal("");
|
||||
|
||||
createEffect(() => setUsernameValid(checkIfUsernameValid(username())));
|
||||
|
||||
function checkIfUsernameValid(name) {
|
||||
return name === user().getUsername();
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
try {
|
||||
await UserService.deleteUser(user().getId());
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
throw e;
|
||||
}
|
||||
DeleteUserModal.Handler.hide();
|
||||
eventEmitter.dispatchEvent(USER_DELETED_EVENT, user);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={props.ref}
|
||||
id="deleteUserModal"
|
||||
modalTitle="Delete user"
|
||||
centered={true}
|
||||
>
|
||||
<div class="modal-body">
|
||||
<Show when={error() !== ""}>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-3 row">
|
||||
<span>
|
||||
Do you really want to delete the user {user().getUsername()}?
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="del_username" class="col-form-label">
|
||||
Type <b>{user().getUsername()}</b> to confirm:
|
||||
</label>
|
||||
<ValidatedTextInput
|
||||
id="del_username"
|
||||
valid={isUsernameValid()}
|
||||
value={username()}
|
||||
onInput={(e) => setUsername(e.target.value)}
|
||||
errorText={"Wrong input"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteUser}
|
||||
class="btn btn-danger"
|
||||
disabled={!isUsernameValid()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
DeleteUserModal.Handler = new ModalHandler();
|
||||
DeleteUserModal.setUser = setUser;
|
||||
DeleteUserModal.onUserDeleted = (callback) =>
|
||||
eventEmitter.on(USER_DELETED_EVENT, callback);
|
||||
|
||||
export default DeleteUserModal;
|
||||
31
www/src/modals/modal-handler.js
Normal file
31
www/src/modals/modal-handler.js
Normal file
@ -0,0 +1,31 @@
|
||||
function ModalHandler() {
|
||||
let _ref;
|
||||
let _modalRef;
|
||||
let _modalId;
|
||||
|
||||
function setRef(ref) {
|
||||
_ref = ref;
|
||||
_modalRef = new bootstrap.Modal(ref);
|
||||
}
|
||||
|
||||
function show() {
|
||||
_modalRef.show();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
_modalRef.hide();
|
||||
}
|
||||
|
||||
function setModalId(modalId) {
|
||||
_modalId = modalId;
|
||||
}
|
||||
|
||||
return {
|
||||
setRef,
|
||||
show,
|
||||
hide,
|
||||
setModalId,
|
||||
};
|
||||
}
|
||||
|
||||
export default ModalHandler;
|
||||
49
www/src/modals/modal-registry.jsx
Normal file
49
www/src/modals/modal-registry.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { onMount } from "solid-js";
|
||||
|
||||
import CreateUserModal from "./create-user-modal.jsx";
|
||||
import DeleteUserModal from "./delete-user-modal.jsx";
|
||||
import UserSettingsModal from "./user-settings-modal.jsx";
|
||||
|
||||
const ModalRegistry = (function () {
|
||||
const modals = [
|
||||
{
|
||||
id: "createUserModal",
|
||||
component: CreateUserModal,
|
||||
ref: null,
|
||||
},
|
||||
{
|
||||
id: "deleteUserModal",
|
||||
component: DeleteUserModal,
|
||||
ref: null,
|
||||
},
|
||||
{
|
||||
id: "userSettingsModal",
|
||||
component: UserSettingsModal,
|
||||
ref: null,
|
||||
},
|
||||
];
|
||||
|
||||
function getModals(props) {
|
||||
return function () {
|
||||
onMount(() => {
|
||||
modals.forEach((modal) => {
|
||||
modal.component.Handler.setRef(modal.ref);
|
||||
modal.component.Handler.setModalId(modal.id);
|
||||
});
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<For each={modals}>
|
||||
{(modal) => <modal.component ref={modal.ref} />}
|
||||
</For>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getModals,
|
||||
};
|
||||
})();
|
||||
|
||||
export default ModalRegistry;
|
||||
45
www/src/modals/modal.jsx
Normal file
45
www/src/modals/modal.jsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { mergeProps } from "solid-js";
|
||||
|
||||
function Modal(props) {
|
||||
props = mergeProps(
|
||||
{ size: "", id: "modal", modalTitle: "Modal", centered: false },
|
||||
props
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={"modal fade " + props.size}
|
||||
id={props.id}
|
||||
tabindex="-1"
|
||||
aria-labelledby={props.id + "Label"}
|
||||
aria-hidden="true"
|
||||
ref={props.ref}
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="modal-dialog modal-dialog-scrollable modal-fullscreen-md-down modal-lg"
|
||||
classList={{ "modal-dialog-centered": props.centered }}
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
style={props.fullHeight ? "height: 100%" : ""}
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id={props.id + "Label"}>
|
||||
{props.modalTitle}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Schliessen"
|
||||
/>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
88
www/src/modals/user-settings-modal.jsx
Normal file
88
www/src/modals/user-settings-modal.jsx
Normal file
@ -0,0 +1,88 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import EventEmitter from "../tools/event-emitter.js";
|
||||
import User from "../data/user.js";
|
||||
import Modal from "./modal.jsx";
|
||||
import ValidatedTextInput from "../modules/validated-text-input.jsx";
|
||||
import ModalHandler from "./modal-handler.js";
|
||||
import UserService from "../services/user-service.js";
|
||||
|
||||
const [user, setUser] = createSignal(new User());
|
||||
const eventEmitter = new EventEmitter();
|
||||
const USER_UPDATED_EVENT = "user-updated";
|
||||
|
||||
function UserSettingsModal(props) {
|
||||
const [isAdmin, setAdmin] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
const [userInfo] = createResource(() => UserService.getUserInfo());
|
||||
let canEditAdmin = createMemo(
|
||||
() => userInfo.loading || user().getId() !== userInfo().user_id
|
||||
);
|
||||
createEffect(() => setAdmin(user().isAdmin()));
|
||||
|
||||
async function handleSaveSettings() {
|
||||
try {
|
||||
await UserService.updateUser(user().getId(), { isAdmin: isAdmin() });
|
||||
} catch (error) {
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
UserSettingsModal.Handler.hide();
|
||||
eventEmitter.dispatchEvent(USER_UPDATED_EVENT, user());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={props.ref}
|
||||
id="userSettingsModal"
|
||||
modalTitle={`Settings for ${user().getUsername()}`}
|
||||
centered={true}
|
||||
>
|
||||
<div class="modal-body">
|
||||
<Show when={error() !== ""}>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-3 row">
|
||||
<label for="is_admin" class="col-form-label col-sm-2">
|
||||
Admin
|
||||
</label>
|
||||
<div class="col-sm-10 d-flex align-items-center">
|
||||
<input
|
||||
id="is_admin"
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
checked={isAdmin()}
|
||||
disabled={!canEditAdmin()}
|
||||
onChange={() => setAdmin(!isAdmin())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveSettings}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
UserSettingsModal.Handler = new ModalHandler();
|
||||
UserSettingsModal.setUser = setUser;
|
||||
UserSettingsModal.onUserUpdated = (callback) =>
|
||||
eventEmitter.on(USER_UPDATED_EVENT, callback);
|
||||
|
||||
export default UserSettingsModal;
|
||||
211
www/src/modules/list.jsx
Normal file
211
www/src/modules/list.jsx
Normal file
@ -0,0 +1,211 @@
|
||||
import {
|
||||
Match,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
mergeProps,
|
||||
} from "solid-js";
|
||||
|
||||
function List(props) {
|
||||
props = mergeProps({ items: [], showHeader: true, selectable: true }, props);
|
||||
const [listItems, setListItems] = createSignal([]);
|
||||
const selectedItems = createMemo(() =>
|
||||
listItems()
|
||||
.filter((item) => item.selected())
|
||||
.map((item) => item.item)
|
||||
);
|
||||
const allItemsSelected = createMemo(() =>
|
||||
listItems().every((item) => item.selected())
|
||||
);
|
||||
createEffect(() => {
|
||||
setListItems(
|
||||
props.items.map((item) => {
|
||||
const [selected, setSelected] = createSignal(false);
|
||||
return {
|
||||
selected,
|
||||
setSelected,
|
||||
item: item,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
createEffect(() => {
|
||||
if (!props.onListItemsSelect) return;
|
||||
props.onListItemsSelect(selectedItems());
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (props.onLazyLoad) {
|
||||
props.onLazyLoad();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const [lazyLoadTriggerElement, setLazyLoadTriggerElement] = createSignal();
|
||||
createEffect(() => {
|
||||
observer.observe(lazyLoadTriggerElement());
|
||||
});
|
||||
|
||||
const [scrollTop, setScrollTop] = createSignal(0);
|
||||
const itemsMutationObserver = new MutationObserver(() => {
|
||||
itemsContainerElement().scrollTop = scrollTop();
|
||||
});
|
||||
const [itemsContainerElement, setItemsContainerElement] = createSignal();
|
||||
createEffect(() => {
|
||||
if (!itemsContainerElement()) return;
|
||||
itemsMutationObserver.observe(itemsContainerElement(), { childList: true });
|
||||
});
|
||||
|
||||
function handleListItemClick(item) {
|
||||
if (!props.onListItemClick) return;
|
||||
props.onListItemClick(item);
|
||||
}
|
||||
|
||||
function handleToggleAllItems() {
|
||||
if (allItemsSelected()) {
|
||||
listItems().forEach((item) => item.setSelected(false));
|
||||
return;
|
||||
}
|
||||
listItems().forEach((item) => item.setSelected(true));
|
||||
}
|
||||
|
||||
function renderHeaderRow(columns) {
|
||||
columns = columns.filter((column) => !column.hidden);
|
||||
if (!props.showHeader) return null;
|
||||
return (
|
||||
<div class="list-group-item d-flex p-0">
|
||||
<div class="d-flex flex-fill border-bottom border-2 fw-bold overflow-y-scroll">
|
||||
<Show when={props.selectable}>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center border-end py-2 px-3"
|
||||
onClick={() => handleToggleAllItems()}
|
||||
>
|
||||
<input
|
||||
class="form-check-input m-0"
|
||||
type="checkbox"
|
||||
checked={allItemsSelected()}
|
||||
id={""}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={columns}>
|
||||
{(column) => (
|
||||
<div
|
||||
class={"d-flex p-2" + (column.width ? "" : " flex-fill")}
|
||||
style={column.width ? `width:${column.width}em` : ""}
|
||||
>
|
||||
<Show when={column.withIcons}>
|
||||
<div class="pe-2" style="color: transparent">
|
||||
<i class="bi-folder2" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="text-truncate">
|
||||
<span>{column.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderListItem(listItem, columns) {
|
||||
columns = columns.filter((column) => !column.hidden);
|
||||
let item = listItem.item;
|
||||
return (
|
||||
<a
|
||||
role="button"
|
||||
class="list-group-item list-group-item-action d-flex p-0 overflow-x-hidden border-0 border-bottom border-1"
|
||||
style="max-width: 100%"
|
||||
>
|
||||
<Show when={props.selectable}>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center border-end py-2 px-3"
|
||||
onClick={() => listItem.setSelected(!listItem.selected())}
|
||||
>
|
||||
<input
|
||||
class="form-check-input m-0"
|
||||
type="checkbox"
|
||||
checked={listItem.selected()}
|
||||
id={""}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="d-flex flex-fill overflow-hidden"
|
||||
onClick={() => handleListItemClick(item)}
|
||||
>
|
||||
<For each={columns}>
|
||||
{(column) => (
|
||||
<div
|
||||
class={
|
||||
"d-flex p-2 align-items-center" +
|
||||
(column.width ? " flex-shrink-0" : " flex-fill")
|
||||
}
|
||||
style={
|
||||
"overflow: hidden; " +
|
||||
(column.width ? `width:${column.width}em` : "")
|
||||
}
|
||||
>
|
||||
<Show when={column.withIcons}>
|
||||
<Show when={!item[column.id].icon}>
|
||||
<div class="pe-2" style="color: transparent">
|
||||
<i class="bi-folder2" />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={item[column.id].icon}>
|
||||
<div class="pe-2">
|
||||
<i class={item[column.id].icon} />
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={item[column.id].html}>
|
||||
{item[column.id].html}
|
||||
</Match>
|
||||
<Match when={item[column.id].text}>
|
||||
<div class="text-truncate">
|
||||
<span>{item[column.id]?.text}</span>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={props.style}
|
||||
class={
|
||||
"list-group d-flex flex-column overflow-hidden " + (props.class || "")
|
||||
}
|
||||
>
|
||||
{renderHeaderRow(props.columns)}
|
||||
<div
|
||||
class="list-group-item p-0 flex-fill overflow-y-scroll"
|
||||
style="min-width: 0"
|
||||
onScroll={(event) => setScrollTop(event.target.scrollTop)}
|
||||
ref={setItemsContainerElement}
|
||||
>
|
||||
<For each={listItems()}>
|
||||
{(listItem) => renderListItem(listItem, props.columns)}
|
||||
</For>
|
||||
<div
|
||||
style="width: 100%; height: 3em;"
|
||||
ref={setLazyLoadTriggerElement}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default List;
|
||||
127
www/src/modules/settings/change-password.jsx
Normal file
127
www/src/modules/settings/change-password.jsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import UserService from "../../services/user-service";
|
||||
import ValidatedTextInput from "../validated-text-input";
|
||||
|
||||
function ChangePasswordSettings() {
|
||||
const PASSWORD_MIN_LENGTH = 6;
|
||||
|
||||
const [error, setError] = createSignal("");
|
||||
const [success, setSuccess] = createSignal(false);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = createSignal("");
|
||||
const [newPassword, setNewPassword] = createSignal("");
|
||||
const [newPasswordRepeat, setNewPasswordRepeat] = createSignal("");
|
||||
|
||||
const [isCurrentPasswordValid, setCurrentPasswordValid] = createSignal(true);
|
||||
const [isNewPasswordValid, setNewPasswordValid] = createSignal(true);
|
||||
const [isNewPasswordRepeatValid, setNewPasswordRepeatValid] =
|
||||
createSignal(true);
|
||||
const [isValid, setValid] = createSignal(false);
|
||||
|
||||
const [currentPasswordActive, setCurrentPasswordActive] = createSignal(false);
|
||||
const [newPasswordRepeatActive, setNewPasswordRepeatActive] =
|
||||
createSignal(false);
|
||||
|
||||
createEffect(() =>
|
||||
setValid(
|
||||
isCurrentPasswordValid() &&
|
||||
isNewPasswordValid() &&
|
||||
isNewPasswordRepeatValid()
|
||||
)
|
||||
);
|
||||
createEffect(() => setCurrentPasswordValid(currentPassword().length > 0));
|
||||
createEffect(() =>
|
||||
setNewPasswordValid(newPassword().length >= PASSWORD_MIN_LENGTH)
|
||||
);
|
||||
createEffect(() =>
|
||||
setNewPasswordRepeatValid(newPassword() === newPasswordRepeat())
|
||||
);
|
||||
createEffect(() => (error() ? setSuccess(false) : null));
|
||||
createEffect(() =>
|
||||
success() ? setTimeout(() => setSuccess(false), 5000) : null
|
||||
);
|
||||
|
||||
async function handleChangePassword() {
|
||||
try {
|
||||
await UserService.changePassword({
|
||||
currentPassword: currentPassword(),
|
||||
newPassword: newPassword(),
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (error) {
|
||||
setError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="border rounded bg-dark p-3">
|
||||
<div
|
||||
class={"alert alert-danger" + (error() ? "" : " d-none")}
|
||||
role="alert"
|
||||
>
|
||||
{error()}
|
||||
</div>
|
||||
<div
|
||||
class={"alert alert-success" + (success() ? "" : " d-none")}
|
||||
role="alert"
|
||||
>
|
||||
Changing password successful.
|
||||
</div>
|
||||
<div class="fw-bold mb-3">Change password</div>
|
||||
<div class="row mb-3">
|
||||
<label for="currentPassword" class="col-sm-2 col-form-label">
|
||||
Current Password
|
||||
</label>
|
||||
<ValidatedTextInput
|
||||
class="col-sm-10"
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
valid={isCurrentPasswordValid()}
|
||||
value={currentPassword()}
|
||||
onInput={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label for="newPassword" class="col-sm-2 col-form-label">
|
||||
New Password
|
||||
</label>
|
||||
<ValidatedTextInput
|
||||
class="col-sm-10"
|
||||
id="newPassword"
|
||||
type="password"
|
||||
valid={isNewPasswordValid()}
|
||||
value={newPassword()}
|
||||
onInput={(e) => setNewPassword(e.target.value)}
|
||||
errorText={`Neues Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen lang
|
||||
sein.`}
|
||||
/>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label for="newPasswordRepeat" class="col-sm-2 col-form-label">
|
||||
New Passwort repeat
|
||||
</label>
|
||||
<ValidatedTextInput
|
||||
class="col-sm-10"
|
||||
id="newPasswordRepeat"
|
||||
type="password"
|
||||
valid={isNewPasswordRepeatValid()}
|
||||
value={newPasswordRepeat()}
|
||||
onInput={(e) => setNewPasswordRepeat(e.target.value)}
|
||||
errorText={"Passwörter müssen übereinstimmen"}
|
||||
/>
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
disabled={!isValid()}
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangePasswordSettings;
|
||||
80
www/src/modules/settings/users-list.jsx
Normal file
80
www/src/modules/settings/users-list.jsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { createSignal, onMount } from "solid-js";
|
||||
import List from "../list.jsx";
|
||||
import UserService from "../../services/user-service.js";
|
||||
import CreateUserModal from "../../modals/create-user-modal.jsx";
|
||||
import DeleteUserModal from "../../modals/delete-user-modal.jsx";
|
||||
import UserSettingsModal from "../../modals/user-settings-modal.jsx";
|
||||
|
||||
function UserList() {
|
||||
const [users, setUsers] = createSignal([]);
|
||||
|
||||
onMount(() => {
|
||||
updateUsers();
|
||||
CreateUserModal.onUserCreated(() => updateUsers());
|
||||
UserSettingsModal.onUserUpdated(() => updateUsers());
|
||||
DeleteUserModal.onUserDeleted(() => updateUsers());
|
||||
});
|
||||
|
||||
async function updateUsers() {
|
||||
let users = await UserService.getUsers();
|
||||
setUsers(users);
|
||||
}
|
||||
|
||||
function handleShowUserSettings(user) {
|
||||
UserSettingsModal.setUser(user);
|
||||
UserSettingsModal.Handler.show();
|
||||
}
|
||||
|
||||
function handleCreateUser() {
|
||||
CreateUserModal.setUsers(users());
|
||||
CreateUserModal.Handler.show();
|
||||
}
|
||||
|
||||
function handleDeleteUser(user) {
|
||||
DeleteUserModal.setUser(user);
|
||||
DeleteUserModal.Handler.show();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="d-flex flex-row justify-content-end">
|
||||
<button class="btn btn-dark me-2 mb-3" onClick={handleCreateUser}>
|
||||
<i class="bi bi-plus me-2"></i>
|
||||
New User
|
||||
</button>
|
||||
</div>
|
||||
<List
|
||||
class="h-100"
|
||||
columns={[
|
||||
{ id: "username", name: "Username" },
|
||||
{ id: "is_admin", name: "Admin", width: 10 },
|
||||
{ id: "options", name: "Options", width: 10 },
|
||||
]}
|
||||
items={users().map((user) => ({
|
||||
username: { text: user.getUsername() },
|
||||
is_admin: { text: user.isAdmin() ? "Yes" : "No" },
|
||||
options: {
|
||||
html: (
|
||||
<>
|
||||
<button
|
||||
class="btn btn-outline-secondary me-2"
|
||||
onClick={() => handleShowUserSettings(user)}
|
||||
>
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary me-2"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
>
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserList;
|
||||
33
www/src/modules/validated-text-input.jsx
Normal file
33
www/src/modules/validated-text-input.jsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { createSignal, mergeProps } from "solid-js";
|
||||
|
||||
function ValidatedTextInput(props) {
|
||||
props = mergeProps(
|
||||
{ type: "text", valid: true, onInput: () => {}, errorText: "" },
|
||||
props
|
||||
);
|
||||
let [isActive, setActive] = createSignal(false);
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<input
|
||||
type={props.type}
|
||||
class={
|
||||
"form-control" + (!isActive() || props.valid ? "" : " is-invalid")
|
||||
}
|
||||
id={props.id}
|
||||
value={props.value}
|
||||
onInput={props.onInput}
|
||||
onFocusOut={() => setActive(true)}
|
||||
/>
|
||||
<div
|
||||
class={
|
||||
"invalid-feedback" +
|
||||
(!isActive() || props.valid ? " d-none" : " d-block")
|
||||
}
|
||||
>
|
||||
{props.errorText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidatedTextInput;
|
||||
36
www/src/tools/event-emitter.js
Normal file
36
www/src/tools/event-emitter.js
Normal file
@ -0,0 +1,36 @@
|
||||
function EventEmitter() {
|
||||
let instance;
|
||||
|
||||
let listeners = {};
|
||||
|
||||
function on(eventName, callback) {
|
||||
if (!listeners[eventName]) {
|
||||
listeners[eventName] = [];
|
||||
}
|
||||
|
||||
if (listeners[eventName].indexOf(callback) !== -1) return;
|
||||
|
||||
listeners[eventName].push(callback);
|
||||
}
|
||||
|
||||
function dispatchEvent(eventName, payload) {
|
||||
if (!listeners[eventName]) return;
|
||||
listeners[eventName].forEach((listener) => listener(payload));
|
||||
}
|
||||
|
||||
function off(eventName, callback) {
|
||||
if (!listeners[eventName]) return;
|
||||
let index = listeners[eventName].indexOf(callback);
|
||||
listeners[eventName].splice(index, 1);
|
||||
}
|
||||
|
||||
instance = {
|
||||
on,
|
||||
off,
|
||||
dispatchEvent,
|
||||
};
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
export default EventEmitter;
|
||||
74
www/src/tools/url-utils.js
Normal file
74
www/src/tools/url-utils.js
Normal file
@ -0,0 +1,74 @@
|
||||
function UrlUtils() {
|
||||
function getUrl() {
|
||||
return window.location.href;
|
||||
}
|
||||
|
||||
function setUrl(url) {
|
||||
history.pushState(null, null, url);
|
||||
}
|
||||
|
||||
function addQueryParameter(url, key, value) {
|
||||
let [baseUrl, queryString] = url.split("?");
|
||||
let queryParameters = queryString ? queryString.split("&") : [];
|
||||
|
||||
let hasKey = false;
|
||||
for (let i = 0; i < queryParameters.length; i++) {
|
||||
const [paramKey] = queryParameters[i].split("=");
|
||||
if (paramKey === key) {
|
||||
queryParameters[i] = `${key}=${value}`;
|
||||
hasKey = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasKey) {
|
||||
queryParameters.push(`${key}=${value}`);
|
||||
}
|
||||
|
||||
const newUrl = `${baseUrl}?${queryParameters.join("&")}`;
|
||||
return newUrl;
|
||||
}
|
||||
|
||||
function removeQueryParameter(url, key) {
|
||||
let [baseUrl, queryString] = url.split("?");
|
||||
let queryParameters = queryString ? queryString.split("&") : [];
|
||||
|
||||
for (let i = 0; i < queryParameters.length; i++) {
|
||||
const [paramKey] = queryParameters[i].split("=");
|
||||
if (paramKey === key) {
|
||||
queryParameters.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const newUrl = `${baseUrl}?${queryParameters.join("&")}`;
|
||||
return newUrl;
|
||||
}
|
||||
|
||||
function getQueryParameter(key) {
|
||||
let url = getUrl();
|
||||
let [baseUrl, queryString] = url.split("?");
|
||||
let queryParameters = queryString ? queryString.split("&") : [];
|
||||
|
||||
for (let i = 0; i < queryParameters.length; i++) {
|
||||
const [paramKey, paramValue] = queryParameters[i].split("=");
|
||||
if (paramKey === key) {
|
||||
return decodeURIComponent(paramValue);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
addQueryParameter,
|
||||
removeQueryParameter,
|
||||
getQueryParameter,
|
||||
getUrl,
|
||||
setUrl,
|
||||
};
|
||||
}
|
||||
|
||||
UrlUtils = new UrlUtils();
|
||||
|
||||
export default UrlUtils;
|
||||
@ -1,9 +1,213 @@
|
||||
import {
|
||||
createSignal,
|
||||
mergeProps,
|
||||
createResource,
|
||||
createEffect,
|
||||
onMount,
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
import { computePosition, shift, autoUpdate, offset } from "@floating-ui/dom";
|
||||
|
||||
import UserService from "../services/user-service.js";
|
||||
import ModalRegistry from "../modals/modal-registry.jsx";
|
||||
import UrlUtils from "../tools/url-utils.js";
|
||||
import SettingsView from "./settings-view.jsx";
|
||||
|
||||
import {
|
||||
DEVICES_VIEW,
|
||||
REMOTES_VIEW,
|
||||
RECORDINGS_VIEW,
|
||||
SETTINGS_VIEW,
|
||||
} from "../data/constants.js";
|
||||
|
||||
let [activeView, setActiveView] = createSignal(DEVICES_VIEW);
|
||||
|
||||
const MainView = function (props) {
|
||||
props = mergeProps({ onLogout: () => {} }, props);
|
||||
|
||||
const [userInfo] = createResource(() => UserService.getUserInfo());
|
||||
const [username, setUsername] = createSignal("");
|
||||
|
||||
createEffect(() => {
|
||||
if (userInfo.loading) return;
|
||||
setUsername(userInfo().username);
|
||||
});
|
||||
|
||||
let Modals = ModalRegistry.getModals();
|
||||
|
||||
let userButtonRef;
|
||||
let userMenuRef;
|
||||
let userMenuBackRef;
|
||||
let cleanUserMenu;
|
||||
|
||||
onMount(() => {
|
||||
setViewFromUrl();
|
||||
window.addEventListener("popstate", setViewFromUrl);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("popstate", setViewFromUrl);
|
||||
});
|
||||
|
||||
function setViewFromUrl() {
|
||||
let view = UrlUtils.getQueryParameter("view");
|
||||
if (view) {
|
||||
setActiveView(view);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
UserService.logout();
|
||||
props.onLogout();
|
||||
}
|
||||
|
||||
function toggleUserMenu() {
|
||||
if (userMenuRef.style.display === "none") {
|
||||
showUserMenu();
|
||||
} else {
|
||||
hideUserMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function showUserMenu() {
|
||||
cleanUserMenu = autoUpdate(userButtonRef, userMenuRef, () =>
|
||||
computePosition(userButtonRef, userMenuRef, {
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(6), shift()],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(userMenuRef.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
})
|
||||
);
|
||||
userMenuRef.style.display = "block";
|
||||
userMenuBackRef.style.display = "block";
|
||||
}
|
||||
|
||||
function hideUserMenu() {
|
||||
userMenuRef.style.display = "none";
|
||||
userMenuBackRef.style.display = "none";
|
||||
cleanUserMenu();
|
||||
}
|
||||
|
||||
function handleViewChange(view) {
|
||||
if (activeView() === view) return;
|
||||
setActiveView(view);
|
||||
let url = UrlUtils.getUrl();
|
||||
url = UrlUtils.addQueryParameter(url, "view", view);
|
||||
UrlUtils.setUrl(url);
|
||||
}
|
||||
|
||||
function render() {
|
||||
return <div class="d-flex flex-column" style="height:100vh"></div>;
|
||||
return (
|
||||
<div class="d-flex flex-column" style="height:100vh">
|
||||
<Modals></Modals>
|
||||
<HeaderBar></HeaderBar>
|
||||
<ActiveView class={"bg-body-tertiary"}></ActiveView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderBar() {
|
||||
return (
|
||||
<div>
|
||||
<div class="container-xl px-3 py-2 d-flex bg-dark-subtle">
|
||||
<div class="d-flex align-items-center flex-fill">
|
||||
<button
|
||||
class={
|
||||
"btn me-2" +
|
||||
(activeView() === DEVICES_VIEW ? " btn-secondary" : " btn-dark")
|
||||
}
|
||||
onClick={() => handleViewChange(DEVICES_VIEW)}
|
||||
>
|
||||
<i class="bi-folder2 me-2"></i>
|
||||
Devices
|
||||
</button>
|
||||
<button
|
||||
class={
|
||||
"btn me-2" +
|
||||
(activeView() === REMOTES_VIEW ? " btn-secondary" : " btn-dark")
|
||||
}
|
||||
onClick={() => handleViewChange(REMOTES_VIEW)}
|
||||
>
|
||||
<i class="bi-gear-fill me-2"></i>
|
||||
Remotes
|
||||
</button>
|
||||
<button
|
||||
class={
|
||||
"btn me-2" +
|
||||
(activeView() === RECORDINGS_VIEW
|
||||
? " btn-secondary"
|
||||
: " btn-dark")
|
||||
}
|
||||
onClick={() => handleViewChange(RECORDINGS_VIEW)}
|
||||
>
|
||||
<i class="bi bi-terminal me-2"></i>
|
||||
Recordings
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button
|
||||
ref={userButtonRef}
|
||||
class="btn btn-dark rounded-circle p-0"
|
||||
style="width: 2.4em; height: 2.4em"
|
||||
onClick={toggleUserMenu}
|
||||
>
|
||||
<i class="bi bi-person-fill"></i>
|
||||
</button>
|
||||
<div
|
||||
onClick={hideUserMenu}
|
||||
ref={userMenuBackRef}
|
||||
style="position:absolute;width:100vw;height:100vh;left:0;right:0;z-index:1060;display:none"
|
||||
></div>
|
||||
<div
|
||||
class="list-group position-absolute"
|
||||
style="display: none; z-index: 1070"
|
||||
ref={userMenuRef}
|
||||
>
|
||||
<li class="list-group-item bg-dark-subtle">{username()}</li>
|
||||
<button
|
||||
type="button"
|
||||
class="list-group-item list-group-item-action"
|
||||
onClick={() => {
|
||||
handleViewChange(SETTINGS_VIEW);
|
||||
hideUserMenu();
|
||||
}}
|
||||
>
|
||||
<i class="bi bi-gear-fill me-2"></i>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="list-group-item list-group-item-action pe-auto"
|
||||
onClick={() => handleLogout()}
|
||||
>
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveView(props) {
|
||||
return (
|
||||
<div class="container-xl p-0 d-flex flex-column flex-fill overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={activeView() === SETTINGS_VIEW}>
|
||||
<SettingsView {...props} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return render();
|
||||
};
|
||||
|
||||
MainView.setActiveView = setActiveView;
|
||||
|
||||
export default MainView;
|
||||
|
||||
72
www/src/views/settings-view.jsx
Normal file
72
www/src/views/settings-view.jsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Show, createEffect, createResource, createSignal } from "solid-js";
|
||||
import ChangePasswordSettings from "../modules/settings/change-password";
|
||||
import UserList from "../modules/settings/users-list";
|
||||
import UserService from "../services/user-service";
|
||||
|
||||
function SettingsView(props) {
|
||||
const ACCOUNT_TAB = "account";
|
||||
const USERS_TAB = "users";
|
||||
|
||||
const [activeTab, setActiveTab] = createSignal(ACCOUNT_TAB);
|
||||
const [userInfo] = createResource(() => UserService.getUserInfo());
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: ACCOUNT_TAB,
|
||||
text: "Account",
|
||||
render: renderAccountSettings,
|
||||
},
|
||||
{
|
||||
id: USERS_TAB,
|
||||
text: "Users",
|
||||
adminOnly: true,
|
||||
render: () => renderUserSettings,
|
||||
},
|
||||
];
|
||||
|
||||
function renderAccountSettings() {
|
||||
return (
|
||||
<>
|
||||
<ChangePasswordSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderUserSettings() {
|
||||
return (
|
||||
<>
|
||||
<UserList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={"flex-fill p-3 d-flex flex-column " + props.class}>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h3 class="me-3 mb-0">Settings</h3>
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<Show
|
||||
when={!tab.adminOnly || (!userInfo.loading && userInfo().is_admin)}
|
||||
>
|
||||
<button
|
||||
class={
|
||||
"btn ms-2" +
|
||||
(activeTab() === tab.id ? " btn-secondary" : " btn-dark")
|
||||
}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.text}
|
||||
</button>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<For each={tabs}>
|
||||
{(tab) => <Show when={tab.id === activeTab()}>{tab.render()}</Show>}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsView;
|
||||
Loading…
Reference in New Issue
Block a user