Merge pull request #56 from sadnub/feature-policies-alerts

User Management addition
This commit is contained in:
wh1te909 2020-08-20 21:22:17 -07:00 committed by GitHub
commit 098960009d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 987 additions and 62 deletions

View File

@ -0,0 +1,38 @@
import pyotp
from rest_framework.serializers import (
ModelSerializer,
SerializerMethodField,
)
from .models import User
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = (
"id",
"username",
"first_name",
"last_name",
"email",
"is_active",
"last_login",
)
class TOTPSetupSerializer(ModelSerializer):
qr_url = SerializerMethodField()
class Meta:
model = User
fields = (
"username",
"totp_key",
"qr_url",
)
def get_qr_url(self, obj):
return pyotp.totp.TOTP(obj.totp_key).provisioning_uri(obj.username, issuer_name="Tactical RMM")

View File

@ -0,0 +1,10 @@
from django.urls import path
from . import views
urlpatterns = [
path("users/", views.GetAddUsers.as_view()),
path("<int:pk>/users/", views.GetUpdateDeleteUser.as_view()),
path("users/reset/", views.UserActions.as_view()),
path("users/reset_totp/", views.UserActions.as_view()),
path("users/setup_totp/", views.TOTPSetup.as_view()),
]

View File

@ -2,7 +2,10 @@ import pyotp
from django.contrib.auth import login
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.db import IntegrityError
from rest_framework.views import APIView
from rest_framework.authtoken.serializers import AuthTokenSerializer
from knox.views import LoginView as KnoxLoginView
from rest_framework.permissions import AllowAny
@ -10,6 +13,10 @@ from rest_framework.response import Response
from rest_framework import status
from .models import User
from agents.models import Agent
from tacticalrmm.utils import notify_error
from .serializers import UserSerializer, TOTPSetupSerializer
class CheckCreds(KnoxLoginView):
@ -19,6 +26,11 @@ class CheckCreds(KnoxLoginView):
def post(self, request, format=None):
serializer = AuthTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data["user"]
if not user.totp_key:
return Response("totp not set")
return Response("ok")
@ -46,3 +58,94 @@ class LoginView(KnoxLoginView):
return super(LoginView, self).post(request, format=None)
else:
return Response("bad credentials", status=status.HTTP_400_BAD_REQUEST)
class GetAddUsers(APIView):
def get(self, request):
agents = Agent.objects.values_list("agent_id", flat=True)
users = User.objects.exclude(username__in=agents)
return Response(UserSerializer(users, many=True).data)
def post(self, request):
# add new user
try:
user = User.objects.create_user(
request.data["username"],
request.data["email"],
request.data["password"],
)
except IntegrityError:
return notify_error(
f"ERROR: User {request.data['username']} already exists!"
)
user.first_name = request.data["first_name"]
user.last_name = request.data["last_name"]
# Can be changed once permissions and groups are introduced
user.is_superuser = True
user.save()
return Response(user.username)
class GetUpdateDeleteUser(APIView):
def get(self, request, pk):
user = get_object_or_404(User, pk=pk)
return Response(UserSerializer(user).data)
def put(self, request, pk):
user = get_object_or_404(User, pk=pk)
serializer = UserSerializer(instance=user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response("ok")
def delete(self, request, pk):
get_object_or_404(User, pk=pk).delete()
return Response("ok")
class UserActions(APIView):
# reset password
def post(self, request):
user = get_object_or_404(User, pk=request.data["id"])
user.set_password(request.data["password"])
user.save()
return Response("ok")
# reset two factor token
def put(self, request):
user = get_object_or_404(User, pk=request.data["id"])
user.totp_key = ""
user.save()
return Response(
f"{user.username}'s Two-Factor key was reset. Have them sign in again to setup"
)
class TOTPSetup(APIView):
permission_classes = (AllowAny,)
# totp setup
def post(self, request):
user = get_object_or_404(User, username=request.data["username"])
if not user.totp_key:
code = pyotp.random_base32()
user.totp_key = code
user.save(update_fields=["totp_key"])
return Response(TOTPSetupSerializer(user).data)
return Response("TOTP token already set")

View File

@ -1,3 +1,6 @@
from django.contrib import admin
# Register your models here.
from .models import Alert
admin.site.register(Alert)

View File

@ -1,6 +1,7 @@
from rest_framework.serializers import (
ModelSerializer,
ReadOnlyField,
DateTimeField,
)
from .models import Alert
@ -11,6 +12,7 @@ class AlertSerializer(ModelSerializer):
hostname = ReadOnlyField(source="agent.hostname")
client = ReadOnlyField(source="agent.client")
site = ReadOnlyField(source="agent.site")
alert_time = DateTimeField(format="iso-8601")
class Meta:
model = Alert

View File

@ -11,7 +11,7 @@ AUTH_USER_MODEL = "accounts.User"
# bump this version everytime vue code is changed
# to alert user they need to manually refresh their browser
APP_VER = "0.0.23"
APP_VER = "0.0.24"
# https://github.com/wh1te909/salt
LATEST_SALT_VER = "1.0.3"

View File

@ -23,4 +23,5 @@ urlpatterns = [
path("logs/", include("logs.urls")),
path("scripts/", include("scripts.urls")),
path("alerts/", include("alerts.urls")),
path("accounts/", include("accounts.urls"))
]

5
web/package-lock.json generated
View File

@ -15528,6 +15528,11 @@
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=",
"dev": true
},
"qrcode.vue": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-1.7.0.tgz",
"integrity": "sha512-R7t6Y3fDDtcU7L4rtqwGUDP9xD64gJhIwpfjhRCTKmBoYF6SS49PIJHRJ048cse6OI7iwTwgyy2C46N9Ygoc6g=="
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",

