diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/ui/api/Api.mjs b/ui/api/Api.mjs index ffb5d91..900f73e 100644 --- a/ui/api/Api.mjs +++ b/ui/api/Api.mjs @@ -73,4 +73,10 @@ export class Api extends ApiBase { static async search(query = null) { return await this.get("/api/users/search", {query}); } + static async getInstances() { + return await this.get("/api/bridging/getInstances", {}); + } + static async addInstance(url = null, useAllowlist = null, enabled = null) { + return await this.post("/api/bridging/addInstance", {url, useAllowlist, enabled}); + } } \ No newline at end of file diff --git a/ui/base.css b/ui/base.css index 488c793..44fef5c 100644 --- a/ui/base.css +++ b/ui/base.css @@ -91,15 +91,15 @@ h1 { } h2 { - font-size: 2rem; + font-size: 1.75rem; } h3 { - font-size: 1.75rem; + font-size: 1.5rem; } h4 { - font-size: 1.5rem; + font-size: 1.2rem; } span { diff --git a/ui/classes.css b/ui/classes.css index 641ce92..dadbfb1 100644 --- a/ui/classes.css +++ b/ui/classes.css @@ -151,6 +151,21 @@ font-size: 0.8em; } +.pill { + padding: 0.5em 1em; + border-radius: 9999px; + background: var(--text-3); + color: var(--bg); + font-size: 0.8em; +} + +.circle { + border-radius: 50%; + width: 0.8em; + height: 0.8em; + background: var(--bg-3); +} + .align-center { align-items: center; } @@ -299,4 +314,8 @@ --size: 38px; width: var(--size); height: var(--size); +} + +.max800 { + max-width: 800px; } \ No newline at end of file diff --git a/ui/components/common.mjs b/ui/components/common.mjs index 52a8928..3cff704 100644 --- a/ui/components/common.mjs +++ b/ui/components/common.mjs @@ -1,9 +1,10 @@ -import {create, FjsObservable, ifjs, signal, signalFromProperty} from "https://fjs.targoninc.com/f.js"; +import {computedSignal, create, FjsObservable, ifjs, signal, signalFromProperty} from "https://fjs.targoninc.com/f.js"; import {popup, removePopups, toast} from "../actions.mjs"; import {PopupComponents} from "./popup.mjs"; import {Api} from "../api/Api.mjs"; import {Live} from "../live/Live.mjs"; import {Popups} from "../api/Popups.mjs"; +import {Store} from "../api/Store.mjs"; export class CommonTemplates { static icon(icon, classes = [], tag = "span") { @@ -83,6 +84,8 @@ export class CommonTemplates { const activeIfActive = page => { return (currentRoute && currentRoute.path === page) ? "active" : "_"; }; + const user = Store.get('user'); + const hasAnyRole = computedSignal(user, user => user && user.roles && user.roles.length > 0); return create("div") .classes("flex", "align-center", "full-width", "space-between", "padded") @@ -93,6 +96,11 @@ export class CommonTemplates { CommonTemplates.buttonWithIcon("chat", "Chat", () => window.router.navigate('chat'), [activeIfActive("chat")]), CommonTemplates.buttonWithIcon("person_add", "New DM", () => Popups.newDm()), ).build(), + ifjs(hasAnyRole, create("div") + .classes("flex", "align-center") + .children( + CommonTemplates.buttonWithIcon("admin_panel_settings", "Administration", () => window.router.navigate('admin'), [activeIfActive("admin")]), + ).build()), create("div") .classes("flex", "align-center") .children( @@ -102,6 +110,20 @@ export class CommonTemplates { ).build(); } + static circleIndicator(text, color = "var(--blue)") { + return create("div") + .classes("flex", "align-center") + .children( + create("span") + .classes("circle") + .styles("background-color", color) + .build(), + create("span") + .text(text) + .build() + ).build(); + } + static userInList(image, name, text, onclick) { return create("button") .classes("flex") @@ -269,4 +291,21 @@ export class CommonTemplates { .build() ).build(); } + + static checkbox(id, label, value, onchange) { + return create("div") + .classes("flex", "small-gap") + .children( + create("input") + .type("checkbox") + .id(id) + .checked(value) + .onchange(onchange) + .build(), + create("label") + .for(id) + .text(label) + .build() + ).build(); + } } \ No newline at end of file diff --git a/ui/components/pages/admin.mjs b/ui/components/pages/admin.mjs new file mode 100644 index 0000000..f996b83 --- /dev/null +++ b/ui/components/pages/admin.mjs @@ -0,0 +1,212 @@ +import {LayoutTemplates} from "../layout.mjs"; +import {create, ifjs, signal, signalFromProperty, signalMap} from "https://fjs.targoninc.com/f.js"; +import {CommonTemplates} from "../common.mjs"; +import {Store} from "../../api/Store.mjs"; +import {Api} from "../../api/Api.mjs"; +import {popup, removePopups, toast} from "../../actions.mjs"; +import {PopupComponents} from "../popup.mjs"; + +export class AdminComponent { + static render() { + return LayoutTemplates.pageFull(AdminComponent.content()); + } + + static content() { + const user = Store.get('user'); + + return create("div") + .classes("panes-v", "full-width", "full-height") + .children( + CommonTemplates.actions(), + create("div") + .classes("panes", "full-width", "flex-grow") + .children( + LayoutTemplates.pane(LayoutTemplates.centeredContent( + create("div") + .classes("flex-v", "padded") + .children( + create("h1") + .text("Administration") + .build(), + AdminComponent.roleList(user), + AdminComponent.permissionList(user), + AdminComponent.bridgeInstanceSettings() + ).build() + ), "100%", "500px", "100%") + ).build() + ).build(); + } + + static roleList(user) { + const roles = signalFromProperty(user, 'roles'); + + return create("div") + .classes("flex-v", "card") + .children( + create("h2") + .text("Your Roles") + .build(), + signalMap(roles, + create("div") + .classes("flex", "max800"), + role => AdminComponent.role(role)), + ).build(); + } + + static role(role) { + return create("span") + .classes("pill") + .text(role.name) + .title(role.description) + .build(); + } + + static permissionList(user) { + const permissions = signalFromProperty(user, 'permissions'); + + return create("div") + .classes("flex-v", "card") + .children( + create("h2") + .text("Your Permissions") + .build(), + signalMap(permissions, + create("div") + .classes("flex", "max800"), + permission => AdminComponent.permission(permission)), + ).build(); + } + + static permission(permission) { + return create("span") + .classes("pill") + .text(permission.name) + .title(permission.description) + .build(); + } + + static bridgeInstanceSettings() { + const bridgedInstances = signal([]); + const loading = signal(true); + Api.getInstances().then(res => { + loading.value = false; + if (res.status === 200) { + bridgedInstances.value = res.data; + } else { + toast("Failed to fetch bridged instances", "negative"); + } + }); + + return create("div") + .classes("flex-v", "card") + .children( + create("h2") + .text("Bridged Instances") + .build(), + AdminComponent.bridgeInstanceActions(bridgedInstances), + ifjs(loading, CommonTemplates.spinner()), + signalMap(bridgedInstances, + create("div") + .classes("flex-v", "max800"), + instance => AdminComponent.bridgeInstance(instance)), + ).build(); + } + + static bridgeInstanceActions(bridgedInstances) { + return create("div") + .classes("flex-v") + .children( + create("p") + .text("Bridged instances allow you to connect to other instances of Venel.") + .build(), + create("div") + .classes("flex", "max800") + .children( + CommonTemplates.buttonWithIcon("add_link", "Add Bridged Instance", () => { + popup(AdminComponent.addBridgedInstancePopup(() => { + removePopups(); + }, bridgedInstances)); + }), + ).build(), + ).build(); + } + + static addBridgedInstancePopup(onclose, bridgedInstances) { + const instanceInfo = signal({ + url: "", + useAllowlist: false, + enabled: true + }); + const url = signalFromProperty(instanceInfo, 'url'); + const useAllowlist = signalFromProperty(instanceInfo, 'useAllowlist'); + const enabled = signalFromProperty(instanceInfo, 'enabled'); + + return create("div") + .classes("flex-v", "card") + .children( + create("div") + .classes("flex", "space-between") + .children( + create("h3").text("Add instance").build(), + PopupComponents.closeButton(onclose), + ).build(), + create("div") + .classes("flex-v", "max800") + .children( + CommonTemplates.input("url", "url", "URL", "URL of the instance", url, (e) => { + instanceInfo.value = { + ...instanceInfo.value, + url: e.target.value + }; + }, true), + CommonTemplates.checkbox("useAllowlist", "Use Allowlist", useAllowlist, (e) => { + instanceInfo.value = { + ...instanceInfo.value, + useAllowlist: e.target.checked + }; + }), + CommonTemplates.checkbox("enabled", "Enabled", enabled, (e) => { + instanceInfo.value = { + ...instanceInfo.value, + enabled: e.target.checked + }; + }), + ).build(), + create("div") + .classes("flex", "space-between") + .children( + CommonTemplates.buttonWithIcon("close", "Cancel" , onclose), + CommonTemplates.buttonWithIcon("add_link", "Add", () => { + if (!url.value) { + toast("URL is required", "negative"); + return; + } + + Api.addInstance(url.value, useAllowlist.value, enabled.value).then(res => { + if (res.status === 200) { + toast("Bridged instance added", "positive"); + bridgedInstances.value.push(res.data); + onclose(); + } else { + toast("Failed to add bridged instance", "negative"); + } + }); + }), + ).build(), + ).build(); + } + + static bridgeInstance(instance) { + return create("div") + .classes("flex", "max800") + .children( + create("span") + .classes("instance") + .text(instance.url) + .build(), + ifjs(instance.useAllowlist, CommonTemplates.circleIndicator("Only allowed users", "var(--green)")), + ifjs(instance.enabled, CommonTemplates.circleIndicator("Enabled", "var(--green)")), + ifjs(instance.enabled, CommonTemplates.circleIndicator("Disabled", "var(--red)"), true), + ).build(); + } +} \ No newline at end of file diff --git a/ui/routing/Page.mjs b/ui/routing/Page.mjs index 625dda7..03f482d 100644 --- a/ui/routing/Page.mjs +++ b/ui/routing/Page.mjs @@ -77,6 +77,10 @@ export class Page { "profile": { path: "profile", component: "ProfileComponent" + }, + "admin": { + path: "admin", + component: "AdminComponent" } }; } \ No newline at end of file diff --git a/ui/routing/Routes.mjs b/ui/routing/Routes.mjs index 08a5f04..3d2fa61 100644 --- a/ui/routing/Routes.mjs +++ b/ui/routing/Routes.mjs @@ -31,5 +31,11 @@ export const routes = [ path: "profile", title: "Profile", noUser: "login" + }, + { + path: "admin", + title: "Administration", + noUser: "login", + noAdmin: "chat" } ]; \ No newline at end of file