Compare commits

..

110 Commits

Author SHA1 Message Date
wh1te909
2422ff2174 Release 0.101.52 2025-02-03 20:36:14 +00:00
wh1te909
144949e9f4 bump version (again) 2025-02-03 20:35:58 +00:00
wh1te909
1d8dcb7b1f Release 0.101.50 2025-02-03 20:33:52 +00:00
wh1te909
d0a655f570 bump version 2025-02-03 20:22:16 +00:00
wh1te909
a9e14e4cb4 fix clipboard perms amidaware/tacticalrmm#1134 2025-02-01 20:14:05 +00:00
wh1te909
6ff5e7bf94 Release 0.101.50 2024-11-20 19:46:59 +00:00
wh1te909
f7b52e506d bump version 2024-11-20 19:39:45 +00:00
wh1te909
4932997498 Merge pull request #28 from sadnub/sso
feat: single sign-on https://github.com/amidaware/tacticalrmm/issues/508
2024-11-20 11:33:39 -08:00
wh1te909
09ecc36bcd wording 2024-11-19 23:15:17 +00:00
wh1te909
4d8abbaa12 make the modal persistent 2024-11-19 23:14:45 +00:00
wh1te909
9f143a7e05 restore missing livepoll function that got missed during rework 2024-11-15 23:55:05 +00:00
wh1te909
e51efaa9e2 typo 2024-11-15 20:52:15 +00:00
wh1te909
64edab9df4 fix lint 2024-11-08 21:25:02 +00:00
wh1te909
85f408ae94 increase chunksize limit 2024-11-08 21:01:08 +00:00
wh1te909
26ed91cad9 update reqs 2024-11-07 22:08:10 +00:00
wh1te909
bb828b5996 add client/site columns to alerts table 2024-11-07 21:06:36 +00:00
wh1te909
2583e9ac9e make user admin modal wider 2024-11-06 20:45:42 +00:00
wh1te909
acaced7122 clear the provider id on logout 2024-11-05 20:26:22 +00:00
wh1te909
795ba12f3a handle 423 2024-11-04 23:50:02 +00:00
wh1te909
face099460 urls now sent by backend and add javascript origin url 2024-11-04 22:35:07 +00:00
wh1te909
2690e9daef add descriptive wording 2024-11-04 20:33:19 +00:00
wh1te909
ec5ef65911 don't allow SSO reset from UI 2024-11-01 18:29:49 +00:00
wh1te909
237b097684 return error from backend instead if local login disabled rather than not displaying at all in UI 2024-11-01 18:29:19 +00:00
wh1te909
6f6d98fae2 set provider icon from api 2024-11-01 17:52:59 +00:00
wh1te909
583f57f2af add sso 2024-11-01 17:49:09 +00:00
wh1te909
4270fd0d19 fix logic 2024-10-31 21:14:44 +00:00
wh1te909
02eeea50e3 rename type to avoid naming conflict with component 2024-10-31 20:46:33 +00:00
sadnub
54207d1c0f disable certain UI elements if block_local_user_local is enabled 2024-10-29 11:19:07 -04:00
wh1te909
16b9bf1529 fix run on server missing for posix 2024-10-29 11:19:07 -04:00
wh1te909
1adeadd48e fix run on server missing for posix 2024-10-28 21:26:14 +00:00
wh1te909
fada3c2ed7 also add column for copying callback url 2024-10-25 19:26:20 +00:00
wh1te909
c1cd6114de add disable and hint to sso form 2024-10-25 19:25:36 +00:00
wh1te909
79d02060ef style sso login 2024-10-25 19:24:17 +00:00
wh1te909
3ce67b0701 remove debug 2024-10-25 19:21:43 +00:00
sadnub
8aab840633 change secret field to password and allow toggling visibility 2024-10-24 04:34:19 +00:00
sadnub
0ce8da44c1 add sso user column in user table and fix disconnecting sso accounts 2024-10-24 04:34:19 +00:00
sadnub
856a3b8b96 allow dispay full name in UI if present 2024-10-24 04:34:19 +00:00
sadnub
0e59f580c3 auto redirect to sso login on sso signup 2024-10-24 04:34:19 +00:00
sadnub
d0cf72bbd2 fix 403 on sso provider signup and other tweaks to UI. Setting to disable SSO 2024-10-24 04:34:19 +00:00
sadnub
65096e6b88 implement role assignment on sso user signups and log ip for sso logins 2024-10-24 04:34:19 +00:00
sadnub
c31ed666b5 added user session tracking, social accoutn tracking, and implemented local user logon blocking 2024-10-24 04:34:19 +00:00
sadnub
09e39ef6da rollback axios not redirecting on 401 errors for certain urls 2024-10-24 04:34:19 +00:00
sadnub
75a9ef88d1 implement session auth login logic and cleanup views 2024-10-24 04:34:19 +00:00
sadnub
0eb81662d3 move sso auth to auth store 2024-10-24 04:34:19 +00:00
sadnub
541134a88f sso init 2024-10-24 04:34:19 +00:00
wh1te909
561da0496c Release 0.101.49 2024-10-23 02:08:56 +00:00
wh1te909
ee8aada530 bump version 2024-10-23 02:08:33 +00:00
wh1te909
fa2ef65103 update lint 2024-10-23 01:16:18 +00:00
wh1te909
d73991cb0a update node 2024-10-23 01:14:36 +00:00
wh1te909
a8e5203b58 add total cpu/ram and fix race condition with polling interval for process manager closes amidaware/tacticalrmm#2037 2024-10-22 07:05:43 +00:00
wh1te909
bdf7cd7bf4 update reqs 2024-10-21 21:03:12 +00:00
wh1te909
c3bd551b3a wording 2024-10-21 21:02:58 +00:00
wh1te909
e045485d8c don't limit file extensions 2024-10-18 18:09:13 +00:00
wh1te909
fa0992c49f add win task name tooltip for debugging closes amidaware/tacticalrmm#1886 2024-10-18 00:01:38 +00:00
wh1te909
21ea5a1981 add mon type icon to agents table closes amidaware/tacticalrmm#1966 2024-10-17 23:34:44 +00:00
wh1te909
a53a3b3343 add syntax hover icon to bulk script closes amidaware/tacticalrmm#1946 2024-10-17 23:28:56 +00:00
wh1te909
ddb7c82575 change icon 2024-10-17 22:52:11 +00:00
wh1te909
fbb221fcac add run on server option to run script endpoint amidaware/tacticalrmm#1923 2024-10-17 20:40:46 +00:00
wh1te909
0d832ba833 update reqs 2024-10-17 20:40:10 +00:00
wh1te909
870d70b4f2 show more detail in checks tab closes amidaware/tacticalrmm#2014 2024-10-15 08:36:43 +00:00
wh1te909
33dbeb5552 update reqs 2024-10-13 19:51:26 +00:00
wh1te909
9457bf2bc5 hide global keystore content and add perms closes amidaware/tacticalrmm#1984 2024-10-06 05:59:51 +00:00
wh1te909
797b27af13 add refresh button to policy status closes amidaware/tacticalrmm#2010 2024-10-06 03:51:02 +00:00
wh1te909
f6bbe3ecd8 add saving output of bulk script to custom field and agent note amidaware/tacticalrmm#1845 2024-10-06 01:15:24 +00:00
wh1te909
f0c603d36f update reqs 2024-09-30 17:53:56 +00:00
wh1te909
f87c6b2a10 update reqs 2024-09-30 08:22:45 +00:00
wh1te909
4186b1cbf2 update reqs 2024-09-04 09:54:51 +00:00
wh1te909
393b4fa90a Release 0.101.48 2024-08-05 18:08:07 +00:00
wh1te909
dce732ec3c bump version 2024-08-05 17:51:08 +00:00
wh1te909
0c744eded6 show custom field id when hovering amidaware/tacticalrmm#1943 2024-07-29 19:40:53 +00:00
wh1te909
4c1a231811 show script id when hovering over the name in script manager table closes amidaware/tacticalrmm#1943 2024-07-29 04:10:24 +00:00
wh1te909
c53179892c fix pinia state 2024-07-28 22:40:55 +00:00
wh1te909
1f5af9ba2d sort urlactions by name closes amidaware/tacticalrmm#1795 2024-07-26 18:53:21 +00:00
wh1te909
b4b63826dc Release 0.101.47 2024-07-12 08:33:55 +00:00
wh1te909
45e2690a81 bump versions 2024-07-12 08:33:14 +00:00
wh1te909
2ff504db09 update quasar 2024-07-12 08:29:36 +00:00
wh1te909
0c89e58d8c add copy to clipboard button for command/script output 2024-07-12 08:23:49 +00:00
wh1te909
e1dc75e2d8 make dialog a bit wider for webhooks and move buttons to the left 2024-07-12 08:18:09 +00:00
wh1te909
8f1c694071 Release 0.101.44 2024-04-09 00:13:26 +00:00
wh1te909
e837c494cb Release 0.101.43 2024-03-25 17:38:24 +00:00
wh1te909
13f0f117da Release 0.101.40 2024-02-03 01:44:41 +00:00
wh1te909
0b6ae80777 Release 0.101.39 2024-02-02 00:55:23 +00:00
wh1te909
cfe1cb2dbf Release 0.101.38 2023-12-22 17:50:19 +00:00
wh1te909
e1dc8050e3 Release 0.101.37 2023-12-01 18:55:37 +00:00
wh1te909
3e6365574e Release 0.101.36 2023-11-23 00:03:20 +00:00
wh1te909
5114ff40aa Release 0.101.35 2023-11-07 17:25:24 +00:00
wh1te909
6ea7c92b20 Release 0.101.34 2023-10-31 17:51:19 +00:00
wh1te909
20d534eab0 Release 0.101.31 2023-10-01 17:36:52 +00:00
wh1te909
1b2286c4f8 Release 0.101.30 2023-09-30 21:59:09 +00:00
wh1te909
8207f30234 Release 0.101.29 2023-08-30 04:10:11 +00:00
wh1te909
68036f6837 Release 0.101.28 2023-08-14 06:39:49 +00:00
wh1te909
03fae45ac5 Release 0.101.25 2023-07-04 18:49:46 +00:00
wh1te909
c2591c9e7d Release 0.101.22 2023-05-30 22:11:30 +00:00
wh1te909
7fcbe6fbd8 Release 0.101.20 2023-05-09 21:09:45 +00:00
wh1te909
a2f472ef9c Release 0.101.18 2023-04-09 03:28:23 +00:00
wh1te909
8403ac0e93 Release 0.101.16 2023-03-22 17:00:29 +00:00
wh1te909
b7a91563b0 Release 0.101.13 2023-01-18 20:05:20 +00:00
wh1te909
ab19afca16 Release 0.101.11 2022-12-21 18:44:46 +00:00
wh1te909
f24c6a7a80 Release 0.101.9 2022-12-04 23:01:59 +00:00
wh1te909
99490bf859 Release 0.101.7 2022-11-13 01:20:33 +00:00
wh1te909
72cdeeaa6a Release 0.101.5 2022-10-25 22:02:34 +00:00
wh1te909
1eca4d605b Release 0.101.3 2022-10-19 22:35:54 +00:00
wh1te909
52ee98f6f8 Release 0.101.0 2022-09-24 02:43:53 +00:00
wh1te909
d270b877c9 Release 0.100.9 2022-08-23 05:04:57 +00:00
wh1te909
fd8b2a1d98 Release 0.100.8 2022-08-09 20:40:48 +00:00
wh1te909
f518043d8d Release 0.100.7 2022-08-01 17:36:11 +00:00
wh1te909
cc2335558d Release 0.100.6 2022-07-27 06:15:49 +00:00
wh1te909
a8a171ba2c Release 0.100.5 2022-07-10 00:00:08 +00:00
wh1te909
24a63f477e Release 0.100.4 2022-07-07 16:38:14 +00:00
wh1te909
ddeb6293a1 init 2022-05-17 20:46:22 +00:00
54 changed files with 3153 additions and 709 deletions

