diff --git a/.env.example b/.env.example index 1c478cd99..0d5b497e0 100644 --- a/.env.example +++ b/.env.example @@ -5,11 +5,15 @@ QUART_DEBUG=true HOST=127.0.0.1 PORT=5000 - LNBITS_ALLOWED_USERS="" LNBITS_ADMIN_USERS="" +# Extensions only admin can access +LNBITS_ADMIN_EXTENSIONS="nostradmin" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" +# Disable extensions for all users, use "all" to disable all extensions +LNBITS_DISABLED_EXTENSIONS="amilk" + # Database: to use SQLite, specify LNBITS_DATA_FOLDER # to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... # to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://... @@ -18,8 +22,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DATA_FOLDER="./data" # LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename" -# disable selected extensions, or use "all" to disable all extensions -LNBITS_DISABLED_EXTENSIONS="amilk,ngrok" LNBITS_FORCE_HTTPS=true LNBITS_SERVICE_FEE="0.0" diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index ad2d9f2cc..61710a722 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse from lnbits import bolt11 from lnbits.db import Connection, POSTGRES, COCKROACH -from lnbits.settings import DEFAULT_WALLET_NAME +from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS from . import db from .models import User, Wallet, Payment, BalanceCheck @@ -61,6 +61,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[ email=user["email"], extensions=[e[0] for e in extensions], wallets=[Wallet(**w) for w in wallets], + admin=LNBITS_ADMIN_USERS and user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS] ) diff --git a/lnbits/decorators.py b/lnbits/decorators.py index fc92594ed..9eee1afae 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -13,7 +13,7 @@ from starlette.requests import Request from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.models import User, Wallet from lnbits.requestvars import g -from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS +from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS class KeyChecker(SecurityBase): @@ -122,6 +122,7 @@ async def get_key_type( # 0: admin # 1: invoice # 2: invalid + pathname = r['path'].split('/')[1] if not api_key_header and not api_key_query: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) @@ -131,7 +132,10 @@ async def get_key_type( try: checker = WalletAdminKeyChecker(api_key=token) await checker.__call__(r) - return WalletTypeInfo(0, checker.wallet) + wallet = WalletTypeInfo(0, checker.wallet) + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: raise @@ -143,7 +147,10 @@ async def get_key_type( try: checker = WalletInvoiceKeyChecker(api_key=token) await checker.__call__(r) - return WalletTypeInfo(1, checker.wallet) + wallet = WalletTypeInfo(0, checker.wallet) + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: raise diff --git a/lnbits/extensions/nostradmin/README.md b/lnbits/extensions/nostradmin/README.md new file mode 100644 index 000000000..596cce9de --- /dev/null +++ b/lnbits/extensions/nostradmin/README.md @@ -0,0 +1,3 @@ +# Nostr + +Opens a Nostr daemon diff --git a/lnbits/extensions/nostradmin/__init__.py b/lnbits/extensions/nostradmin/__init__.py new file mode 100644 index 000000000..797542c49 --- /dev/null +++ b/lnbits/extensions/nostradmin/__init__.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_nostradmin") + +nostradmin_ext: APIRouter = APIRouter(prefix="/nostradmin", tags=["nostradmin"]) + + +def nostr_renderer(): + return template_renderer(["lnbits/extensions/nostradmin/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/nostradmin/config.json b/lnbits/extensions/nostradmin/config.json new file mode 100644 index 000000000..2c4f76d35 --- /dev/null +++ b/lnbits/extensions/nostradmin/config.json @@ -0,0 +1,6 @@ +{ + "name": "NostrAdmin", + "short_description": "Admin daemon for Nostr", + "icon": "swap_horizontal_circle", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/nostradmin/crud.py b/lnbits/extensions/nostradmin/crud.py new file mode 100644 index 000000000..1e1bf8106 --- /dev/null +++ b/lnbits/extensions/nostradmin/crud.py @@ -0,0 +1,146 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash +import shortuuid +from . import db +from .models import ( + nostrKeys, + nostrNotes, + nostrCreateRelays, + nostrRelays, + nostrConnections, + nostrCreateConnections, + nostrRelayList, +) +from .models import nostrKeys, nostrCreateRelays, nostrRelaySetList + +###############KEYS################## + + +async def create_nostrkeys(data: nostrKeys) -> nostrKeys: + await db.execute( + """ + INSERT INTO nostradmin.keys ( + pubkey, + privkey + ) + VALUES (?, ?) + """, + (data.pubkey, data.privkey), + ) + return await get_nostrkeys(nostrkey_id) + + +async def get_nostrkeys(pubkey: str) -> nostrKeys: + row = await db.fetchone("SELECT * FROM nostradmin.keys WHERE pubkey = ?", (pubkey,)) + return nostrKeys(**row) if row else None + + +###############NOTES################## + + +async def create_nostrnotes(data: nostrNotes) -> nostrNotes: + await db.execute( + """ + INSERT INTO nostradmin.notes ( + id, + pubkey, + created_at, + kind, + tags, + content, + sig + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + data.id, + data.pubkey, + data.created_at, + data.kind, + data.tags, + data.content, + data.sig, + ), + ) + return await get_nostrnotes(data.id) + + +async def get_nostrnotes(nostrnote_id: str) -> nostrNotes: + row = await db.fetchone("SELECT * FROM nostradmin.notes WHERE id = ?", (nostrnote_id,)) + return nostrNotes(**row) if row else None + + +###############RELAYS################## + + +async def create_nostrrelays(data: nostrCreateRelays) -> nostrCreateRelays: + nostrrelay_id = shortuuid.uuid(name=data.relay) + + if await get_nostrrelay(nostrrelay_id): + return "error" + await db.execute( + """ + INSERT INTO nostradmin.relays ( + id, + relay + ) + VALUES (?, ?) + """, + (nostrrelay_id, data.relay), + ) + return await get_nostrrelay(nostrrelay_id) + + +async def get_nostrrelays() -> nostrRelays: + rows = await db.fetchall("SELECT * FROM nostradmin.relays") + return [nostrRelays(**row) for row in rows] + + +async def get_nostrrelay(nostrrelay_id: str) -> nostrRelays: + row = await db.fetchone("SELECT * FROM nostradmin.relays WHERE id = ?", (nostrrelay_id,)) + return nostrRelays(**row) if row else None + +async def update_nostrrelaysetlist(data: nostrRelaySetList) -> nostrRelayList: + await db.execute( + """ + UPDATE nostradmin.relaylists SET + denylist = ?, + allowlist = ? + WHERE id = ? + """, + (data.denylist, data.allowlist, 1), + ) + return await get_nostrrelaylist() + +async def get_nostrrelaylist() -> nostrRelayList: + row = await db.fetchone("SELECT * FROM nostradmin.relaylists WHERE id = ?", (1,)) + return nostrRelayList(**row) if row else None + + +###############CONNECTIONS################## + + +async def create_nostrconnections( + data: nostrCreateConnections +) -> nostrCreateConnections: + nostrrelay_id = shortuuid.uuid(name=data.relayid + data.pubkey) + await db.execute( + """ + INSERT INTO nostradmin.connections ( + id, + pubkey, + relayid + ) + VALUES (?, ?, ?) + """, + (data.id, data.pubkey, data.relayid), + ) + return await get_nostrconnections(data.id) + + +async def get_nostrconnections(nostrconnections_id: str) -> nostrConnections: + row = await db.fetchone( + "SELECT * FROM nostradmin.connections WHERE id = ?", (nostrconnections_id,) + ) + return nostrConnections(**row) if row else None diff --git a/lnbits/extensions/nostradmin/migrations.py b/lnbits/extensions/nostradmin/migrations.py new file mode 100644 index 000000000..590f72ea7 --- /dev/null +++ b/lnbits/extensions/nostradmin/migrations.py @@ -0,0 +1,74 @@ +from lnbits.db import Database + +async def m001_initial(db): + """ + Initial nostradmin table. + """ + await db.execute( + f""" + CREATE TABLE nostradmin.keys ( + pubkey TEXT NOT NULL PRIMARY KEY, + privkey TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.notes ( + id TEXT NOT NULL PRIMARY KEY, + pubkey TEXT NOT NULL, + created_at TEXT NOT NULL, + kind INT NOT NULL, + tags TEXT NOT NULL, + content TEXT NOT NULL, + sig TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.relays ( + id TEXT NOT NULL PRIMARY KEY, + relay TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.relaylists ( + id TEXT NOT NULL PRIMARY KEY DEFAULT 1, + allowlist TEXT, + denylist TEXT + ); + """ + ) + + await db.execute( + """ + INSERT INTO nostradmin.relaylists ( + id, + denylist + ) + VALUES (?, ?) + """, + ("1", "wss://zucks-meta-relay.com\nwss://nostr.cia.gov",), + ) + + await db.execute( + f""" + CREATE TABLE nostradmin.connections ( + id TEXT NOT NULL PRIMARY KEY, + publickey TEXT NOT NULL, + relayid TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.subscribed ( + id TEXT NOT NULL PRIMARY KEY, + userPubkey TEXT NOT NULL, + subscribedPubkey TEXT NOT NULL + ); + """ + ) \ No newline at end of file diff --git a/lnbits/extensions/nostradmin/models.py b/lnbits/extensions/nostradmin/models.py new file mode 100644 index 000000000..dc99b083c --- /dev/null +++ b/lnbits/extensions/nostradmin/models.py @@ -0,0 +1,52 @@ +import json +from sqlite3 import Row +from typing import Optional + +from fastapi import Request +from pydantic import BaseModel +from pydantic.main import BaseModel +from fastapi.param_functions import Query + +class nostrKeys(BaseModel): + pubkey: str + privkey: str + +class nostrNotes(BaseModel): + id: str + pubkey: str + created_at: str + kind: int + tags: str + content: str + sig: str + +class nostrCreateRelays(BaseModel): + relay: str = Query(None) + +class nostrCreateConnections(BaseModel): + pubkey: str = Query(None) + relayid: str = Query(None) + +class nostrRelays(BaseModel): + id: Optional[str] + relay: Optional[str] + status: Optional[bool] = False + +class nostrRelayList(BaseModel): + id: str + allowlist: Optional[str] + denylist: Optional[str] + +class nostrRelaySetList(BaseModel): + allowlist: Optional[str] + denylist: Optional[str] + +class nostrConnections(BaseModel): + id: str + pubkey: Optional[str] + relayid: Optional[str] + +class nostrSubscriptions(BaseModel): + id: str + userPubkey: Optional[str] + subscribedPubkey: Optional[str] \ No newline at end of file diff --git a/lnbits/extensions/nostradmin/templates/nostradmin/index.html b/lnbits/extensions/nostradmin/templates/nostradmin/index.html new file mode 100644 index 000000000..57552bf28 --- /dev/null +++ b/lnbits/extensions/nostradmin/templates/nostradmin/index.html @@ -0,0 +1,303 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
NOSTR RELAYS ONLINE
+
+ +
+ + + + Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+ + + + + + + + + + +
Relays in this list will NOT be used
+ +
+
+ +
+
+ + Update Deny List + +
+
+
+
+ + +
ONLY relays in this list will be used
+ +
+
+ +
+
+ + Update Allow List + +
+
+
+
+
+
+
+ +
+ + +
{{SITE_TITLE}} Nostr Extension
+