View File

@ -12,6 +12,7 @@
"@quasar/extras": "^1.9.0",
"axios": "^0.19.2",
"dotenv": "^8.2.0",
"qrcode.vue": "^1.7.0",
"quasar": "^1.12.13"
},
"devDependencies": {

View File

@ -0,0 +1,322 @@
<template>
<div style="width: 900px; max-width: 90vw;">
<q-card>
<q-bar>
<q-btn ref="refresh" @click="refresh" class="q-mr-sm" dense flat push icon="refresh" />User Administration
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip content-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"
/>
<q-btn
ref="edit"
label="Edit"
:disable="selected.length === 0"
dense
flat
push
unelevated
no-caps
icon="edit"
@click="showEditUserModal(selected[0])"
/>
<q-btn
ref="delete"
label="Delete"
:disable="selected.length === 0"
dense
flat
push
unelevated
no-caps
icon="delete"
@click="deleteUser(selected[0])"
/>
</div>
<q-table
dense
:data="users"
:columns="columns"
:pagination.sync="pagination"
:selected.sync="selected"
selection="single"
row-key="id"
binary-state-sort
hide-pagination
:hide-bottom="!!selected"
>
<!-- header slots -->
<template v-slot:header="props">
<q-tr :props="props">
<template v-for="col in props.cols">
<q-th v-if="col.name === 'active'" auto-width :key="col.name">
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enable User</q-tooltip>
</q-icon>
</q-th>
<q-th v-else :key="col.name" :props="props">{{ col.label }}</q-th>
</template>
</q-tr>
</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"
:class="{highlight: selected.length !== 0 && selected[0].id === props.row.id}"
@click="editUserId = props.row.id; props.selected = true"
@contextmenu="editUserId = props.row.id; props.selected = true"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="showEditUserModal(selected[0])"
id="context-edit"
>
<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)"
id="context-delete"
>
<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 @input="toggleEnabled(props.row)" v-model="props.row.is_active" />
</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">{{ props.row.last_login }}</q-td>
<q-td v-else>Never</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-card>
<!-- user form modal -->
<q-dialog v-model="showUserFormModal" @hide="closeUserFormModal">
<UserForm :pk="editUserId" @close="closeUserFormModal" />
</q-dialog>
<!-- user reset password form modal -->
<q-dialog v-model="showResetPasswordModal" @hide="closeResetPasswordModal">
<UserResetPasswordForm
:pk="resetUserId"
:username="resetUserName"
@close="closeResetPasswordModal"
/>
</q-dialog>
</div>
</template>
<script>
import mixins, { notifySuccessConfig, notifyErrorConfig } from "@/mixins/mixins";
import { mapState } from "vuex";
import UserForm from "@/components/modals/admin/UserForm";
import UserResetPasswordForm from "@/components/modals/admin/UserResetPasswordForm";
export default {
name: "AdminManager",
components: { UserForm, UserResetPasswordForm },
mixins: [mixins],
data() {
return {
showUserFormModal: false,
showResetPasswordModal: false,
editUserId: null,
resetUserId: null,
resetUserName: null,
selected: [],
columns: [
{ name: "is_active", label: "Active", field: "is_active", align: "left" },
{ name: "username", label: "Username", field: "username", align: "left" },
{
name: "name",
label: "Name",
field: "name",
align: "left",
},
{
name: "email",
label: "Email",
field: "email",
align: "left",
},
{
name: "last_login",
label: "Last Login",
field: "last_login",
align: "left",
},
],
pagination: {
rowsPerPage: 9999,
},
};
},
methods: {
getUsers() {
this.$store.dispatch("admin/loadUsers");
},
clearRow() {
this.selected = [];
},
refresh() {
this.getUsers();
this.clearRow();
},
deleteUser(data) {
this.$q
.dialog({
title: `Delete user ${data.username}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
})
.onOk(() => {
this.$store
.dispatch("admin/deleteUser", data.id)
.then(response => {
this.$q.notify(notifySuccessConfig(`User ${data.username} was deleted!`));
})
.catch(error => {
this.$q.notify(notifyErrorConfig(`An Error occured while deleting user ${data.username}`));
});
});
},
showEditUserModal(data) {
this.editUserId = data.id;
this.showUserFormModal = true;
},
closeUserFormModal() {
this.showUserFormModal = false;
this.editUserId = null;
this.refresh();
},
showAddUserModal() {
this.editUserId = null;
this.selected = [];
this.showUserFormModal = true;
},
toggleEnabled(user) {
let text = user.is_active ? "User enabled successfully" : "User disabled successfully";
const data = {
id: user.id,
is_active: user.is_active,
};
this.$store
.dispatch("admin/editUser", data)
.then(response => {
this.$q.notify(notifySuccessConfig(text));
})
.catch(error => {
this.$q.notify(notifyErrorConfig("An Error occured while editing user"));
});
},
ResetPassword(user) {
this.resetUserId = user.id;
this.resetUserName = user.username;
this.showResetPasswordModal = true;
},
closeResetPasswordModal(user) {
this.resetUserId = null;
this.resetUserName = null;
this.showResetPasswordModal = false;
},
reset2FA(user) {
const data = {
id: user.id,
};
this.$q
.dialog({
title: `Reset 2FA for ${user.username}?`,
cancel: true,
ok: { label: "Reset", color: "positive" },
})
.onOk(() => {
this.$store
.dispatch("admin/resetUserTOTP", data)
.then(response => {
this.$q.notify(notifySuccessConfig(response.data, 4000));
})
.catch(error => {
this.$q.notify(notifyErrorConfig("An Error occured while resetting key"));
});
});
},
},
computed: {
...mapState({
users: state => state.admin.users,
}),
},
mounted() {
this.refresh();
},
};
</script>

View File

@ -3,21 +3,33 @@
<q-badge v-if="alerts.length !== 0" color="red" floating transparent>{{ alertsLengthText() }}</q-badge>
<q-menu>
<q-list separator>
<q-item v-if="alerts.length === 0">No Alerts</q-item>
<q-item v-if="alerts.length === 0">No New Alerts</q-item>
<q-item v-for="alert in alerts" :key="alert.id">
<q-item-section>
<q-item-label>{{ alert.client }} - {{ alert.hostname }}</q-item-label>
<q-item-label caption>
<q-icon :class="`text-${alertColor(alert.severity)}`" :name="alert.severity"></q-icon>
<q-item-label overline>{{ alert.client }} - {{ alert.site }} - {{ alert.hostname }}</q-item-label>
<q-item-label>
<q-icon size="xs" :class="`text-${alertColor(alert.severity)}`" :name="alert.severity"></q-icon>
{{ alert.message }}
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{ alert.alert_time }}</q-item-label>
<q-item-label caption>{{ alertTime(alert.alert_time) }}</q-item-label>
<q-item-label>
<q-icon name="snooze" size="xs">
<q-tooltip>
Snooze the alert for 24 hours
</q-tooltip>
</q-icon>
<q-icon name="alarm_off" size="xs">
<q-tooltip>
Dismiss alert
</q-tooltip>
</q-icon>
</q-item-label>
</q-item-section>
</q-item>
<q-item clickable @click="showAlertsModal = true">View All Alerts</q-item>
<q-item clickable @click="showAlertsModal = true">View All Alerts ({{ alerts.length }})</q-item>
</q-list>
</q-menu>
@ -72,7 +84,8 @@ export default {
},
computed: {
...mapGetters({
alerts: "alerts/getNewAlerts"
newAlerts: "alerts/getNewAlerts",
alerts: "alerts/getAlerts"
})
},
mounted() {

View File

@ -69,6 +69,10 @@
<q-item clickable v-close-popup @click="showAutomationManager = true">
<q-item-section>Automation Manager</q-item-section>
</q-item>
<!-- admin manager -->
<q-item clickable v-close-popup @click="showAdminManager = true">
<q-item-section>User Administration</q-item-section>
</q-item>
<!-- core settings -->
<q-item clickable v-close-popup @click="showEditCoreSettingsModal = true">
<q-item-section>Global Settings</q-item-section>
@ -124,6 +128,12 @@
<AutomationManager @close="showAutomationManager = false" />
</q-dialog>
</div>
<!-- Admin Manager -->
<div class="q-pa-md q-gutter-sm">
<q-dialog v-model="showAdminManager">
<AdminManager @close="showAdminManager = false" />
</q-dialog>
</div>
<!-- Upload new mesh agent -->
<q-dialog v-model="showUploadMesh">
<UploadMesh @close="showUploadMesh = false" />
@ -142,6 +152,7 @@ import UpdateAgents from "@/components/modals/agents/UpdateAgents";
import ScriptManager from "@/components/ScriptManager";
import EditCoreSettings from "@/components/modals/coresettings/EditCoreSettings";
import AutomationManager from "@/components/automation/AutomationManager";
import AdminManager from "@/components/AdminManager";
import InstallAgent from "@/components/modals/agents/InstallAgent";
import UploadMesh from "@/components/modals/core/UploadMesh";
@ -158,7 +169,8 @@ export default {
EditCoreSettings,
AutomationManager,
InstallAgent,
UploadMesh
UploadMesh,
AdminManager,
},
props: ["clients"],
data() {
@ -170,8 +182,9 @@ export default {
showUpdateAgentsModal: false,
showEditCoreSettingsModal: false,
showAutomationManager: false,
showAdminManager: false,
showInstallAgent: false,
showUploadMesh: false
showUploadMesh: false,
};
},
methods: {
@ -187,7 +200,7 @@ export default {
},
edited() {
this.$emit("edited");
}
}
},
},
};
</script>

View File

@ -0,0 +1,163 @@
<template>
<q-card style="width: 60vw">
<q-form ref="form" @submit="onSubmit">
<q-card-section class="row items-center">
<div class="text-h6">{{ title }}</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="row">
<div class="col-2">Username:</div>
<div class="col-10">
<q-input
outlined
dense
v-model="username"
:rules="[ val => !!val || '*Required']"
class="q-pa-none"
/>
</div>
</q-card-section>
<q-card-section class="row" v-if="!this.pk">
<div class="col-2">Password:</div>
<div class="col-10">
<q-input
outlined
dense
v-model="password"
:type="isPwd ? 'password' : 'text'"
:rules="[ val => !!val || '*Required']"
class="q-pa-none"
>
<template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
</div>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Email:</div>
<div class="col-10">
<q-input
outlined
dense
v-model="email"
:rules="[val => isValidEmail(val) || 'Invalid email']"
class="q-pa-none"
/>
</div>
</q-card-section>
<q-card-section class="row">
<div class="col-2">First Name:</div>
<div class="col-10">
<q-input outlined dense v-model="first_name" />
</div>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Last Name:</div>
<div class="col-10">
<q-input outlined dense v-model="last_name" />
</div>
</q-card-section>
<q-card-section class="row">
<div class="col-2">Active:</div>
<div class="col-10">
<q-toggle v-model="is_active" color="green" />
</div>
</q-card-section>
<q-card-section class="row items-center">
<q-btn :label="title" color="primary" type="submit" />
</q-card-section>
</q-form>
</q-card>
</template>
<script>
import mixins, { notifySuccessConfig, notifyErrorConfig } from "@/mixins/mixins";
export default {
name: "UserForm",
mixins: [mixins],
props: { pk: Number },
data() {
return {
username: "",
password: "",
email: "",
first_name: "",
last_name: "",
is_active: true,
isPwd: true,
};
},
computed: {
title() {
return this.pk ? "Edit User" : "Add User";
},
},
methods: {
getUser() {
this.$q.loading.show();
this.$store.dispatch("admin/loadUser", this.pk).then(r => {
this.$q.loading.hide();
this.username = r.data.username;
this.email = r.data.email;
this.is_active = r.data.is_active;
this.first_name = r.data.first_name;
this.last_name = r.data.last_name;
});
},
onSubmit() {
this.$q.loading.show();
let formData = {
id: this.pk,
username: this.username,
email: this.email,
is_active: this.is_active,
first_name: this.first_name,
last_name: this.last_name,
};
if (this.pk) {
this.$store
.dispatch("admin/editUser", formData)
.then(r => {
this.$q.loading.hide();
this.$emit("close");
this.$q.notify(notifySuccessConfig("User edited!"));
})
.catch(e => {
this.$q.loading.hide();
this.$q.notify(notifyErrorConfig(e.response.data));
});
} else {
formData.password = this.password;
this.$store
.dispatch("admin/addUser", formData)
.then(r => {
this.$q.loading.hide();
this.$emit("close");
this.$q.notify(notifySuccessConfig(`User ${r.data} was added!`));
})
.catch(e => {
this.$q.loading.hide();
this.$q.notify(notifyErrorConfig(e.response.data));
});
}
},
},
mounted() {
// If pk prop is set that means we are editting
if (this.pk) {
this.getUser();
}
},
};
</script>

View File

@ -0,0 +1,71 @@
<template>
<q-card style="width: 60vw">
<q-form ref="form" @submit="onSubmit">
<q-card-section class="row items-center">
<div class="text-h6">{{ username }} Password Reset</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="row">
<div class="col-2">New Password:</div>
<div class="col-10">
<q-input
outlined
dense
v-model="password"
:type="isPwd ? 'password' : 'text'"
:rules="[ val => !!val || '*Required']"
>
<template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
</div>
</q-card-section>
<q-card-section class="row items-center">
<q-btn label="Reset" color="primary" type="submit" />
</q-card-section>
</q-form>
</q-card>
</template>
<script>
import mixins, { notifySuccessConfig, notifyErrorConfig } from "@/mixins/mixins";
export default {
name: "UserResetForm",
mixins: [mixins],
props: { pk: Number, username: String },
data() {
return {
password: "",
isPwd: true,
};
},
methods: {
onSubmit() {
this.$q.loading.show();
let formData = {
id: this.pk,
password: this.password,
};
this.$store
.dispatch("admin/resetUserPassword", formData)
.then(r => {
this.$q.loading.hide();
this.$emit("close");
this.$q.notify(notifySuccessConfig("User Password Reset!"));
})
.catch(e => {
this.$q.loading.hide();
this.$q.notify(notifyErrorConfig(e.response.data));
});
},
},
};
</script>

View File

@ -7,25 +7,58 @@
<q-tooltip content-class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-separator />
<q-card-section>All Alerts</q-card-section>
<q-card-section>
<q-list separator>
<q-item v-for="alert in alerts" :key="alert.id">
<q-item-section>
<q-item-label>{{ alert.client }} - {{ alert.hostname }}</q-item-label>
<q-item-label caption>
<q-icon :class="`text-${alertColor(alert.severity)}`" :name="alert.severity"></q-icon>
{{ alert.message }}
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{ alert.timestamp }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-separator />
<q-card-section class="row">
<div class="col-3">
<q-input outlined dense v-model="search">
<template v-slot:append>
<q-icon v-if="search !== ''" name="close" @click="search = ''" class="cursor-pointer" />
<q-icon name="search" />
</template>
<template v-slot:hint>
Type in client, site, or agent name
</template>
</q-input>
</div>
<div class="col-3">
<q-checkbox outlined dense v-model="includeDismissed" label="Include dismissed alerts?"/>
</div>
</q-card-section>
<q-separator />
<q-list separator>
<q-item v-if="alerts.length === 0">No Alerts!</q-item>
<q-item v-for="alert in alerts" :key="alert.id">
<q-item-section>
<q-item-label overline>{{ alert.client }} - {{ alert.site }} - {{ alert.hostname }}</q-item-label>
<q-item-label>
<q-icon size="sm" :class="`text-${alertColor(alert.severity)}`" :name="alert.severity"></q-icon>
{{ alert.message }}
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{ alertTime(alert.alert_time) }}</q-item-label>
<q-item-label>
<q-icon name="snooze" size="sm">
<q-tooltip>
Snooze the alert for 24 hours
</q-tooltip>
</q-icon>
<q-icon name="alarm_off" size="sm">
<q-tooltip>
Dismiss alert
</q-tooltip>
</q-icon>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</template>
@ -38,7 +71,8 @@ export default {
mixins: [mixins],
data() {
return {
search: "",
includeDismissed: false
};
},
methods: {

View File

@ -240,10 +240,6 @@ export default {
const removed = this.settings.email_alert_recipients.filter(k => k !== email);
this.settings.email_alert_recipients = removed;
},
isValidEmail(val) {
const email = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return email.test(val);
},
editSettings() {
this.$q.loading.show();
axios

View File

@ -32,30 +32,38 @@ export function notifyInfoConfig(msg, timeout = 2000) {
}
};
function getTimeLapse(unixtime) {
var previous = unixtime * 1000;
var current = new Date();
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
var msPerMonth = msPerDay * 30;
var msPerYear = msPerDay * 365;
var elapsed = current - previous;
if (elapsed < msPerMinute) {
return Math.round(elapsed / 1000) + " seconds ago";
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + " minutes ago";
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + " hours ago";
} else if (elapsed < msPerMonth) {
return Math.round(elapsed / msPerDay) + " days ago";
} else if (elapsed < msPerYear) {
return Math.round(elapsed / msPerMonth) + " months ago";
} else {
return Math.round(elapsed / msPerYear) + " years ago";
}
}
export default {
methods: {
bootTime(unixtime) {
var previous = unixtime * 1000;
var current = new Date();
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
var msPerMonth = msPerDay * 30;
var msPerYear = msPerDay * 365;
var elapsed = current - previous;
if (elapsed < msPerMinute) {
return Math.round(elapsed / 1000) + " seconds ago";
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + " minutes ago";
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + " hours ago";
} else if (elapsed < msPerMonth) {
return Math.round(elapsed / msPerDay) + " days ago";
} else if (elapsed < msPerYear) {
return Math.round(elapsed / msPerMonth) + " months ago";
} else {
return Math.round(elapsed / msPerYear) + " years ago";
}
return getTimeLapse(unixtime);
},
alertTime(datetime) {
return getTimeLapse(Date.parse(datetime) / 1000);
},
notifySuccess(msg, timeout = 2000) {
Notify.create(notifySuccessConfig(msg, timeout));
@ -68,6 +76,10 @@ export default {
},
notifyInfo(msg, timeout = 2000) {
Notify.create(notifyInfoConfig(msg, timeout));
}
},
isValidEmail(val) {
const email = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return email.test(val);
},
}
};

View File

@ -15,6 +15,11 @@ const routes = [
requireAuth: true
}
},
{
path: "/totp_setup/:username",
name: "TOTPSetup",
component: () => import("@/views/TOTPSetup")
},
{
path: "/takecontrol/:pk",
name: "TakeControl",

51
web/src/store/admin.js Normal file
View File

@ -0,0 +1,51 @@
import axios from "axios";
export default {
namespaced: true,
state: {
users: []
},
getters: {
users(state) {
return state.checks;
}
},
mutations: {
setUsers(state, users) {
state.users = users;
}
},
actions: {
loadUsers(context) {
return axios.get("/accounts/users/").then(r => {
context.commit("setUsers", r.data);
})
},
loadUser(context, pk) {
return axios.get(`/accounts/${pk}/users/`);
},
addUser(context, data) {
return axios.post("/accounts/users/", data);
},
editUser(context, data) {
return axios.put(`/accounts/${data.id}/users/`, data);
},
deleteUser(context, pk) {
return axios.delete(`/accounts/${pk}/users/`).then(r => {
context.dispatch("loadUsers");
});
},
resetUserPassword(context, data) {
return axios.post("/accounts/users/reset/", data);
},
resetUserTOTP(context, data) {
return axios.put("/accounts/users/reset_totp/", data);
},
setupTOTP(context, data) {
return axios.post("/accounts/users/setup_totp/", data);
}
}
}

View File

@ -5,6 +5,7 @@ import { Notify } from "quasar";
import logModule from "./logs";
import alertsModule from "./alerts";
import automationModule from "./automation";
import adminModule from "./admin.js"
Vue.use(Vuex);
@ -14,6 +15,7 @@ export default function () {
logs: logModule,
automation: automationModule,
alerts: alertsModule,
admin: adminModule
},
state: {
username: localStorage.getItem("user_name") || null,

View File

@ -70,7 +70,7 @@ export default {
data() {
return {
credentials: {},
prompt: false
prompt: false,
};
},
@ -79,7 +79,11 @@ export default {
axios
.post("/checkcreds/", this.credentials)
.then(r => {
this.prompt = true;
if (r.data === "totp not set") {
this.$router.push({ name: "TOTPSetup", params: { username: this.credentials.username } });
} else {
this.prompt = true;
}
})
.catch(() => {
this.notifyError("Bad credentials");
@ -97,11 +101,11 @@ export default {
this.credentials = {};
this.prompt = false;
});
}
},
},
created() {
this.$q.dark.set(true);
}
},
};
</script>

View File

@ -0,0 +1,76 @@
<template>
<div class="q-pa-md">
<div class="row">
<div class="col"></div>
<div class="col">
<q-card>
<q-card-section class="row items-center">
<div class="text-h6">Setup 2-Factor</div>
</q-card-section>
<q-card-section>
<p> Scan the QR Code with your authenticator app and then click
Finish to be redirected back to the signin page.</p>
<qrcode-vue :value="qr_url" size="200" level="H" />
</q-card-section>
<q-card-section>
<p> You can also use the below code to configure the authenticator manually.</p>
<p>{{ totp_key }}</p>
</q-card-section>
<q-card-actions align="center">
<q-btn label="Finish" color="primary" class="full-width" @click="finish" />
</q-card-actions>
</q-card>
</div>
<div class="col"></div>
</div>
</div>
</template>
<script>
import QrcodeVue from 'qrcode.vue';
export default {
name: "TOTPSetup",
components: { QrcodeVue },
data() {
return {
totp_key: "",
qr_url: ""
};
},
methods: {
getQRCodeData() {
this.$q.loading = true;
const data = {
username: this.$route.params.username
};
this.$store
.dispatch("admin/setupTOTP", data)
.then(r => {
this.$q.loading = false;
if (r.data === "TOTP token already set") {
this.$router.push({ name: "Login" });
} else {
this.totp_key = r.data.totp_key;
this.qr_url = r.data.qr_url;
}
})
.catch(e => {
this.$q.loading = false;
console.log(e.response);
});
},
finish() {
this.$router.push({ name: "Login" });
}
},
created() {
this.$q.dark.set(false);
this.getQRCodeData();
}
};
</script>