View File

@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "20.11.1" node-version: "20.18.0"
- run: touch env-config.js - run: touch env-config.js

View File

@@ -9,11 +9,11 @@ jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: "20.18.0"
- run: npm install - run: npm install
- name: Run Prettier formatting - name: Run Prettier formatting

1517
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "0.101.46", "version": "0.101.52",
"private": true, "private": true,
"productName": "Tactical RMM", "productName": "Tactical RMM",
"scripts": { "scripts": {
@@ -10,38 +10,38 @@
"format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore" "format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "1.16.12", "@quasar/extras": "1.16.13",
"@vueuse/core": "10.11.0", "@vueuse/core": "11.2.0",
"@vueuse/integrations": "10.11.0", "@vueuse/integrations": "11.2.0",
"@vueuse/shared": "10.11.0", "@vueuse/shared": "11.2.0",
"apexcharts": "3.49.2", "apexcharts": "3.54.1",
"axios": "1.7.2", "axios": "1.7.7",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"monaco-editor": "0.50.0", "monaco-editor": "0.50.0",
"pinia": "2.1.7", "pinia": "2.2.6",
"qrcode": "1.5.3", "qrcode": "1.5.4",
"quasar": "2.16.5", "quasar": "2.17.2",
"vue": "3.4.31", "vue": "3.5.12",
"vue-router": "4.4.0", "vue-router": "4.4.5",
"vue3-apexcharts": "1.5.3", "vue3-apexcharts": "1.7.0",
"vuedraggable": "4.1.0", "vuedraggable": "4.1.0",
"vuex": "4.1.0", "vuex": "4.1.0",
"@xterm/xterm": "5.5.0", "@xterm/xterm": "5.5.0",
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.10.0",
"yaml": "2.4.5" "yaml": "2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "4.0.0", "@intlify/unplugin-vue-i18n": "4.0.0",
"@quasar/app-vite": "1.9.3", "@quasar/app-vite": "1.10.2",
"@quasar/cli": "2.4.1", "@quasar/cli": "2.4.1",
"@types/node": "20.14.10", "@types/node": "22.7.5",
"@typescript-eslint/eslint-plugin": "7.16.0", "@typescript-eslint/eslint-plugin": "7.16.0",
"@typescript-eslint/parser": "7.16.0", "@typescript-eslint/parser": "7.16.0",
"autoprefixer": "10.4.19", "autoprefixer": "10.4.20",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-vue": "8.7.1", "eslint-plugin-vue": "8.7.1",
"prettier": "3.2.5", "prettier": "3.3.3",
"typescript": "5.5.3" "typescript": "5.6.2"
} }
} }

View File

@@ -8,6 +8,7 @@
// Configuration for your app // Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const { mergeConfig } = require("vite");
const { configure } = require("quasar/wrappers"); const { configure } = require("quasar/wrappers");
const path = require("path"); const path = require("path");
require("dotenv").config(); require("dotenv").config();
@@ -78,9 +79,22 @@ module.exports = configure(function (/* ctx */) {
// polyfillModulePreload: true, // polyfillModulePreload: true,
distDir: "dist/", distDir: "dist/",
// extendViteConf (viteConf) {}, /* eslint-disable quotes */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
extendViteConf(viteConf, { isServer, isClient }) {
viteConf.build = mergeConfig(viteConf.build, {
chunkSizeWarningLimit: 1600,
rollupOptions: {
output: {
entryFileNames: `[hash].js`,
chunkFileNames: `[hash].js`,
assetFileNames: `[hash].[ext]`,
},
},
});
},
/* eslint-enable quotes */
// viteVuePluginOptions: {}, // viteVuePluginOptions: {},
// vitePlugins: [] // vitePlugins: []
}, },

View File

