minimal
This commit is contained in:
@@ -53,6 +53,10 @@ from lnbits.utils.logger import (
|
|||||||
log_server_info,
|
log_server_info,
|
||||||
)
|
)
|
||||||
from lnbits.wallets import get_funding_source, set_funding_source
|
from lnbits.wallets import get_funding_source, set_funding_source
|
||||||
|
try:
|
||||||
|
from lnbits.extensions.wasm.wasm_host.extension_host import register_wasm_ext_routes
|
||||||
|
except Exception: # pragma: no cover - optional parent extension
|
||||||
|
register_wasm_ext_routes = None
|
||||||
|
|
||||||
from .commands import migrate_databases
|
from .commands import migrate_databases
|
||||||
from .core import init_core_routers
|
from .core import init_core_routers
|
||||||
@@ -423,6 +427,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
|
|||||||
|
|
||||||
def register_ext_tasks(ext: Extension) -> None:
|
def register_ext_tasks(ext: Extension) -> None:
|
||||||
"""Register extension async tasks."""
|
"""Register extension async tasks."""
|
||||||
|
if ext.extension_type == "wasm":
|
||||||
|
return
|
||||||
ext_module = importlib.import_module(ext.module_name)
|
ext_module = importlib.import_module(ext.module_name)
|
||||||
|
|
||||||
if hasattr(ext_module, f"{ext.code}_start"):
|
if hasattr(ext_module, f"{ext.code}_start"):
|
||||||
@@ -432,6 +438,18 @@ def register_ext_tasks(ext: Extension) -> None:
|
|||||||
|
|
||||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||||
"""Register FastAPI routes for extension."""
|
"""Register FastAPI routes for extension."""
|
||||||
|
if ext.extension_type != "wasm":
|
||||||
|
ext.extension_type = _load_extension_type(ext.code) or ext.extension_type
|
||||||
|
if ext.extension_type == "wasm":
|
||||||
|
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
|
||||||
|
if register_wasm_ext_routes is None:
|
||||||
|
logger.error(
|
||||||
|
"WASM host extension not installed; cannot register wasm extension "
|
||||||
|
f"{ext.code}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
register_wasm_ext_routes(app, ext)
|
||||||
|
return
|
||||||
ext_module = importlib.import_module(ext.module_name)
|
ext_module = importlib.import_module(ext.module_name)
|
||||||
|
|
||||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||||
@@ -457,6 +475,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||||||
app.include_router(router=ext_route, prefix=prefix)
|
app.include_router(router=ext_route, prefix=prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_extension_type(ext_id: str) -> str | None:
|
||||||
|
try:
|
||||||
|
conf_path = Path(
|
||||||
|
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||||
|
)
|
||||||
|
if not conf_path.is_file():
|
||||||
|
return None
|
||||||
|
with open(conf_path, "r+") as json_file:
|
||||||
|
config_json = json.load(json_file)
|
||||||
|
return config_json.get("extension_type")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def check_and_register_extensions(app: FastAPI) -> None:
|
async def check_and_register_extensions(app: FastAPI) -> None:
|
||||||
await check_installed_extensions(app)
|
await check_installed_extensions(app)
|
||||||
for ext in await get_valid_extensions(False):
|
for ext in await get_valid_extensions(False):
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from lnbits.settings import settings
|
|||||||
async def migrate_extension_database(
|
async def migrate_extension_database(
|
||||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||||
):
|
):
|
||||||
|
if _is_wasm_extension(ext):
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||||
@@ -34,6 +36,34 @@ async def migrate_extension_database(
|
|||||||
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
|
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_wasm_extension(ext: InstallableExtension) -> bool:
|
||||||
|
if ext.meta and getattr(ext.meta, "extension_type", None) == "wasm":
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
candidate_dirs = [
|
||||||
|
Path(ext.ext_dir),
|
||||||
|
Path(settings.lnbits_extensions_path, "extensions", ext.id),
|
||||||
|
Path(settings.lnbits_path, "extensions", ext.id),
|
||||||
|
Path.cwd() / "extensions" / ext.id,
|
||||||
|
]
|
||||||
|
for base in candidate_dirs:
|
||||||
|
conf_path = Path(base, "config.json")
|
||||||
|
if not conf_path.is_file():
|
||||||
|
continue
|
||||||
|
with open(conf_path, "r+") as json_file:
|
||||||
|
config_json = json.load(json_file)
|
||||||
|
if config_json.get("extension_type") == "wasm":
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def run_migration(
|
async def run_migration(
|
||||||
db: Connection,
|
db: Connection,
|
||||||
migrations_module: Any,
|
migrations_module: Any,
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class PayToEnableInfo(BaseModel):
|
|||||||
class UserExtensionInfo(BaseModel):
|
class UserExtensionInfo(BaseModel):
|
||||||
paid_to_enable: bool | None = False
|
paid_to_enable: bool | None = False
|
||||||
payment_hash_to_enable: str | None = None
|
payment_hash_to_enable: str | None = None
|
||||||
|
granted_permissions: list[str] | None = None
|
||||||
|
granted_payment_tags: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserExtension(BaseModel):
|
class UserExtension(BaseModel):
|
||||||
@@ -147,6 +149,7 @@ class Extension(BaseModel):
|
|||||||
short_description: str | None = None
|
short_description: str | None = None
|
||||||
tile: str | None = None
|
tile: str | None = None
|
||||||
upgrade_hash: str | None = ""
|
upgrade_hash: str | None = ""
|
||||||
|
extension_type: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self) -> str:
|
def module_name(self) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
@@ -48,6 +50,20 @@ from lnbits.decorators import (
|
|||||||
)
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _load_extension_type(ext_id: str) -> str:
|
||||||
|
try:
|
||||||
|
conf_path = Path(
|
||||||
|
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||||
|
)
|
||||||
|
if not conf_path.is_file():
|
||||||
|
return "python"
|
||||||
|
with open(conf_path, "r+") as json_file:
|
||||||
|
config_json = json.load(json_file)
|
||||||
|
return config_json.get("extension_type", "python")
|
||||||
|
except Exception:
|
||||||
|
return "python"
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_user_extension,
|
create_user_extension,
|
||||||
delete_dbversion,
|
delete_dbversion,
|
||||||
@@ -596,6 +612,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
|||||||
"isPaymentRequired": ext.requires_payment,
|
"isPaymentRequired": ext.requires_payment,
|
||||||
"inProgress": False,
|
"inProgress": False,
|
||||||
"selectedForUpdate": False,
|
"selectedForUpdate": False,
|
||||||
|
"extensionType": _load_extension_type(ext.id),
|
||||||
}
|
}
|
||||||
for ext in installable_exts
|
for ext in installable_exts
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ window.PageExtensions = {
|
|||||||
paylinkWebsocket: null,
|
paylinkWebsocket: null,
|
||||||
searchToggle: false,
|
searchToggle: false,
|
||||||
reviewsUrl: null,
|
reviewsUrl: null,
|
||||||
|
permissionsDialog: {
|
||||||
|
show: false,
|
||||||
|
extension: null,
|
||||||
|
checked: [],
|
||||||
|
missing: [],
|
||||||
|
tags: [],
|
||||||
|
tagOptions: []
|
||||||
|
},
|
||||||
reviewsDialog: {
|
reviewsDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
extension: null,
|
extension: null,
|
||||||
@@ -92,6 +100,20 @@ window.PageExtensions = {
|
|||||||
this.filterExtensions(this.searchTerm, val)
|
this.filterExtensions(this.searchTerm, val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
permissionsAllChecked() {
|
||||||
|
const ext = this.permissionsDialog.extension
|
||||||
|
if (!ext || !Array.isArray(ext.permissions)) return true
|
||||||
|
const required = ext.permissions.map(p => p.id)
|
||||||
|
return required.every(p => this.permissionsDialog.checked.includes(p))
|
||||||
|
},
|
||||||
|
permissionsHasMissingEndpoints() {
|
||||||
|
return (
|
||||||
|
this.permissionsDialog.missing &&
|
||||||
|
this.permissionsDialog.missing.length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
filterExtensions(term, tab) {
|
filterExtensions(term, tab) {
|
||||||
// Filter the extensions list
|
// Filter the extensions list
|
||||||
@@ -241,6 +263,37 @@ window.PageExtensions = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async enableExtensionForUser(extension) {
|
async enableExtensionForUser(extension) {
|
||||||
|
if (extension.extensionType === 'wasm') {
|
||||||
|
const wasmHost = this.extensions.find(ext => ext.id === 'wasm')
|
||||||
|
if (!wasmHost || !wasmHost.isInstalled || !wasmHost.isActive) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Enable the WASM! host extension before using this extension.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (extension.permissions && extension.permissions.length) {
|
||||||
|
if (!extension._grantedPermissions) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Save permissions before enabling this extension.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extension.paymentTags && extension.paymentTags.length) {
|
||||||
|
if (
|
||||||
|
!extension._grantedPaymentTags ||
|
||||||
|
!extension._grantedPaymentTags.length
|
||||||
|
) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Select payment tags before enabling this extension.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (extension.isPaymentRequired) {
|
if (extension.isPaymentRequired) {
|
||||||
this.showPayToEnable(extension)
|
this.showPayToEnable(extension)
|
||||||
return
|
return
|
||||||
@@ -294,6 +347,118 @@ window.PageExtensions = {
|
|||||||
this.selectedExtension.payToEnable.showQRCode = false
|
this.selectedExtension.payToEnable.showQRCode = false
|
||||||
this.showPayToEnableDialog = true
|
this.showPayToEnableDialog = true
|
||||||
},
|
},
|
||||||
|
openPermissionsDialog(extension) {
|
||||||
|
this.permissionsDialog.extension = extension
|
||||||
|
this.permissionsDialog.checked = extension._grantedPermissions
|
||||||
|
? extension._grantedPermissions.slice()
|
||||||
|
: extension.grantedPermissions
|
||||||
|
? extension.grantedPermissions.slice()
|
||||||
|
: []
|
||||||
|
this.permissionsDialog.missing = []
|
||||||
|
this.permissionsDialog.tags = extension._grantedPaymentTags
|
||||||
|
? extension._grantedPaymentTags.slice()
|
||||||
|
: extension.grantedPaymentTags
|
||||||
|
? extension.grantedPaymentTags.slice()
|
||||||
|
: []
|
||||||
|
this.permissionsDialog.tagOptions = []
|
||||||
|
this.permissionsDialog.show = true
|
||||||
|
},
|
||||||
|
cancelPermissionsDialog() {
|
||||||
|
this.permissionsDialog.show = false
|
||||||
|
this.permissionsDialog.extension = null
|
||||||
|
this.permissionsDialog.checked = []
|
||||||
|
this.permissionsDialog.missing = []
|
||||||
|
this.permissionsDialog.tags = []
|
||||||
|
this.permissionsDialog.tagOptions = []
|
||||||
|
},
|
||||||
|
async openPermissionsForExtension(extension) {
|
||||||
|
if (extension.extensionType !== 'wasm') {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'This extension does not use WASM permissions.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.permissionsDialog.extension = extension
|
||||||
|
this.permissionsDialog.checked = extension._grantedPermissions
|
||||||
|
? extension._grantedPermissions.slice()
|
||||||
|
: extension.grantedPermissions
|
||||||
|
? extension.grantedPermissions.slice()
|
||||||
|
: []
|
||||||
|
this.permissionsDialog.missing = []
|
||||||
|
this.permissionsDialog.tags = extension._grantedPaymentTags
|
||||||
|
? extension._grantedPaymentTags.slice()
|
||||||
|
: extension.grantedPaymentTags
|
||||||
|
? extension.grantedPaymentTags.slice()
|
||||||
|
: []
|
||||||
|
this.permissionsDialog.tagOptions = []
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/wasm/api/v1/extensions/${extension.id}/capabilities`,
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
if (data && Array.isArray(data.permissions)) {
|
||||||
|
extension.permissions = data.permissions
|
||||||
|
}
|
||||||
|
if (data && Array.isArray(data.missing_permissions)) {
|
||||||
|
this.permissionsDialog.missing = data.missing_permissions
|
||||||
|
}
|
||||||
|
if (data && Array.isArray(data.payment_tags)) {
|
||||||
|
extension.paymentTags = data.payment_tags
|
||||||
|
this.permissionsDialog.tagOptions = data.payment_tags
|
||||||
|
}
|
||||||
|
if (data && Array.isArray(data.granted_permissions)) {
|
||||||
|
extension.grantedPermissions = data.granted_permissions
|
||||||
|
this.permissionsDialog.checked = data.granted_permissions.slice()
|
||||||
|
}
|
||||||
|
if (data && Array.isArray(data.granted_payment_tags)) {
|
||||||
|
extension.grantedPaymentTags = data.granted_payment_tags
|
||||||
|
this.permissionsDialog.tags = data.granted_payment_tags.slice()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
}
|
||||||
|
this.permissionsDialog.show = true
|
||||||
|
},
|
||||||
|
async confirmPermissionsDialog() {
|
||||||
|
const ext = this.permissionsDialog.extension
|
||||||
|
const granted = this.permissionsDialog.checked.slice()
|
||||||
|
const tags = this.permissionsDialog.tags.slice()
|
||||||
|
this.permissionsDialog.show = false
|
||||||
|
this.permissionsDialog.extension = null
|
||||||
|
this.permissionsDialog.checked = []
|
||||||
|
const missing = this.permissionsDialog.missing || []
|
||||||
|
this.permissionsDialog.missing = []
|
||||||
|
this.permissionsDialog.tags = []
|
||||||
|
this.permissionsDialog.tagOptions = []
|
||||||
|
if (missing.length) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Missing API endpoints for one or more permissions.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!ext) return
|
||||||
|
ext._grantedPermissions = granted
|
||||||
|
ext.grantedPermissions = granted
|
||||||
|
ext._grantedPaymentTags = tags
|
||||||
|
ext.grantedPaymentTags = tags
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/wasm/api/v1/extensions/${ext.id}/permissions`,
|
||||||
|
this.g.user.wallets[0].adminkey,
|
||||||
|
{permissions: granted, payment_tags: tags}
|
||||||
|
)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Permissions saved.'
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
updatePayToInstallData(extension) {
|
updatePayToInstallData(extension) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None)
|
|||||||
invoice_listeners[name] = send_chan
|
invoice_listeners[name] = send_chan
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_invoice_listener(name: str) -> None:
|
||||||
|
if name in invoice_listeners:
|
||||||
|
invoice_listeners.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,18 @@
|
|||||||
<span v-text="$t('enable_extension_details')">
|
<span v-text="$t('enable_extension_details')">
|
||||||
</span> </q-tooltip
|
</span> </q-tooltip
|
||||||
></q-btn>
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="
|
||||||
|
extension.isInstalled &&
|
||||||
|
extension.isActive &&
|
||||||
|
!g.user.extensions.includes(extension.id) &&
|
||||||
|
extension.extensionType === 'wasm'
|
||||||
|
"
|
||||||
|
flat
|
||||||
|
color="grey-5"
|
||||||
|
@click="openPermissionsForExtension(extension)"
|
||||||
|
label="Permissions"
|
||||||
|
></q-btn>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="showManageExtension(extension)"
|
@click="showManageExtension(extension)"
|
||||||
@@ -921,6 +933,91 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="permissionsDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Permissions required</div>
|
||||||
|
<div class="text-caption text-grey">This extension can:</div>
|
||||||
|
<q-list v-if="permissionsDialog.extension">
|
||||||
|
<q-item
|
||||||
|
v-for="perm in permissionsDialog.extension.permissions"
|
||||||
|
:key="perm.id || perm"
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label v-text="perm.label || perm"></q-item-label>
|
||||||
|
<q-item-label
|
||||||
|
caption
|
||||||
|
v-if="perm.description"
|
||||||
|
v-text="perm.description"
|
||||||
|
></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="permissionsDialog.checked"
|
||||||
|
:val="perm.id || perm"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
permissionsDialog.tagOptions && permissionsDialog.tagOptions.length
|
||||||
|
"
|
||||||
|
class="q-mt-md"
|
||||||
|
>
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
Allow this extension to listen for payment tags:
|
||||||
|
</div>
|
||||||
|
<q-list>
|
||||||
|
<q-item
|
||||||
|
v-for="tag in permissionsDialog.tagOptions"
|
||||||
|
:key="tag"
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label v-text="tag"></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-checkbox v-model="permissionsDialog.tags" :val="tag" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="permissionsDialog.missing && permissionsDialog.missing.length"
|
||||||
|
class="q-mt-md text-negative"
|
||||||
|
>
|
||||||
|
<div class="text-caption">
|
||||||
|
Missing API endpoints required by this extension:
|
||||||
|
</div>
|
||||||
|
<q-chip
|
||||||
|
v-for="perm in permissionsDialog.missing"
|
||||||
|
:key="perm"
|
||||||
|
:label="perm"
|
||||||
|
color="red-2"
|
||||||
|
text-color="black"
|
||||||
|
class="q-mr-xs q-mt-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
v-text="$t('cancel')"
|
||||||
|
@click="cancelPermissionsDialog"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
|
||||||
|
label="Save"
|
||||||
|
@click="confirmPermissionsDialog"
|
||||||
|
></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog v-model="showExtensionDetailsDialog" position="top">
|
<q-dialog v-model="showExtensionDetailsDialog" position="top">
|
||||||
<q-card
|
<q-card
|
||||||
v-if="selectedExtensionDetails"
|
v-if="selectedExtensionDetails"
|
||||||
|
|||||||
Reference in New Issue
Block a user