Only Admin users can manage this extension

+ + +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/nostradmin/views.py b/lnbits/extensions/nostradmin/views.py new file mode 100644 index 000000000..90d03eba0 --- /dev/null +++ b/lnbits/extensions/nostradmin/views.py @@ -0,0 +1,102 @@ +from http import HTTPStatus +import asyncio +from fastapi import Request +from fastapi.param_functions import Query +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from . import nostradmin_ext, nostr_renderer +# FastAPI good for incoming +from fastapi import Request, WebSocket, WebSocketDisconnect +# Websockets needed for outgoing +import websockets + +from lnbits.core.crud import update_payment_status +from lnbits.core.models import User +from lnbits.core.views.api import api_payment +from lnbits.decorators import check_user_exists + +from .crud import get_nostrkeys, get_nostrrelay + +templates = Jinja2Templates(directory="templates") + + +@nostradmin_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return nostr_renderer().TemplateResponse( + "nostradmin/index.html", {"request": request, "user": user.dict()} + ) + +##################################################################### +#################### NOSTR WEBSOCKET THREAD ######################### +##### THE QUEUE LOOP THREAD THING THAT LISTENS TO BUNCH OF ########## +### WEBSOCKET CONNECTIONS, STORING DATA IN DB/PUSHING TO FRONTEND ### +################### VIA updater() FUNCTION ########################## +##################################################################### + +websocket_queue = asyncio.Queue(1000) + +# while True: +async def nostr_subscribe(): + return + # for the relays: + # async with websockets.connect("ws://localhost:8765") as websocket: + # for the public keys: + # await websocket.send("subscribe to events") + # await websocket.recv() + +##################################################################### +################### LNBITS WEBSOCKET ROUTES ######################### +#### HERE IS WHERE LNBITS FRONTEND CAN RECEIVE AND SEND MESSAGES #### +##################################################################### + +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket, nostr_id: str): + await websocket.accept() + websocket.id = nostr_id + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def send_personal_message(self, message: str, nostr_id: str): + for connection in self.active_connections: + if connection.id == nostr_id: + await connection.send_text(message) + + async def broadcast(self, message: str): + for connection in self.active_connections: + await connection.send_text(message) + + +manager = ConnectionManager() + + +@nostradmin_ext.websocket("/nostradmin/ws/relayevents/{nostr_id}", name="nostr_id.websocket_by_id") +async def websocket_endpoint(websocket: WebSocket, nostr_id: str): + await manager.connect(websocket, nostr_id) + try: + while True: + data = await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) + + +async def updater(nostr_id, message): + copilot = await get_copilot(nostr_id) + if not copilot: + return + await manager.send_personal_message(f"{message}", nostr_id) + + +async def relay_check(relay: str): + async with websockets.connect(relay) as websocket: + if str(websocket.state) == "State.OPEN": + print(str(websocket.state)) + return True + else: + return False \ No newline at end of file diff --git a/lnbits/extensions/nostradmin/views_api.py b/lnbits/extensions/nostradmin/views_api.py new file mode 100644 index 000000000..4ff897043 --- /dev/null +++ b/lnbits/extensions/nostradmin/views_api.py @@ -0,0 +1,63 @@ +from http import HTTPStatus +import asyncio +from fastapi import Request +from fastapi.param_functions import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key +from lnbits.utils.exchange_rates import currencies + +from lnbits.settings import LNBITS_ADMIN_USERS +from . import nostradmin_ext +from .crud import ( + create_nostrkeys, + get_nostrkeys, + create_nostrnotes, + get_nostrnotes, + create_nostrrelays, + get_nostrrelays, + get_nostrrelaylist, + update_nostrrelaysetlist, + create_nostrconnections, + get_nostrconnections, +) +from .models import nostrKeys, nostrCreateRelays, nostrRelaySetList +from .views import relay_check + +@nostradmin_ext.get("/api/v1/relays") +async def api_relays_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): + relays = await get_nostrrelays() + if not relays: + await create_nostrrelays(nostrCreateRelays(relay="wss://relayer.fiatjaf.com")) + await create_nostrrelays( + nostrCreateRelays(relay="wss://nostr-pub.wellorder.net") + ) + relays = await get_nostrrelays() + if not relays: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." + ) + else: + for relay in relays: + relay.status = await relay_check(relay.relay) + return relays + + + +@nostradmin_ext.get("/api/v1/relaylist") +async def api_relaylist(wallet: WalletTypeInfo = Depends(get_key_type)): + if wallet.wallet.user not in LNBITS_ADMIN_USERS: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." + ) + return await get_nostrrelaylist() + +@nostradmin_ext.post("/api/v1/setlist") +async def api_relayssetlist(data: nostrRelaySetList, wallet: WalletTypeInfo = Depends(get_key_type)): + if wallet.wallet.user not in LNBITS_ADMIN_USERS: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." + ) + return await update_nostrrelaysetlist(data) \ No newline at end of file diff --git a/lnbits/helpers.py b/lnbits/helpers.py index dfd9d53de..d26163759 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -15,6 +15,7 @@ import lnbits.settings as settings class Extension(NamedTuple): code: str is_valid: bool + is_admin_only: bool name: Optional[str] = None short_description: Optional[str] = None icon: Optional[str] = None @@ -25,6 +26,7 @@ class Extension(NamedTuple): class ExtensionManager: def __init__(self): self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS + self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS] self._extension_folders: List[str] = [ x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions")) ][0] @@ -47,14 +49,17 @@ class ExtensionManager: ) as json_file: config = json.load(json_file) is_valid = True + is_admin_only = True if extension in self._admin_only else False except Exception: config = {} is_valid = False + is_admin_only = False output.append( Extension( extension, is_valid, + is_admin_only, config.get("name"), config.get("short_description"), config.get("icon"), diff --git a/lnbits/settings.py b/lnbits/settings.py index b204e28f1..26699bc02 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -29,6 +29,7 @@ LNBITS_ALLOWED_USERS: List[str] = env.list( "LNBITS_ALLOWED_USERS", default=[], subcast=str ) LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str) +LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str) LNBITS_DISABLED_EXTENSIONS: List[str] = env.list( "LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str ) diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index 13f683882..d49eec16f 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -111,7 +111,7 @@ window.LNbits = { '/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName }, updateWallet: function (walletName, userId, walletId) { - window.location.href = `/wallet?usr=${userId}&wal=${walletId}&nme=${walletName}` + window.location.href = `/wallet?usr=${userId}&wal=${walletId}&nme=${walletName}` }, deleteWallet: function (walletId, userId) { window.location.href = '/deletewallet?usr=' + userId + '&wal=' + walletId @@ -123,6 +123,7 @@ window.LNbits = { [ 'code', 'isValid', + 'isAdminOnly', 'name', 'shortDescription', 'icon', @@ -135,7 +136,12 @@ window.LNbits = { return obj }, user: function (data) { - var obj = {id: data.id, email: data.email, extensions: data.extensions, wallets: data.wallets} + var obj = { + id: data.id, + email: data.email, + extensions: data.extensions, + wallets: data.wallets + } var mapWallet = this.wallet obj.wallets = obj.wallets .map(function (obj) { @@ -153,16 +159,23 @@ window.LNbits = { return obj }, wallet: function (data) { - newWallet = {id: data.id, name: data.name, adminkey: data.adminkey, inkey: data.inkey} + newWallet = { + id: data.id, + name: data.name, + adminkey: data.adminkey, + inkey: data.inkey + } newWallet.msat = data.balance_msat newWallet.sat = Math.round(data.balance_msat / 1000) - newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(newWallet.sat) + newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format( + newWallet.sat + ) newWallet.url = ['/wallet?usr=', data.user, '&wal=', data.id].join('') return newWallet }, payment: function (data) { obj = { - checking_id:data.id, + checking_id: data.id, pending: data.pending, amount: data.amount, fee: data.fee, @@ -174,8 +187,8 @@ window.LNbits = { extra: data.extra, wallet_id: data.wallet_id, webhook: data.webhook, - webhook_status: data.webhook_status, - } + webhook_status: data.webhook_status + } obj.date = Quasar.utils.date.formatDate( new Date(obj.time * 1000), @@ -225,7 +238,8 @@ window.LNbits = { Quasar.plugins.Notify.create({ timeout: 5000, type: types[error.response.status] || 'warning', - message: error.response.data.message || error.response.data.detail || null, + message: + error.response.data.message || error.response.data.detail || null, caption: [error.response.status, ' ', error.response.statusText] .join('') @@ -368,6 +382,10 @@ window.windowMixin = { .filter(function (obj) { return !obj.hidden }) + .filter(function (obj) { + if (window.user.admin) return obj + return !obj.isAdminOnly + }) .map(function (obj) { if (user) { obj.isEnabled = user.extensions.indexOf(obj.code) !== -1