@@ -31,6 +31,34 @@ export async function resetTwoFactor() {
} }
} }
// sessions api
export async function fetchUserSessions(id) {
try {
const { data } = await axios.get(`${baseUrl}/users/${id}/sessions/`);
return data;
} catch (e) {
console.error(e);
}
}
export async function deleteAllUserSessions(id) {
try {
const { data } = await axios.delete(`${baseUrl}/users/${id}/sessions/`);
return data;
} catch (e) {
console.error(e);
}
}
export async function deleteUserSession(id) {
try {
const { data } = await axios.delete(`${baseUrl}/sessions/${id}/`);
return data;
} catch (e) {
console.error(e);
}
}
// role api function // role api function
export async function fetchRoles(params = {}) { export async function fetchRoles(params = {}) {
try { try {

View File

@@ -8,8 +8,15 @@ import type {
TestRunURLActionResponse, TestRunURLActionResponse,
} from "@/types/core/urlactions"; } from "@/types/core/urlactions";
import type { CoreSetting } from "@/types/core/settings";
const baseUrl = "/core"; const baseUrl = "/core";
export async function fetchCoreSettings(params = {}): Promise<CoreSetting> {
const { data } = await axios.get("/core/settings/", { params: params });
return data;
}
export async function fetchDashboardInfo(params = {}) { export async function fetchDashboardInfo(params = {}) {
const { data } = await axios.get(`${baseUrl}/dashinfo/`, { params: params }); const { data } = await axios.get(`${baseUrl}/dashinfo/`, { params: params });
return data; return data;

View File

@@ -21,6 +21,7 @@ export function setErrorMessage(data, message) {
export default function ({ app, router }) { export default function ({ app, router }) {
app.config.globalProperties.$axios = axios; app.config.globalProperties.$axios = axios;
axios.defaults.withCredentials = true;
axios.interceptors.request.use( axios.interceptors.request.use(
function (config) { function (config) {
@@ -65,12 +66,20 @@ export default function ({ app, router }) {
// perms // perms
else if (error.response.status === 403) { else if (error.response.status === 403) {
// don't notify user if method is GET // don't notify user if method is GET
if (error.config.method === "get" || error.config.method === "patch") if (
error.config.method === "get" ||
error.config.method === "patch" ||
error.config.url === "accounts/ssoproviders/token/"
)
return Promise.reject({ ...error }); return Promise.reject({ ...error });
text = error.response.data.detail; text = error.response.data.detail;
} }
// catch all for other 400 error messages // catch all for other 400 error messages
else if (error.response.status >= 400 && error.response.status < 500) { else if (
error.response.status >= 400 &&
error.response.status < 500 &&
error.response.status !== 423
) {
if (error.config.responseType === "blob") { if (error.config.responseType === "blob") {
text = (await error.response.data.text()).replace(/^"|"$/g, ""); text = (await error.response.data.text()).replace(/^"|"$/g, "");
} else if (error.response.data.non_field_errors) { } else if (error.response.data.non_field_errors) {
@@ -85,7 +94,7 @@ export default function ({ app, router }) {
} }
} }
if (text || error.response) { if ((text || error.response) && error.response.status !== 423) {
Notify.create({ Notify.create({
color: "negative", color: "negative",
message: text ? text : "", message: text ? text : "",

View File

@@ -1,157 +1,202 @@
<template> <template>
<div style="width: 900px; max-width: 90vw"> <q-card style="width: 65vw; max-width: 70vw; min-height: 50vh">
<q-card> <q-bar>
<q-bar> <q-btn
ref="refresh"
@click="getUsers"
class="q-mr-sm"
dense
flat
push
icon="refresh"
/>User Administration
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<div class="q-pa-md">
<div class="q-gutter-sm">
<q-btn <q-btn
ref="refresh" ref="new"
@click="getUsers" label="New"
class="q-mr-sm"
dense dense
flat flat
push push
icon="refresh" unelevated
/>User Administration no-caps
<q-space /> icon="add"
<q-btn dense flat icon="close" v-close-popup> @click="showAddUserModal"
<q-tooltip class="bg-white text-primary">Close</q-tooltip> />
</q-btn>
</q-bar>
<div class="q-pa-md">
<div class="q-gutter-sm">
<q-btn
ref="new"
label="New"
dense
flat
push
unelevated
no-caps
icon="add"
@click="showAddUserModal"
/>
</div>
<q-table
dense
:rows="users"
:columns="columns"
v-model:pagination="pagination"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
>
<!-- header slots -->
<template v-slot:header-cell-is_active="props">
<q-th :props="props" auto-width>
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enable User</q-tooltip>
</q-icon>
</q-th>
</template>
<!-- No data Slot -->
<template v-slot:no-data>
<div class="full-width row flex-center q-gutter-sm">
<span v-if="users.length === 0">No Users</span>
</div>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="showEditUserModal(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="showEditUserModal(props.row)"
>
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteUser(props.row)"
:disable="props.row.username === logged_in_user"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="ResetPassword(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Password</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="reset2FA(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Two-Factor Auth</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- enabled checkbox -->
<q-td>
<q-checkbox
dense
@update:model-value="toggleEnabled(props.row)"
v-model="props.row.is_active"
:disable="props.row.username === logged_in_user"
/>
</q-td>
<q-td>{{ props.row.username }}</q-td>
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
<q-td>{{ props.row.email }}</q-td>
<q-td v-if="props.row.last_login">{{
formatDate(props.row.last_login)
}}</q-td>
<q-td v-else>Never</q-td>
<q-td>{{ props.row.last_login_ip }}</q-td>
</q-tr>
</template>
</q-table>
</div> </div>
</q-card> <q-table
</div> dense
:rows="users"
:columns="columns"
v-model:pagination="pagination"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
>
<!-- header slots -->
<template v-slot:header-cell-is_active="props">
<q-th :props="props" auto-width>
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enable User</q-tooltip>
</q-icon>
</q-th>
</template>
<template v-slot:header-cell-sso="props">
<q-th :props="props" auto-width></q-th>
</template>
<!-- No data Slot -->
<template v-slot:no-data>
<div class="full-width row flex-center q-gutter-sm">
<span v-if="users.length === 0">No Users</span>
</div>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="showEditUserModal(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="showEditUserModal(props.row)"
>
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteUser(props.row)"
:disable="props.row.username === logged_in_user"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="ResetPassword(props.row)"
id="context-reset"
:disable="props.row.social_accounts.length !== 0"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Password</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="reset2FA(props.row)"
id="context-reset"
:disable="props.row.social_accounts.length !== 0"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Two-Factor Auth</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="showSSOAccounts(props.row)"
id="context-reset"
:disable="props.row.social_accounts.length === 0"
>
<q-item-section side>
<q-icon name="groups" />
</q-item-section>
<q-item-section>Show Connected SSO Accounts</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="showSessions(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="groups" />
</q-item-section>
<q-item-section>Show Active Sessions</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- enabled checkbox -->
<q-td>
<q-checkbox
dense
@update:model-value="toggleEnabled(props.row)"
v-model="props.row.is_active"
:disable="props.row.username === logged_in_user"
/>
</q-td>
<q-td>
<q-chip
v-if="props.row.social_accounts.length > 0"
color="primary"
dense
>SSO</q-chip
>
</q-td>
<q-td>{{ props.row.username }}</q-td>
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
<q-td>{{ props.row.email }}</q-td>
<q-td v-if="props.row.last_login">{{
formatDate(props.row.last_login)
}}</q-td>
<q-td v-else>Never</q-td>
<q-td>{{ props.row.last_login_ip }}</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-card>
</template> </template>
<script> <script>
import mixins from "@/mixins/mixins"; import mixins from "@/mixins/mixins";
import { computed } from "vue"; import { computed } from "vue";
import { mapState, useStore } from "vuex"; import { useStore } from "vuex";
import { useQuasar } from "quasar";
import { mapState as piniaMapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import UserForm from "@/components/modals/admin/UserForm.vue"; import UserForm from "@/components/modals/admin/UserForm.vue";
import UserResetPasswordForm from "@/components/modals/admin/UserResetPasswordForm.vue"; import UserResetPasswordForm from "@/components/modals/admin/UserResetPasswordForm.vue";
import SSOAccountsTable from "@/ee/sso/components/SSOAccountsTable.vue";
import UserSessionsTable from "@/components/accounts/UserSessionsTable.vue";
export default { export default {
name: "AdminManager", name: "AdminManager",
@@ -161,8 +206,30 @@ export default {
const store = useStore(); const store = useStore();
const formatDate = computed(() => store.getters.formatDate); const formatDate = computed(() => store.getters.formatDate);
const $q = useQuasar();
function showSSOAccounts(user) {
$q.dialog({
component: SSOAccountsTable,
componentProps: {
user,
},
});
}
async function showSessions(user) {
$q.dialog({
component: UserSessionsTable,
componentProps: {
user,
},
});
}
return { return {
formatDate, formatDate,
showSSOAccounts,
showSessions,
}; };
}, },
data() { data() {
@@ -175,6 +242,13 @@ export default {
field: "is_active", field: "is_active",
align: "left", align: "left",
}, },
{
name: "sso",
label: "",
field: "sso",
align: "left",
sortable: true,
},
{ {
name: "username", name: "username",
label: "Username", label: "Username",
@@ -316,7 +390,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapState({ ...piniaMapState(useAuthStore, {
logged_in_user: (state) => state.username, logged_in_user: (state) => state.username,
}), }),
}, },

View File

@@ -46,6 +46,9 @@
<template v-slot:header-cell-plat="props"> <template v-slot:header-cell-plat="props">
<q-th auto-width :props="props"></q-th> <q-th auto-width :props="props"></q-th>
</template> </template>
<template v-slot:header-cell-mon-type="props">
<q-th auto-width :props="props"></q-th>
</template>
<template v-slot:header-cell-checks-status="props"> <template v-slot:header-cell-checks-status="props">
<q-th :props="props"> <q-th :props="props">
<q-icon name="fas fa-check-double" size="1.2em"> <q-icon name="fas fa-check-double" size="1.2em">
@@ -206,6 +209,20 @@
</q-icon> </q-icon>
</q-td> </q-td>
<q-td key="mon-type" :props="props">
<q-icon
v-if="props.row.monitoring_type === 'server'"
name="dns"
size="sm"
color="primary"
>
<q-tooltip>Server</q-tooltip>
</q-icon>
<q-icon v-else name="computer" size="sm" color="primary">
<q-tooltip>Workstation</q-tooltip>
</q-icon>
</q-td>
<q-td key="checks-status" :props="props"> <q-td key="checks-status" :props="props">
<q-icon <q-icon
v-if="props.row.maintenance_mode" v-if="props.row.maintenance_mode"

View File

@@ -151,6 +151,14 @@
v-model="localRole.can_edit_core_settings" v-model="localRole.can_edit_core_settings"
label="Edit Global Settings" label="Edit Global Settings"
/> />
<q-checkbox
v-model="localRole.can_view_global_keystore"
label="View Global Key Store"
/>
<q-checkbox
v-model="localRole.can_edit_global_keystore"
label="Edit Global Key Store"
/>
<q-checkbox <q-checkbox
v-model="localRole.can_do_server_maint" v-model="localRole.can_do_server_maint"
label="Do Server Maintenance" label="Do Server Maintenance"
@@ -477,6 +485,8 @@ export default {
// settings perms // settings perms
can_view_core_settings: false, can_view_core_settings: false,
can_edit_core_settings: false, can_edit_core_settings: false,
can_view_global_keystore: false,
can_edit_global_keystore: false,
can_do_server_maint: false, can_do_server_maint: false,
can_code_sign: false, can_code_sign: false,
can_run_urlactions: false, can_run_urlactions: false,

View File

@@ -0,0 +1,151 @@
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card style="width: 60vw; max-width: 90vw; min-height: 40vh">
<q-bar>
User Sessions for {{ user.username }}
<q-space />
<q-btn v-close-popup dense flat icon="close">
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-table
dense
:table-class="{
'table-bgcolor': !$q.dark.isActive,
'table-bgcolor-dark': $q.dark.isActive,
}"
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
class="tbl-sticky"
:rows="sessions"
:columns="columns"
:loading="loading"
:pagination="{ rowsPerPage: 0, sortBy: 'display', descending: true }"
row-key="id"
binary-state-sort
virtual-scroll
:rows-per-page-options="[0]"
>
<template #top>
<q-space />
<q-btn
label="Remove All Sessions"
@click="removeAllSessions"
size="sm"
color="negative"
/>
</template>
<template #body="props">
<q-tr>
<!-- rows -->
<td>{{ formatDate(props.row.created) }}</td>
<td>{{ formatDate(props.row.expiry) }}</td>
<td>
<q-btn
size="sm"
@click="removeSession(props.row)"
label="Disconnect"
color="negative"
></q-btn>
</td>
</q-tr>
</template>
</q-table>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { onMounted, ref } from "vue";
import { useDialogPluginComponent, useQuasar, type QTableColumn } from "quasar";
import { notifySuccess } from "@/utils/notify";
import { formatDate } from "@/utils/format";
import {
fetchUserSessions,
deleteAllUserSessions,
deleteUserSession,
} from "@/api/accounts";
//types
import type { SSOUser } from "@/ee/sso/types/sso";
import type { AuthToken } from "@/types/accounts";
const columns: QTableColumn[] = [
{
name: "created",
label: "Created",
field: "created",
align: "left",
sortable: true,
},
{
name: "expiry",
label: "Expires",
field: "expiry",
align: "left",
sortable: true,
},
{
name: "action",
label: "",
field: "action",
align: "left",
sortable: true,
},
];
// emits
defineEmits([...useDialogPluginComponent.emits]);
// props
const props = defineProps<{
user: SSOUser;
}>();
const { dialogRef, onDialogHide } = useDialogPluginComponent();
const $q = useQuasar();
const sessions = ref([] as AuthToken[]);
const loading = ref(false);
function removeSession(token: AuthToken) {
$q.dialog({
title: `Disconnect session for ${token.user}?`,
message: "This user will be signed out immediately.",
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await deleteUserSession(token.digest);
notifySuccess("Login session deleted successfully");
} finally {
loading.value = false;
await getSessions();
}
});
}
function removeAllSessions() {
$q.dialog({
title: `Disconnect all sessions for ${props.user.username}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await deleteAllUserSessions(props.user.id);
notifySuccess("Login sessions deleted successfully");
} finally {
loading.value = false;
onDialogHide();
}
});
}
async function getSessions() {
sessions.value = await fetchUserSessions(props.user.id);
}
onMounted(getSessions);
</script>

View File

@@ -302,9 +302,9 @@ export default {
async function getURLActions() { async function getURLActions() {
menuLoading.value = true; menuLoading.value = true;
try { try {
urlActions.value = (await fetchURLActions()).filter( urlActions.value = (await fetchURLActions())
(action) => action.action_type === "web", .filter((action) => action.action_type === "web")
); .sort((a, b) => a.name.localeCompare(b.name));
if (urlActions.value.length === 0) { if (urlActions.value.length === 0) {
notifyWarning( notifyWarning(
@@ -312,8 +312,11 @@ export default {
); );
return; return;
} }
} catch (e) {} } catch (e) {
menuLoading.value = true; console.error(e);
} finally {
menuLoading.value = false;
}
} }
function showSendCommand(agent) { function showSendCommand(agent) {

View File

@@ -295,7 +295,12 @@
</q-td> </q-td>
<q-td v-else></q-td> <q-td v-else></q-td>
<!-- name --> <!-- name -->
<q-td>{{ props.row.name }}</q-td> <q-td
>{{ props.row.name
}}<q-tooltip v-if="props.row?.win_task_name" :delay="700">{{
props.row.win_task_name
}}</q-tooltip></q-td
>
<!-- sync status --> <!-- sync status -->
<q-td v-if="props.row.task_result.sync_status === 'notsynced'" <q-td v-if="props.row.task_result.sync_status === 'notsynced'"
>Will sync on next agent checkin</q-td >Will sync on next agent checkin</q-td

View File

@@ -370,7 +370,13 @@
style="cursor: pointer; text-decoration: underline" style="cursor: pointer; text-decoration: underline"
class="text-primary" class="text-primary"
@click="showPingInfo(props.row)" @click="showPingInfo(props.row)"
>Last Output</span >{{
grep(props.row.check_result.more_info, [
"transmitted",
"received",
"packet loss",
])
}}</span
> >
<span <span
v-else-if=" v-else-if="
@@ -379,7 +385,7 @@
style="cursor: pointer; text-decoration: underline" style="cursor: pointer; text-decoration: underline"
class="text-primary" class="text-primary"
@click="showScriptOutput(props.row.check_result)" @click="showScriptOutput(props.row.check_result)"
>Last Output</span >{{ processOutput(props.row.check_result) }}</span
> >
<span <span
v-else-if=" v-else-if="
@@ -392,7 +398,9 @@
> >
<span <span
v-else-if=" v-else-if="
props.row.check_type === 'diskspace' || ['diskspace', 'cpuload', 'memory'].includes(
props.row.check_type,
) ||
(props.row.check_type === 'winsvc' && props.row.check_result.id) (props.row.check_type === 'winsvc' && props.row.check_result.id)
" "
>{{ props.row.check_result.more_info }}</span >{{ props.row.check_result.more_info }}</span
@@ -510,6 +518,40 @@ export default {
descending: false, descending: false,
}); });
// TODO this will break when we add translations
function grep(text, stringsToMatch) {
try {
const lines = text.split("\n");
const matched = [];
for (const line of lines) {
if (stringsToMatch.every((str) => line.includes(str))) {
matched.push(line);
}
}
return matched.length > 0 ? matched.join("\n") : "Last Output";
} catch (e) {
console.error(e);
return "Last Output";
}
}
function processOutput(result) {
try {
if (result.stdout && result.stdout.trim() !== "") {
return result.stdout.substring(0, 60);
} else if (result.stderr && result.stderr.trim() !== "") {
return result.stderr.substring(0, 60);
} else {
return "Last Output";
}
} catch (e) {
console.error(e);
return "Last Output";
}
}
function getAlertSeverity(check) { function getAlertSeverity(check) {
if (check.check_result.alert_severity) { if (check.check_result.alert_severity) {
return check.check_result.alert_severity; return check.check_result.alert_severity;
@@ -707,6 +749,8 @@ export default {
getAlertSeverity, getAlertSeverity,
runChecks, runChecks,
resetAllChecks, resetAllChecks,
grep,
processOutput,
// dialogs // dialogs
showScriptOutput, showScriptOutput,

View File

@@ -17,70 +17,85 @@
:loading="loading" :loading="loading"
> >
<template v-slot:top> <template v-slot:top>
<q-btn <div class="q-gutter-md flex flex-center items-center">
v-if="isPolling"
dense
flat
push
@click="stopPoll"
icon="stop"
label="Stop Live Refresh"
/>
<q-btn
v-else
dense
flat
push
@click="startPoll"
icon="play_arrow"
label="Resume Live Refresh"
/>
<q-space />
<div class="q-pa-md q-gutter-sm">
<q-btn <q-btn
:disable="pollInterval === 1" v-if="isPolling"
dense dense
@click="pollIntervalChanged('subtract')" flat
push push
icon="remove" @click="stopPoll"
size="sm" icon="stop"
color="grey" label="Stop Live Refresh"
/> />
<q-btn <q-btn
v-else
dense dense
flat
push push
icon="add" @click="startPoll"
size="sm" icon="play_arrow"
color="grey" label="Resume Live Refresh"
@click="pollIntervalChanged('add')"
/> />
</div>
<div class="text-overline">
<q-badge
align="middle"
size="sm"
class="text-h6"
color="blue"
:label="pollInterval"
/>
Refresh interval (seconds)
</div>
<q-space /> <div class="flex flex-center q-ml-md">
<q-input v-model="filter" outlined label="Search" dense clearable> <q-icon name="fas fa-microchip" class="q-mr-xs" />
<template v-slot:prepend> <div class="text-caption q-mr-sm">
<q-icon name="search" /> CPU Usage:
</template> <span class="text-body1 text-weight-medium"
</q-input> >{{ totalCpuUsage }}%</span
<!-- file download doesn't work so disabling --> >
<export-table-btn </div>
v-show="false"
class="q-ml-sm" <q-icon name="fas fa-memory" class="q-mr-xs" />
:columns="columns" <div class="text-caption">
:data="processes" RAM Usage:
/> <span class="text-body1 text-weight-medium"
>{{ bytes2Human(totalRamUsage) }}/{{ total_ram }} GB</span
>
</div>
</div>
<q-space />
<div class="q-pa-md q-gutter-sm">
<q-btn
:disable="pollInterval === 1"
dense
@click="pollIntervalChanged('subtract')"
push
icon="remove"
size="sm"
color="grey"
/>
<q-btn
dense
push
icon="add"
size="sm"
color="grey"
@click="pollIntervalChanged('add')"
/>
</div>
<div class="text-overline">
<q-badge
align="middle"
size="sm"
class="text-h6"
color="blue"
:label="pollInterval"
/>
Refresh interval (seconds)
</div>
<q-space />
<q-input v-model="filter" outlined label="Search" dense clearable>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props" class="cursor-pointer"> <q-tr :props="props" class="cursor-pointer">
@@ -121,9 +136,6 @@ import {
import { bytes2Human } from "@/utils/format"; import { bytes2Human } from "@/utils/format";
import { notifySuccess } from "@/utils/notify"; import { notifySuccess } from "@/utils/notify";
// ui imports
import ExportTableBtn from "@/components/ui/ExportTableBtn.vue";
const columns = [ const columns = [
{ {
name: "name", name: "name",
@@ -164,7 +176,6 @@ const columns = [
]; ];
export default { export default {
components: { ExportTableBtn },
name: "ProcessManager", name: "ProcessManager",
props: { props: {
agent_id: !String, agent_id: !String,
@@ -175,52 +186,71 @@ export default {
const poll = ref(null); const poll = ref(null);
const isPolling = computed(() => !!poll.value); const isPolling = computed(() => !!poll.value);
async function startPoll() { function startPoll() {
await getProcesses(); stopPoll();
if (processes.value.length > 0) { getProcesses();
refreshProcesses(); poll.value = setInterval(() => {
} getProcesses();
}, pollInterval.value * 1000);
} }
function stopPoll() { function stopPoll() {
clearInterval(poll.value); if (poll.value) {
poll.value = null; clearInterval(poll.value);
poll.value = null;
}
} }
function pollIntervalChanged(action) { function pollIntervalChanged(action) {
if (action === "subtract" && pollInterval.value <= 1) {
stopPoll();
startPoll();
return;
}
if (action === "add") { if (action === "add") {
pollInterval.value++; pollInterval.value++;
} else { } else if (action === "subtract" && pollInterval.value > 1) {
pollInterval.value--; pollInterval.value--;
} }
stopPoll(); if (isPolling.value) {
startPoll(); startPoll();
}
} }
// process manager logic // process manager logic
const processes = ref([]); const processes = ref([]);
const filter = ref(""); const filter = ref("");
const memory = ref(null); const total_ram = ref(0);
const loading = ref(false); const loading = ref(false);
const totalCpuUsage = computed(() => {
if (!Array.isArray(processes.value) || processes.value.length === 0) {
return "0.00";
}
const total = processes.value.reduce((acc, proc) => {
const cpuPercent = parseFloat(proc.cpu_percent);
if (isNaN(cpuPercent)) {
return acc;
}
return acc + cpuPercent;
}, 0);
return total.toFixed(2);
});
const totalRamUsage = computed(() => {
return processes.value.reduce((acc, proc) => acc + proc.membytes, 0);
});
async function getProcesses() { async function getProcesses() {
loading.value = true; loading.value = true;
processes.value = await fetchAgentProcesses(props.agent_id); try {
processes.value = await fetchAgentProcesses(props.agent_id);
} catch (error) {
console.error(error);
}
loading.value = false; loading.value = false;
} }
function refreshProcesses() {
poll.value = setInterval(() => {
getProcesses(props.agent_id);
}, pollInterval.value * 1000);
}
async function killProcess(pid) { async function killProcess(pid) {
loading.value = true; loading.value = true;
let result = ""; let result = "";
@@ -235,11 +265,8 @@ export default {
// lifecycle hooks // lifecycle hooks
onMounted(async () => { onMounted(async () => {
memory.value = await fetchAgent(props.agent_id).total_ram; total_ram.value = (await fetchAgent(props.agent_id)).total_ram;
await getProcesses(); startPoll();
if (processes.value.length > 0) {
refreshProcesses();
}
}); });
onBeforeUnmount(() => clearInterval(poll.value)); onBeforeUnmount(() => clearInterval(poll.value));
@@ -248,10 +275,12 @@ export default {
// reactive data // reactive data
processes, processes,
filter, filter,
memory, total_ram,
isPolling, isPolling,
pollInterval, pollInterval,
loading, loading,
totalCpuUsage,
totalRamUsage,
// non-reactive data // non-reactive data
columns, columns,

View File

@@ -2,6 +2,15 @@
<q-dialog ref="dialog" @hide="onHide"> <q-dialog ref="dialog" @hide="onHide">
<q-card class="q-dialog-plugin" style="min-width: 70vw"> <q-card class="q-dialog-plugin" style="min-width: 70vw">
<q-bar> <q-bar>
<q-btn
ref="refresh"
@click="refresh"
class="q-mr-sm"
dense
flat
push
icon="refresh"
/>
{{ title.slice(0, 27) }} {{ title.slice(0, 27) }}
<q-space /> <q-space />
<q-btn dense flat icon="close" v-close-popup> <q-btn dense flat icon="close" v-close-popup>
@@ -281,6 +290,13 @@ export default {
}, },
}); });
}, },
refresh() {
if (this.type === "task") {
this.getTaskData();
} else {
this.getCheckData();
}
},
show() { show() {
this.$refs.dialog.show(); this.$refs.dialog.show();
}, },

View File

@@ -20,12 +20,18 @@
</div> </div>
<br /> <br />
<div v-if="scriptInfo.stdout"> <div v-if="scriptInfo.stdout">
Standard Output <script-output-copy-clip
label="Standard Output"
:data="scriptInfo.stdout"
/>
<q-separator /> <q-separator />
<pre>{{ scriptInfo.stdout }}</pre> <pre>{{ scriptInfo.stdout }}</pre>
</div> </div>
<div v-if="scriptInfo.stderr"> <div v-if="scriptInfo.stderr">
Standard Error <script-output-copy-clip
label="Standard Error"
:data="scriptInfo.stderr"
/>
<q-separator /> <q-separator />
<pre>{{ scriptInfo.stderr }}</pre> <pre>{{ scriptInfo.stderr }}</pre>
</div> </div>
@@ -43,8 +49,13 @@ import { computed } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { useDialogPluginComponent } from "quasar"; import { useDialogPluginComponent } from "quasar";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
export default { export default {
name: "ScriptOutput", name: "ScriptOutput",
components: {
ScriptOutputCopyClip,
},
emits: [...useDialogPluginComponent.emits], emits: [...useDialogPluginComponent.emits],
props: { scriptInfo: !Object }, props: { scriptInfo: !Object },
setup() { setup() {

View File

@@ -116,7 +116,8 @@
</template> </template>
<script> <script>
import { mapState } from "vuex"; import { mapState as piniaMapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import mixins from "@/mixins/mixins"; import mixins from "@/mixins/mixins";
export default { export default {
@@ -145,7 +146,7 @@ export default {
title() { title() {
return this.user ? "Edit User" : "Add User"; return this.user ? "Edit User" : "Add User";
}, },
...mapState({ ...piniaMapState(useAuthStore, {
logged_in_user: (state) => state.username, logged_in_user: (state) => state.username,
}), }),
}, },

View File

@@ -88,7 +88,24 @@
outlined outlined
mapOptions mapOptions
filterable filterable
/> >
<template v-slot:after>
<q-btn
size="sm"
round
dense
flat
icon="info"
@click="openScriptURL"
>
<q-tooltip
v-if="syntax"
class="bg-white text-primary text-body1"
v-html="formatScriptSyntax(syntax)"
/>
</q-btn>
</template>
</tactical-dropdown>
</q-card-section> </q-card-section>
<q-card-section v-if="mode === 'script'" class="q-pt-none"> <q-card-section v-if="mode === 'script'" class="q-pt-none">
<tactical-dropdown <tactical-dropdown
@@ -153,6 +170,39 @@
</q-checkbox> </q-checkbox>
</q-card-section> </q-card-section>
<q-card-section v-if="mode === 'script'" class="q-pt-none">
<div class="q-gutter-sm">
<q-checkbox
label="Save results to Custom Field"
v-model="collector"
@update:model-value="
state.custom_field = null;
state.collector_all_output = false;
"
/>
<q-checkbox
v-model="state.save_to_agent_note"
label="Save results to Agent Note"
/>
</div>
</q-card-section>
<q-card-section v-if="mode === 'script' && collector">
<tactical-dropdown
:rules="[(val) => !!val || '*Required']"
outlined
v-model="state.custom_field"
:options="customFieldOptions"
label="Select custom field"
mapOptions
filterable
/>
<q-checkbox
v-model="state.collector_all_output"
label="Save all output"
/>
</q-card-section>
<q-card-section v-if="mode === 'script' || mode === 'command'"> <q-card-section v-if="mode === 'script' || mode === 'command'">
<q-input <q-input
v-model.number="state.timeout" v-model.number="state.timeout"
@@ -218,12 +268,14 @@ import {
onMounted, onMounted,
defineComponent, defineComponent,
} from "vue"; } from "vue";
import { useDialogPluginComponent } from "quasar"; import { useDialogPluginComponent, openURL } from "quasar";
import { useScriptDropdown } from "@/composables/scripts"; import { useScriptDropdown } from "@/composables/scripts";
import { useAgentDropdown } from "@/composables/agents"; import { useAgentDropdown } from "@/composables/agents";
import { useClientDropdown, useSiteDropdown } from "@/composables/clients"; import { useClientDropdown, useSiteDropdown } from "@/composables/clients";
import { useCustomFieldDropdown } from "@/composables/core";
import { runBulkAction } from "@/api/agents"; import { runBulkAction } from "@/api/agents";
import { notifySuccess } from "@/utils/notify"; import { notifySuccess } from "@/utils/notify";
import { formatScriptSyntax } from "@/utils/format";
import { cmdPlaceholder } from "@/composables/agents"; import { cmdPlaceholder } from "@/composables/agents";
import { envVarsLabel, runAsUserToolTip } from "@/constants/constants"; import { envVarsLabel, runAsUserToolTip } from "@/constants/constants";
@@ -297,11 +349,18 @@ export default defineComponent({
defaultTimeout, defaultTimeout,
defaultArgs, defaultArgs,
defaultEnvVars, defaultEnvVars,
syntax,
link,
getScriptOptions, getScriptOptions,
} = useScriptDropdown(); } = useScriptDropdown();
const { agents, agentOptions, getAgentOptions } = useAgentDropdown(); const { agents, agentOptions, getAgentOptions } = useAgentDropdown();
const { site, siteOptions, getSiteOptions } = useSiteDropdown(); const { site, siteOptions, getSiteOptions } = useSiteDropdown();
const { client, clientOptions, getClientOptions } = useClientDropdown(); const { client, clientOptions, getClientOptions } = useClientDropdown();
const { customFieldOptions } = useCustomFieldDropdown({ onMount: true });
function openScriptURL() {
link.value ? openURL(link.value) : null;
}
// bulk action logic // bulk action logic
const state = reactive({ const state = reactive({
@@ -312,6 +371,9 @@ export default defineComponent({
cmd: "", cmd: "",
shell: "cmd", shell: "cmd",
custom_shell: null, custom_shell: null,
custom_field: null,
collector_all_output: false,
save_to_agent_note: false,
patchMode: "scan", patchMode: "scan",
offlineAgents: false, offlineAgents: false,
client, client,
@@ -324,6 +386,7 @@ export default defineComponent({
run_as_user: false, run_as_user: false,
}); });
const loading = ref(false); const loading = ref(false);
const collector = ref(false);
watch( watch(
() => state.target, () => state.target,
@@ -395,6 +458,8 @@ export default defineComponent({
state, state,
agentOptions, agentOptions,
clientOptions, clientOptions,
collector,
customFieldOptions,
siteOptions, siteOptions,
filterByPlatformOptions, filterByPlatformOptions,
loading, loading,
@@ -408,6 +473,7 @@ export default defineComponent({
patchModeOptions, patchModeOptions,
runAsUserToolTip, runAsUserToolTip,
envVarsLabel, envVarsLabel,
syntax,
//computed //computed
modalTitle, modalTitle,
@@ -416,6 +482,8 @@ export default defineComponent({
submit, submit,
cmdPlaceholder, cmdPlaceholder,
supportsRunAsUser, supportsRunAsUser,
openScriptURL,
formatScriptSyntax,
// quasar dialog plugin // quasar dialog plugin
dialogRef, dialogRef,

View File

@@ -137,7 +137,7 @@
<q-radio <q-radio
v-model="goarch" v-model="goarch"
:val="GOARCH_ARM64" :val="GOARCH_ARM64"
label="Apple Silicon (M1, M2, M3)" label="Apple Silicon (M-Series)"
v-show="agentOS === 'darwin'" v-show="agentOS === 'darwin'"
/> />
<q-radio <q-radio

View File

@@ -89,7 +89,7 @@
new-value-mode="add" new-value-mode="add"
/> />
</q-card-section> </q-card-section>
<q-card-section> <q-card-section v-if="!state.run_on_server">
<q-option-group <q-option-group
v-model="state.output" v-model="state.output"
:options="outputOptions" :options="outputOptions"
@@ -140,10 +140,30 @@
/> />
<q-checkbox v-model="state.save_all_output" label="Save all output" /> <q-checkbox v-model="state.save_all_output" label="Save all output" />
</q-card-section> </q-card-section>
<q-card-section v-if="agent.plat === 'windows'"> <q-card-section>
<q-checkbox v-model="state.run_as_user" label="Run As User"> <q-checkbox
v-if="agent.plat === 'windows' && !state.run_on_server"
v-model="state.run_as_user"
label="Run As User"
>
<q-tooltip>{{ runAsUserToolTip }}</q-tooltip> <q-tooltip>{{ runAsUserToolTip }}</q-tooltip>
</q-checkbox> </q-checkbox>
<q-checkbox
v-if="!hosted"
:disable="!server_scripts_enabled"
v-model="state.run_on_server"
label="Run On Server"
@update:model-value="ret = null"
>
<q-tooltip v-if="!server_scripts_enabled"
>Enable server side scripts globally to activate this
feature.</q-tooltip
>
<q-tooltip v-else
>Run the script on the Tactical RMM server in the context of this
agent.</q-tooltip
>
</q-checkbox>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<q-input <q-input
@@ -175,7 +195,39 @@
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll" class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
style="max-height: 50vh" style="max-height: 50vh"
> >
<pre>{{ ret }}</pre> <script-output-copy-clip
v-if="!state.run_on_server"
label="Output"
:data="ret"
/>
<q-separator />
<pre v-if="!state.run_on_server">{{ ret }}</pre>
<q-card-section v-if="state.run_on_server" class="scroll">
<div>
Run Time:
<code>{{ ret.execution_time }} seconds</code>
<br />Return Code:
<code>{{ ret.retcode }}</code>
<br />
</div>
<br />
<div v-if="ret.stdout">
<script-output-copy-clip
label="Standard Output"
:data="ret.stdout"
/>
<q-separator />
<pre>{{ ret.stdout }}</pre>
</div>
<div v-if="ret.stderr">
<script-output-copy-clip
label="Standard Error"
:data="ret.stderr"
/>
<q-separator />
<pre>{{ ret.stderr }}</pre>
</div>
</q-card-section>
</q-card-section> </q-card-section>
</q-form> </q-form>
</q-card> </q-card>
@@ -184,7 +236,8 @@
<script setup lang="ts"> <script setup lang="ts">
// composition imports // composition imports
import { ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useStore } from "vuex";
import { useDialogPluginComponent, openURL } from "quasar"; import { useDialogPluginComponent, openURL } from "quasar";
import { useScriptDropdown } from "@/composables/scripts"; import { useScriptDropdown } from "@/composables/scripts";
import { useCustomFieldDropdown } from "@/composables/core"; import { useCustomFieldDropdown } from "@/composables/core";
@@ -195,10 +248,18 @@ import { formatScriptSyntax } from "@/utils/format";
//ui imports //ui imports
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue"; import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
// types // types
import type { Agent } from "@/types/agents"; import type { Agent } from "@/types/agents";
// store
const store = useStore();
const hosted = computed(() => store.state.hosted);
const server_scripts_enabled = computed(
() => store.state.server_scripts_enabled,
);
// static data // static data
const outputOptions = [ const outputOptions = [
{ label: "Wait for Output", value: "wait" }, { label: "Wait for Output", value: "wait" },
@@ -248,6 +309,7 @@ const state = ref({
env_vars: defaultEnvVars, env_vars: defaultEnvVars,
timeout: defaultTimeout, timeout: defaultTimeout,
run_as_user: false, run_as_user: false,
run_on_server: false,
}); });
const ret = ref(null); const ret = ref(null);

View File

@@ -104,6 +104,9 @@
type="submit" type="submit"
/> />
</q-card-actions> </q-card-actions>
<q-card-section v-if="ret !== null"
><script-output-copy-clip label="Output" :data="ret" /> <q-separator
/></q-card-section>
<q-card-section <q-card-section
v-if="ret !== null" v-if="ret !== null"
class="q-pl-md q-pr-md q-pt-none q-ma-none scroll" class="q-pl-md q-pr-md q-pt-none q-ma-none scroll"
@@ -124,8 +127,13 @@ import { sendAgentCommand } from "@/api/agents";
import { cmdPlaceholder } from "@/composables/agents"; import { cmdPlaceholder } from "@/composables/agents";
import { runAsUserToolTip } from "@/constants/constants"; import { runAsUserToolTip } from "@/constants/constants";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
export default { export default {
name: "SendCommand", name: "SendCommand",
components: {
ScriptOutputCopyClip,
},
emits: [...useDialogPluginComponent.emits], emits: [...useDialogPluginComponent.emits],
props: { props: {
agent: !Object, agent: !Object,

View File

@@ -249,6 +249,20 @@ export default {
sortable: true, sortable: true,
format: (a) => this.formatDate(a), format: (a) => this.formatDate(a),
}, },
{
name: "client",
label: "Client",
field: "client",
align: "left",
sortable: true,
},
{
name: "site",
label: "Site",
field: "site",
align: "left",
sortable: true,
},
{ {
name: "hostname", name: "hostname",
label: "Agent", label: "Agent",

View File

@@ -48,6 +48,7 @@
<!-- name --> <!-- name -->
<q-td> <q-td>
{{ props.row.name }} {{ props.row.name }}
<q-tooltip :delay="600">ID: {{ props.row.id }}</q-tooltip>
</q-td> </q-td>
<!-- type --> <!-- type -->
<q-td> <q-td>

View File

@@ -13,6 +13,7 @@
<q-tab name="webhooks" label="Web Hooks" /> <q-tab name="webhooks" label="Web Hooks" />
<q-tab name="retention" label="Retention" /> <q-tab name="retention" label="Retention" />
<q-tab name="apikeys" label="API Keys" /> <q-tab name="apikeys" label="API Keys" />
<q-tab name="sso" label="Single Sign-On (SSO)" />
<!-- <q-tab name="openai" label="Open AI" /> --> <!-- <q-tab name="openai" label="Open AI" /> -->
</q-tabs> </q-tabs>
</template> </template>
@@ -636,6 +637,11 @@
<APIKeysTable /> <APIKeysTable />
</q-tab-panel> </q-tab-panel>
<!-- sso integration -->
<q-tab-panel name="sso">
<SSOProvidersTable />
</q-tab-panel>
<!-- Open AI --> <!-- Open AI -->
<!-- <q-tab-panel name="openai"> <!-- <q-tab-panel name="openai">
<div class="text-subtitle2">Open AI</div> <div class="text-subtitle2">Open AI</div>
@@ -685,7 +691,8 @@
v-show=" v-show="
tab !== 'customfields' && tab !== 'customfields' &&
tab !== 'keystore' && tab !== 'keystore' &&
tab !== 'urlactions' tab !== 'urlactions' &&
tab !== 'sso'
" "
label="Save" label="Save"
color="primary" color="primary"
@@ -722,6 +729,7 @@ import CustomFields from "@/components/modals/coresettings/CustomFields.vue";
import KeyStoreTable from "@/components/modals/coresettings/KeyStoreTable.vue"; import KeyStoreTable from "@/components/modals/coresettings/KeyStoreTable.vue";
import URLActionsTable from "@/components/modals/coresettings/URLActionsTable.vue"; import URLActionsTable from "@/components/modals/coresettings/URLActionsTable.vue";
import APIKeysTable from "@/components/core/APIKeysTable.vue"; import APIKeysTable from "@/components/core/APIKeysTable.vue";
import SSOProvidersTable from "@/ee/sso/components/SSOProvidersTable.vue";
export default { export default {
name: "EditCoreSettings", name: "EditCoreSettings",
@@ -731,7 +739,7 @@ export default {
KeyStoreTable, KeyStoreTable,
URLActionsTable, URLActionsTable,
APIKeysTable, APIKeysTable,
// ServerTasksTable, SSOProvidersTable,
}, },
mixins: [mixins], mixins: [mixins],
data() { data() {
@@ -767,6 +775,13 @@ export default {
return this.$store.state.hosted; return this.$store.state.hosted;
}, },
}, },
watch: {
tab(newTab, oldTab) {
if (oldTab === "sso") {
this.getCoreSettings();
}
},
},
methods: { methods: {
openURL(url) { openURL(url) {
openURL(url); openURL(url);

View File

@@ -27,8 +27,16 @@
outlined outlined
dense dense
v-model="localKey.value" v-model="localKey.value"
:type="isPwd ? 'password' : 'text'"
:rules="[(val) => !!val || '*Required']" :rules="[(val) => !!val || '*Required']"
/> ><template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
@@ -50,6 +58,7 @@ export default {
props: { globalKey: Object }, props: { globalKey: Object },
data() { data() {
return { return {
isPwd: true,
localKey: { localKey: {
name: "", name: "",
value: "", value: "",

View File

@@ -3,6 +3,15 @@
<div class="row"> <div class="row">
<div class="text-subtitle2">Global Key Store</div> <div class="text-subtitle2">Global Key Store</div>
<q-space /> <q-space />
<q-btn
size="sm"
color="grey-5"
text-color="black"
class="q-mr-sm"
:label="isPwd ? 'Show values' : 'Hide values'"
:icon="isPwd ? 'visibility_off' : 'visibility'"
@click="isPwd = !isPwd"
/>
<q-btn <q-btn
size="sm" size="sm"
color="grey-5" color="grey-5"
@@ -61,7 +70,7 @@
</q-td> </q-td>
<!-- value --> <!-- value -->
<q-td> <q-td>
{{ props.row.value }} {{ isPwd ? "****" : props.row.value }}
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
@@ -79,6 +88,7 @@ export default {
data() { data() {
return { return {
keystore: [], keystore: [],
isPwd: true,
pagination: { pagination: {
rowsPerPage: 0, rowsPerPage: 0,
sortBy: "name", sortBy: "name",

View File

@@ -5,7 +5,10 @@
@show="loadEditor" @show="loadEditor"
@before-hide="cleanupEditors" @before-hide="cleanupEditors"
> >
<q-card class="q-dialog-plugin" style="width: 60vw"> <q-card
class="q-dialog-plugin"
:style="`width: ${props.type === 'web' ? 50 : 60}vw; max-width: ${props.type === 'web' ? 60 : 70}vw`"
>
<q-bar> <q-bar>
{{ {{
props.action props.action
@@ -71,7 +74,6 @@
<q-card-section v-show="type === 'rest'"> <q-card-section v-show="type === 'rest'">
<q-toolbar> <q-toolbar>
<q-space />
<q-tabs v-model="tab" dense shrink> <q-tabs v-model="tab" dense shrink>
<q-tab <q-tab
name="body" name="body"

View File

@@ -313,18 +313,19 @@ export default {
}, },
getURLActions() { getURLActions() {
this.$axios.get("/core/urlaction/").then((r) => { this.$axios.get("/core/urlaction/").then((r) => {
if (r.data.length === 0) {
this.notifyWarning(
"No URL Actions configured. Go to Settings > Global Settings > URL Actions",
);
return;
}
this.urlActions = r.data this.urlActions = r.data
.filter((action) => action.action_type === "web") .filter((action) => action.action_type === "web")
.sort((a, b) => a.name.localeCompare(b.name))
.map((action) => ({ .map((action) => ({
label: action.name, label: action.name,
value: action.id, value: action.id,
})); }));
if (this.urlActions.length === 0) {
this.notifyWarning(
"No URL Actions configured. Go to Settings > Global Settings > URL Actions",
);
}
}); });
}, },
getUserPrefs() { getUserPrefs() {

View File

@@ -539,6 +539,7 @@
> >
{{ props.row.name }} {{ props.row.name }}
</q-tooltip> </q-tooltip>
<q-tooltip :delay="600">ID: {{ props.row.id }}</q-tooltip>
</q-td> </q-td>
<!-- args --> <!-- args -->
<q-td key="args" :props="props"> <q-td key="args" :props="props">

View File

@@ -0,0 +1,26 @@
<template>
<div class="row q-gutter-sm items-center">
<div class="col-auto">{{ label }}</div>
<div class="col-auto">
<q-btn dense flat size="md" icon="content_copy" @click="copyText">
<q-tooltip>Copy to Clipboard</q-tooltip>
</q-btn>
</div>
</div>
</template>
<script setup lang="ts">
import { copyOutput } from "@/utils/helpers";
const props = defineProps({
label: String,
data: {
type: String,
required: true,
},
});
const copyText = () => {
copyOutput(props.data);
};
</script>

View File

@@ -42,15 +42,7 @@
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<q-file <q-file label="Script Upload" v-model="file" filled dense counter>
label="Script Upload"
v-model="file"
hint="Supported file types: .ps1, .bat, .py, .sh"
filled
dense
counter
accept=".ps1, .bat, .py, .sh"
>
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="attach_file" /> <q-icon name="attach_file" />
</template> </template>

View File

@@ -18,12 +18,12 @@
</div> </div>
<br /> <br />
<div v-if="ret.stdout"> <div v-if="ret.stdout">
Standard Output <script-output-copy-clip label="Standard Output" :data="ret.stdout" />
<q-separator /> <q-separator />
<pre>{{ ret.stdout }}</pre> <pre>{{ ret.stdout }}</pre>
</div> </div>
<div v-if="ret.stderr"> <div v-if="ret.stderr">
Standard Error <script-output-copy-clip label="Standard Error" :data="ret.stderr" />
<q-separator /> <q-separator />
<pre>{{ ret.stderr }}</pre> <pre>{{ ret.stderr }}</pre>
</div> </div>
@@ -38,9 +38,13 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { testScript, testScriptOnServer } from "@/api/scripts"; import { testScript, testScriptOnServer } from "@/api/scripts";
import { useDialogPluginComponent } from "quasar"; import { useDialogPluginComponent } from "quasar";
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
export default { export default {
name: "TestScriptModal", name: "TestScriptModal",
components: {
ScriptOutputCopyClip,
},
emits: [...useDialogPluginComponent.emits], emits: [...useDialogPluginComponent.emits],
props: { props: {
script: !Object, script: !Object,

View File

@@ -1,5 +1,5 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { fetchUsers } from "@/api/accounts"; import { fetchUsers, fetchRoles } from "@/api/accounts";
import { formatUserOptions } from "@/utils/format"; import { formatUserOptions } from "@/utils/format";
export function useUserDropdown(onMount = false) { export function useUserDropdown(onMount = false) {
@@ -44,3 +44,26 @@ export function useUserDropdown(onMount = false) {
getDynamicUserOptions, getDynamicUserOptions,
}; };
} }
export function useRoleDropdown(opts = {}) {
const roleOptions = ref([]);
async function getRoleOptions() {
const roles = await fetchRoles();
roleOptions.value = roles.map((role) => ({
value: role.id,
label: role.name,
}));
}
if (opts.onMount) {
onMounted(getRoleOptions);
}
return {
//data
roleOptions,
//methods
getRoleOptions,
};
}

View File

@@ -4,7 +4,7 @@ Copyright (c) 2023 Amidaware Inc. All rights reserved.
This Agreement is entered into between the licensee ("You" or the "Licensee") and Amidaware Inc. ("Amidaware") and governs the use of the enterprise features of the Tactical RMM Software (hereinafter referred to as the "Software"). This Agreement is entered into between the licensee ("You" or the "Licensee") and Amidaware Inc. ("Amidaware") and governs the use of the enterprise features of the Tactical RMM Software (hereinafter referred to as the "Software").
The EE features of the Software, including but not limited to Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference. The EE features of the Software, including but not limited to SSO (Single Sign-On), Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference.
## License Grant ## License Grant

144
src/ee/sso/api/sso.ts Normal file
View File

@@ -0,0 +1,144 @@
/*
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
*/
import axios from "axios";
import { getCookie } from "@/ee/sso/utils/cookies";
import { getBaseUrl } from "@/boot/axios";
import { useStorage } from "@vueuse/core";
import type {
SSOAccount,
SSOProvider,
SSOSettingsType,
} from "@/ee/sso/types/sso";
const baseUrl = "accounts";
interface FormData {
provider: string;
process: string;
callback_url: string;
csrfmiddlewaretoken: string;
}
export function getCSRFToken() {
return getCookie("csrftoken");
}
// needed for sso provider redirect
function postForm(url: string, data: FormData) {
const f = document.createElement("form");
f.method = "POST";
f.action = url;
for (const key in data) {
const d = document.createElement("input");
d.type = "hidden";
d.name = key;
d.value = data[key];
f.appendChild(d);
}
document.body.appendChild(f);
f.submit();
}
// sso providers
export async function fetchSSOProviders(): Promise<SSOProvider[]> {
const { data } = await axios.get(`${baseUrl}/ssoproviders/`);
return data;
}
export async function addSSOProvider(payload: SSOProvider) {
const { data } = await axios.post(`${baseUrl}/ssoproviders/`, payload);
return data;
}
export async function editSSOProvider(id: number, payload: SSOProvider) {
const { data } = await axios.put(`${baseUrl}/ssoproviders/${id}/`, payload);
return data;
}
export async function removeSSOProvider(id: number) {
const { data } = await axios.delete(`${baseUrl}/ssoproviders/${id}/`);
return data;
}
export async function fetchSSOSettings(): Promise<SSOSettingsType> {
const { data } = await axios.get(`${baseUrl}/ssoproviders/settings/`);
return data;
}
export async function updateSSOSettings(settings: SSOSettingsType) {
const { data } = await axios.post(
`${baseUrl}/ssoproviders/settings/`,
settings,
);
return data;
}
export async function getSSOProviderToken() {
const { data } = await axios.post(
`${baseUrl}/ssoproviders/token/`,
{},
{
headers: { "X-CSRFToken": getCSRFToken() },
},
);
return data;
}
export async function disconnectSSOAccount(
provider: string,
account: string,
): Promise<SSOAccount> {
const { data } = await axios.delete(`${baseUrl}/ssoproviders/account/`, {
data: { provider, account },
});
return data;
}
// allauth
const allauthBase = "_allauth/browser/v1";
export interface AllAuthResponse<T> {
data: T;
status: number;
meta?: {
is_autheticated: boolean;
};
}
export interface SSOProviderConfig {
client_id: string;
flows: string[];
id: string;
name: string;
}
export interface SSOConfigResponse {
socialaccount: {
providers: SSOProviderConfig[];
};
}
export async function getSSOConfig(): Promise<
AllAuthResponse<SSOConfigResponse>
> {
const { data } = await axios.get(`${allauthBase}/config/`);
return data;
}
export async function openSSOProviderRedirect(id: string) {
//save provider to local storage
useStorage("provider_id", id);
postForm(`${getBaseUrl()}/${allauthBase}/auth/provider/redirect/`, {
provider: id,
process: "login",
callback_url: `${location.origin}/account/provider/callback`,
csrfmiddlewaretoken: getCSRFToken() || "",
});
}

View File

@@ -0,0 +1,142 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card style="width: 60vw; max-width: 90vw; min-height: 40vh">
<q-bar>
Connected Social Accounts for {{ user.username }}
<q-space />
<q-btn v-close-popup dense flat icon="close">
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-table
dense
:table-class="{
'table-bgcolor': !$q.dark.isActive,
'table-bgcolor-dark': $q.dark.isActive,
}"
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
class="tbl-sticky"
:rows="user.social_accounts"
:columns="columns"
:loading="loading"
:pagination="{ rowsPerPage: 0, sortBy: 'display', descending: true }"
row-key="id"
binary-state-sort
virtual-scroll
:rows-per-page-options="[0]"
>
<template #body="props">
<q-tr>
<!-- rows -->
<td>{{ props.row.display }}</td>
<td>{{ props.row.provider }}</td>
<td>{{ formatDate(props.row.last_login) }}</td>
<td>{{ formatDate(props.row.date_joined) }}</td>
<td>
<q-btn
size="sm"
@click="removeSSOAccount(props.row)"
label="Disconnect"
color="negative"
></q-btn>
</td>
</q-tr>
</template>
</q-table>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { ref } from "vue";
import { useDialogPluginComponent, useQuasar, type QTableColumn } from "quasar";
import { disconnectSSOAccount } from "@/ee/sso/api/sso";
import { notifySuccess } from "@/utils/notify";
import { useAuthStore } from "@/stores/auth";
import { formatDate } from "@/utils/format";
//types
import type { SSOAccount, SSOUser } from "../types/sso";
const columns: QTableColumn[] = [
{
name: "display",
label: "Display Name",
field: "display",
align: "left",
sortable: true,
},
{
name: "provider",
label: "Provider",
field: "provider",
align: "left",
sortable: true,
},
{
name: "last_login",
label: "Last Login",
field: "last_login",
align: "left",
sortable: true,
},
{
name: "date_joined",
label: "Date Joined",
field: "date_joined",
align: "left",
sortable: true,
},
{
name: "action",
label: "",
field: "action",
align: "left",
sortable: true,
},
];
// emits
defineEmits([...useDialogPluginComponent.emits]);
// props
const props = defineProps<{
user: SSOUser;
}>();
const { dialogRef, onDialogHide } = useDialogPluginComponent();
const $q = useQuasar();
const auth = useAuthStore();
const loading = ref(false);
function removeSSOAccount(account: SSOAccount) {
$q.dialog({
title: `Disconnect social account: ${account.display}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await disconnectSSOAccount(account.provider, account.uid);
notifySuccess("Social account disconnected successfully");
if (
auth.username === props.user.username &&
auth.ssoLoginProvider === account.provider
) {
await auth.logout();
}
} finally {
loading.value = false;
onDialogHide();
}
});
}
</script>

View File

@@ -0,0 +1,160 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<q-dialog persistent ref="dialogRef" @hide="onDialogHide">
<q-card class="q-dialog-plugin" style="width: 35vw; max-width: 35vw">
<q-bar>
{{ props.provider ? "Edit OIDC Provider" : "Add OIDC Provider" }}
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<!-- name -->
<q-card-section>
<q-input
:readonly="!!props.provider"
:disable="!!props.provider"
label="Provider Name"
outlined
dense
v-model="localProvider.name"
:rules="[
(val) => !!val || '*Required',
(val) =>
/^[a-zA-Z0-9_-]+$/.test(val) ||
'Only letters, numbers, hyphens, and underscores are allowed',
]"
hint="A unique identifier for the SSO provider. Avoid spaces and special characters, as this will be part of the callback URL."
/>
</q-card-section>
<!-- url -->
<q-card-section>
<q-input
label="Issuer URL"
outlined
dense
v-model="localProvider.server_url"
:rules="[(val) => !!val || '*Required']"
hint="The OpenID Connect Issuer URL provided by the SSO provider. This is typically the base URL where the provider hosts their OIDC configuration."
/>
</q-card-section>
<!-- client id -->
<q-card-section>
<q-input
label="Client ID"
outlined
dense
v-model="localProvider.client_id"
:rules="[(val) => !!val || '*Required']"
/>
</q-card-section>
<!-- secret -->
<q-card-section>
<q-input
v-model="localProvider.secret"
filled
:type="hideSecret ? 'password' : 'text'"
label="Secret"
outlined
dense
:rules="[(val) => !!val || '*Required']"
>
<template v-slot:append>
<q-icon
:name="hideSecret ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="hideSecret = !hideSecret"
/>
</template>
</q-input>
</q-card-section>
<q-card-section>
<tactical-dropdown
label="Default User Role"
:options="roleOptions"
outlined
dense
clearable
mapOptions
filled
v-model="localProvider.role"
hint="The role assigned to users upon first sign-in through this provider."
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn
flat
label="Submit"
color="primary"
:loading="loading"
@click="submit"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { ref, reactive } from "vue";
import { useDialogPluginComponent, extend } from "quasar";
import { editSSOProvider, addSSOProvider } from "@/ee/sso/api/sso";
import { notifySuccess } from "@/utils/notify";
import { useRoleDropdown } from "@/composables/accounts";
// components
import TacticalDropdown from "@/components/ui/TacticalDropdown.vue";
// types
import type { SSOProvider } from "@/ee/sso/types/sso";
// define emits
defineEmits([...useDialogPluginComponent.emits]);
// define props
const props = defineProps<{ provider?: SSOProvider }>();
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
const loading = ref(false);
const { roleOptions } = useRoleDropdown({ onMount: true });
const hideSecret = ref(true);
const localProvider: SSOProvider = props.provider
? reactive(extend({}, props.provider))
: reactive({
id: 0,
name: "",
client_id: "",
secret: "",
server_url: "",
role: null,
} as SSOProvider);
async function submit() {
loading.value = true;
try {
props.provider
? await editSSOProvider(localProvider.id, localProvider)
: await addSSOProvider(localProvider);
onDialogOK();
notifySuccess("SSO Provider was edited!");
} catch (e) {}
loading.value = false;
}
</script>

View File

@@ -0,0 +1,293 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<div>
<div class="row">
<div class="text-subtitle2">SSO Providers</div>
<q-space />
<q-btn
size="sm"
color="grey-5"
icon="fas fa-plus"
text-color="black"
label="Add OIDC Provider"
@click="addSSOProvider"
:disable="!ssoSettings.sso_enabled"
>
<q-tooltip v-if="!ssoSettings.sso_enabled" class="text-caption"
>Enable SSO in the settings to allow adding a provider.</q-tooltip
>
</q-btn>
</div>
<q-separator />
<q-table
dense
:rows="providers"
:columns="columns"
:visible-columns="visibleColumns"
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: true }"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
:rows-per-page-options="[0]"
no-data-label="No OIDC Providers added yet"
:loading="loading"
>
<template v-slot:top>
<q-btn
@click="openSSOSettings"
label="SSO Settings"
no-caps
color="primary"
size="md"
/>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="editSSOProvider(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="editSSOProvider(props.row)"
>
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteSSOProvider(props.row)"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator></q-separator>
<!-- callback url -->
<q-item
clickable
v-close-popup
@click="getCallbackURL(props.row.callback_url)"
>
<q-item-section side>
<q-icon name="description" />
</q-item-section>
<q-item-section>Copy Callback URL</q-item-section>
</q-item>
<!-- javascript origin url (used by google oauth) -->
<q-item
clickable
v-close-popup
@click="getCallbackURL(props.row.javascript_origin_url)"
>
<q-item-section side>
<q-icon name="description" />
</q-item-section>
<q-item-section
>Copy Authorized JavaScript origin</q-item-section
>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- name -->
<q-td>
{{ truncateText(props.row.name, 25) }}
<q-tooltip>{{ props.row.name }}</q-tooltip>
</q-td>
<!-- server_url -->
<q-td>
{{ truncateText(props.row.server_url, 20) }}
<q-tooltip>{{ props.row.server_url }}</q-tooltip>
</q-td>
<!-- pattern -->
<q-td>
{{ truncateText(props.row.client_id, 20) }}
<q-tooltip>{{ props.row.client_id }}</q-tooltip>
</q-td>
<q-td>
<q-icon
size="sm"
name="content_copy"
@click="getCallbackURL(props.row.callback_url)"
>
<q-tooltip>Copy Callback URL to Clipboard</q-tooltip>
</q-icon>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</template>
<script setup lang="ts">
// composition imports
import { computed, ref, onMounted } from "vue";
import { useStore } from "vuex";
import { QTableColumn, useQuasar, copyToClipboard } from "quasar";
import {
fetchSSOProviders,
removeSSOProvider,
fetchSSOSettings,
} from "@/ee/sso/api/sso";
import { notifySuccess } from "@/utils/notify";
import { truncateText } from "@/utils/format";
// ui imports
import SSOProvidersForm from "@/ee/sso/components/SSOProvidersForm.vue";
// types
import { type SSOProvider, SSOSettingsType } from "@/ee/sso/types/sso";
import SSOSettings from "@/ee/sso/components/SSOSettings.vue";
// setup quasar
const $q = useQuasar();
// setup vuew store
const store = useStore();
const loading = ref(false);
const providers = ref([] as SSOProvider[]);
const ssoSettings = ref({} as SSOSettingsType);
const columns: QTableColumn[] = [
{
name: "name",
label: "Name",
field: "name",
align: "left",
sortable: true,
},
{
name: "server_url",
label: "Server Url",
field: "server_url",
align: "left",
sortable: true,
},
{
name: "client_id",
label: "Client ID",
field: "client_id",
align: "left",
sortable: true,
},
{
name: "callback_url",
label: "Callback URL",
field: "callback_url",
align: "left",
sortable: false,
},
{
name: "javascript_origin_url",
label: "Javascript Origin URL",
field: "javascript_origin_url",
align: "left",
sortable: false,
},
];
const visibleColumns = computed(() => {
return columns
.map((column) => column.name)
.filter((name) => name !== "javascript_origin_url");
});
async function getSSOSettings() {
try {
ssoSettings.value = await fetchSSOSettings();
} catch (e) {
console.error(e);
}
}
async function getSSOProviders() {
loading.value = true;
try {
providers.value = await fetchSSOProviders();
} catch (e) {
console.error(e);
}
loading.value = false;
}
function addSSOProvider() {
$q.dialog({
component: SSOProvidersForm,
}).onOk(getSSOProviders);
}
function editSSOProvider(provider: SSOProvider) {
$q.dialog({
component: SSOProvidersForm,
componentProps: {
provider: provider,
},
}).onOk(getSSOProviders);
}
function deleteSSOProvider(provider: SSOProvider) {
$q.dialog({
title: `Delete SSO Provider: ${provider.name}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await removeSSOProvider(provider.id);
await getSSOProviders();
notifySuccess(`SSO Provider: ${provider.name} was deleted!`);
} catch (e) {
console.error(e);
}
loading.value = false;
});
}
function getCallbackURL(url: string) {
copyToClipboard(url).then(() => {
notifySuccess("URL copied!");
});
}
function openSSOSettings() {
$q.dialog({
component: SSOSettings,
}).onOk((updatedSSOSettings: SSOSettingsType) => {
store.commit(
"setBlockLocalUserLogon",
updatedSSOSettings.block_local_user_logon,
);
ssoSettings.value = { ...updatedSSOSettings };
});
}
onMounted(async () => {
await getSSOSettings();
await getSSOProviders();
});
</script>

View File

@@ -0,0 +1,112 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card class="q-dialog-plugin" style="width: 50">
<q-bar>
SSO Settings
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<!-- disable sso-->
<q-card-section>
<q-checkbox
dense
label="Enable SSO"
v-model="ssoSettings.sso_enabled"
/>
</q-card-section>
<!-- block local user logon -->
<q-card-section>
<q-checkbox
dense
label="Block Local User Login"
v-model="ssoSettings.block_local_user_logon"
:disable="!ssoSettings.sso_enabled"
hint="When enabled, only users with SSO accounts can log in, with the exception of local superuser accounts."
>
<q-tooltip class="text-caption"
>When enabled, only users with SSO accounts can log in, with the
exception of local superuser accounts.</q-tooltip
>
</q-checkbox>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn
flat
label="Submit"
color="primary"
:loading="loading"
@click="submit"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { ref, watch, onMounted } from "vue";
import { useDialogPluginComponent } from "quasar";
import { notifySuccess, notifyWarning } from "@/utils/notify";
import { fetchSSOSettings, updateSSOSettings } from "@/ee/sso/api/sso";
// types
import { SSOSettingsType } from "../types/sso";
// define emits
defineEmits([...useDialogPluginComponent.emits]);
const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
const ssoSettings = ref({} as SSOSettingsType);
const loading = ref(false);
async function getSSOSettings() {
loading.value = true;
try {
ssoSettings.value = await fetchSSOSettings();
} catch (e) {
console.error(e);
}
loading.value = false;
}
async function submit() {
loading.value = true;
try {
await updateSSOSettings(ssoSettings.value);
notifySuccess("Settings updated successfully");
onDialogOK(ssoSettings.value);
} catch (e) {
if (e.status === 423) {
notifyWarning(e.response.data, 7000);
}
console.error(e);
}
loading.value = false;
}
onMounted(async () => {
await getSSOSettings();
// watcher to disable block local login if sso is disabled
watch(
() => ssoSettings.value.sso_enabled,
(newValue) => {
if (!newValue) {
ssoSettings.value.block_local_user_logon = false;
}
},
);
});
</script>

33
src/ee/sso/types/sso.ts Normal file
View File

@@ -0,0 +1,33 @@
/*
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
*/
import { User } from "@/types/accounts";
export interface SSOProvider {
id: number;
name: string;
provider_id: string;
client_id: string;
secret: string;
server_url: string;
role: number | null;
}
export interface SSOAccount {
uid: string;
display: string;
provider: string;
last_login: string;
date_joined: string;
}
export interface SSOUser extends User {
social_accounts: SSOAccount[];
}
export interface SSOSettingsType {
sso_enabled: boolean;
block_local_user_logon: boolean;
}

View File

@@ -0,0 +1,21 @@
/*
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
*/
export function getCookie(name: string) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

View File

@@ -0,0 +1,32 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<div class="fixed-center text-center" v-if="error">
<p class="text-faded">There was an error logging into your provider.</p>
<q-btn color="secondary" style="width: 200px" to="/login"
>Go back to Login</q-btn
>
</div>
</template>
<script lang="ts" setup>
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
const error = route.query.error;
const router = useRouter();
const auth = useAuthStore();
if (!error) {
if (auth.loggedIn) {
router.push({ name: "Dashboard" });
} else {
router.push({ name: "Login" });
}
}
</script>

View File

@@ -106,7 +106,7 @@
<q-item-label header>Servers</q-item-label> <q-item-label header>Servers</q-item-label>
<q-item> <q-item>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="fa fa-server" size="sm" color="primary" /> <q-icon name="dns" size="sm" color="primary" />
</q-item-section> </q-item-section>
<q-item-section no-wrap> <q-item-section no-wrap>
@@ -157,7 +157,7 @@
<AlertsIcon /> <AlertsIcon />
<q-btn-dropdown flat no-caps stretch :label="username || ''"> <q-btn-dropdown flat no-caps stretch :label="displayName || ''">
<q-list> <q-list>
<q-item <q-item
clickable clickable
@@ -211,7 +211,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// composition imports // composition imports
import { computed, onMounted } from "vue"; import { computed, onMounted, onBeforeUnmount, ref } from "vue";
import { useQuasar } from "quasar"; import { useQuasar } from "quasar";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { useDashboardStore } from "@/stores/dashboard"; import { useDashboardStore } from "@/stores/dashboard";
@@ -240,7 +240,7 @@ const {
daysUntilCertExpires, daysUntilCertExpires,
} = storeToRefs(useDashboardStore()); } = storeToRefs(useDashboardStore());
const { username } = storeToRefs(useAuthStore()); const { displayName } = storeToRefs(useAuthStore());
const darkMode = computed({ const darkMode = computed({
get: () => { get: () => {
@@ -315,8 +315,25 @@ const updateAvailable = computed(() => {
return currentTRMMVersion.value !== latestTRMMVersion.value; return currentTRMMVersion.value !== latestTRMMVersion.value;
}); });
const poll = ref(null);
function livePoll() {
poll.value = setInterval(
() => {
store.dispatch("checkVer");
store.dispatch("getDashInfo", false);
},
60 * 4 * 1000,
);
}
onMounted(() => { onMounted(() => {
store.dispatch("getDashInfo"); store.dispatch("getDashInfo");
store.dispatch("checkVer"); store.dispatch("checkVer");
livePoll();
});
onBeforeUnmount(() => {
clearInterval(poll.value);
}); });
</script> </script>

View File

@@ -43,6 +43,8 @@ export default function () {
}, },
server_scripts_enabled: true, server_scripts_enabled: true,
web_terminal_enabled: true, web_terminal_enabled: true,
sso_enabled: false,
block_local_user_logon: false,
}; };
}, },
getters: { getters: {
@@ -159,6 +161,12 @@ export default function () {
setWebTerminalEnabled(state, obj) { setWebTerminalEnabled(state, obj) {
state.web_terminal_enabled = obj; state.web_terminal_enabled = obj;
}, },
setSSOEnabled(state, obj) {
state.sso_enabled = obj;
},
setBlockLocalUserLogon(state, obj) {
state.block_local_user_logon = obj;
},
}, },
actions: { actions: {
setClientTreeSplitter(context, val) { setClientTreeSplitter(context, val) {
@@ -245,6 +253,7 @@ export default function () {
commit("setRunCmdPlaceholders", data.run_cmd_placeholder_text); commit("setRunCmdPlaceholders", data.run_cmd_placeholder_text);
commit("setServerScriptsEnabled", data.server_scripts_enabled); commit("setServerScriptsEnabled", data.server_scripts_enabled);
commit("setWebTerminalEnabled", data.web_terminal_enabled); commit("setWebTerminalEnabled", data.web_terminal_enabled);
commit("setBlockLocalUserLogon", data.block_local_user_logon);
if (data?.date_format !== "") commit("setDateFormat", data.date_format); if (data?.date_format !== "") commit("setDateFormat", data.date_format);
else commit("setDateFormat", data.default_date_format); else commit("setDateFormat", data.default_date_format);

View File

@@ -1,5 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
import axios from "axios"; import axios from "axios";
interface CheckCredentialsRequest { interface CheckCredentialsRequest {
@@ -27,12 +28,18 @@ interface TOTPSetupResponse {
export const useAuthStore = defineStore("auth", { export const useAuthStore = defineStore("auth", {
state: () => ({ state: () => ({
username: useStorage("user_name", null), username: useStorage("user_name", null),
name: useStorage("name", null),
token: useStorage("access_token", null), token: useStorage("access_token", null),
ssoLoginProvider: useStorage("sso_provider", null),
provider_id: useStorage("provider_id", null),
}), }),
getters: { getters: {
loggedIn: (state) => { loggedIn: (state) => {
return state.token !== null; return state.token !== null;
}, },
displayName: (state) => {
return state.name ? state.name : state.username;
},
}, },
actions: { actions: {
async checkCredentials( async checkCredentials(
@@ -43,13 +50,16 @@ export const useAuthStore = defineStore("auth", {
if (!data.totp) { if (!data.totp) {
this.token = data.token; this.token = data.token;
this.username = data.username; this.username = data.username;
this.name = data.name;
} }
return data; return data;
}, },
async login(credentials: LoginRequest) { async login(credentials: LoginRequest) {
const { data } = await axios.post("/v2/login/", credentials); const { data } = await axios.post("/v2/login/", credentials);
this.username = data.username; this.username = data.username;
this.name = data.name;
this.token = data.token; this.token = data.token;
this.ssoLoginProvider = null;
return data; return data;
}, },
@@ -61,6 +71,9 @@ export const useAuthStore = defineStore("auth", {
} }
this.token = null; this.token = null;
this.username = null; this.username = null;
this.name = null;
this.ssoLoginProvider = null;
this.provider_id = null;
}, },
async setupTotp(): Promise<TOTPSetupResponse | false> { async setupTotp(): Promise<TOTPSetupResponse | false> {
const { data } = await axios.post("/accounts/users/setup_totp/"); const { data } = await axios.post("/accounts/users/setup_totp/");

View File

@@ -1,4 +1,13 @@
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
name: string;
email: string;
}
export interface AuthToken {
digest: string;
created: string;
expiry: string;
user: string;
} }

View File

@@ -0,0 +1,3 @@
export interface CoreSetting {
block_local_user_logon: boolean;
}

8
src/utils/helpers.ts Normal file
View File

@@ -0,0 +1,8 @@
import { copyToClipboard } from "quasar";
import { notifySuccess } from "@/utils/notify";
export function copyOutput(val: string) {
copyToClipboard(val).then(() => {
notifySuccess("Copied to clipboard");
});
}

View File

@@ -509,6 +509,13 @@ export default {
sortable: true, sortable: true,
align: "left", align: "left",
}, },
{
name: "mon-type",
label: "",
field: "monitoring_type",
sortable: true,
align: "left",
},
{ {
name: "checks-status", name: "checks-status",
align: "left", align: "left",
@@ -600,6 +607,7 @@ export default {
visibleColumns: [ visibleColumns: [
"smsalert", "smsalert",
"plat", "plat",
"mon-type",
"emailalert", "emailalert",
"dashboardalert", "dashboardalert",
"checks-status", "checks-status",
@@ -818,15 +826,14 @@ export default {
}, },
getURLActions() { getURLActions() {
this.$axios.get("/core/urlaction/").then((r) => { this.$axios.get("/core/urlaction/").then((r) => {
if (r.data.length === 0) { this.urlActions = r.data
.filter((action) => action.action_type === "web")
.sort((a, b) => a.name.localeCompare(b.name));
if (this.urlActions.length === 0) {
this.notifyWarning( this.notifyWarning(
"No URL Actions configured. Go to Settings > Global Settings > URL Actions", "No URL Actions configured. Go to Settings > Global Settings > URL Actions",
); );
return;
} }
this.urlActions = r.data.filter(
(action) => action.action_type === "web",
);
}); });
}, },
runURLAction(id, action, model) { runURLAction(id, action, model) {

View File

@@ -49,7 +49,34 @@
</div> </div>
</q-form> </q-form>
</q-card-section> </q-card-section>
<q-card-section v-if="ssoProviders?.length > 0">
<div class="text-h6 text-center q-mb-md">Log in with SSO</div>
<q-separator />
<q-list dense bordered class="q-pa-sm">
<q-item
v-for="provider in ssoProviders"
:key="provider.id"
@click="openSSOProviderRedirect(provider.id)"
clickable
class="q-pa-xs hover-bg"
>
<q-item-section avatar>
<q-icon
:name="provider.icon ?? 'mdi-key'"
size="sm"
class="text-primary"
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ provider.name }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card> </q-card>
<!-- 2 factor modal --> <!-- 2 factor modal -->
<q-dialog persistent v-model="prompt"> <q-dialog persistent v-model="prompt">
<q-card style="min-width: 400px"> <q-card style="min-width: 400px">
@@ -84,10 +111,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue"; import { ref, reactive, onMounted } from "vue";
import { type QForm, useQuasar } from "quasar"; import { type QForm, useQuasar } from "quasar";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import {
openSSOProviderRedirect,
getSSOConfig,
type SSOProviderConfig,
} from "@/ee/sso/api/sso";
// setup quasar // setup quasar
const $q = useQuasar(); const $q = useQuasar();
@@ -107,6 +139,7 @@ const credentials = reactive({ username: "", password: "" });
const twofactor = ref(""); const twofactor = ref("");
const prompt = ref(false); const prompt = ref(false);
const showPassword = ref(true); const showPassword = ref(true);
const ssoProviders = ref([] as SSOProviderConfig[]);
async function checkCreds() { async function checkCreds() {
try { try {
@@ -135,6 +168,15 @@ async function onSubmit() {
prompt.value = false; prompt.value = false;
} }
} }
onMounted(async () => {
try {
const result = await getSSOConfig();
ssoProviders.value = result.data.socialaccount.providers;
} catch (e) {
console.error(e);
}
});
</script> </script>
<style> <style>

View File

@@ -34,6 +34,7 @@
<q-tab-panels v-model="tab"> <q-tab-panels v-model="tab">
<q-tab-panel name="terminal" class="q-pa-none"> <q-tab-panel name="terminal" class="q-pa-none">
<iframe <iframe
allow="clipboard-read; clipboard-write"
:src="terminal" :src="terminal"
:style="{ :style="{
height: `${$q.screen.height - 30}px`, height: `${$q.screen.height - 30}px`,
@@ -66,6 +67,7 @@
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="filebrowser" class="q-pa-none"> <q-tab-panel name="filebrowser" class="q-pa-none">
<iframe <iframe
allow="clipboard-read; clipboard-write"
:src="file" :src="file"
:style="{ :style="{
height: `${$q.screen.height - 30}px`, height: `${$q.screen.height - 30}px`,

View File

@@ -23,12 +23,15 @@
/> />
<q-space /> <q-space />
</q-bar> </q-bar>
<div class="q-video" :style="{ height: `${$q.screen.height - 26}px` }">
<q-video <iframe
v-show="control" v-show="control"
:src="control" :src="control"
:style="{ height: `${$q.screen.height - 26}px` }" allow="clipboard-read; clipboard-write"
></q-video> allowfullscreen
frameborder="0"
></iframe>
</div>
</div> </div>
</template> </template>