mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-07-18 23:34:04 +02:00
feat: Implement user management with user types
Adds a user management module allowing administrators to assign types (roles) to users. - Adds `user_type` column to the `user` table (default 'admin'). - Updates user model and adds backend logic for managing user types. - Introduces a new 'User Management' section in Settings UI for admins. - Admins can now view all users and change their user types. - Access to user management functions is restricted to admin users.
This commit is contained in:
parent
10fd6ede1e
commit
d94126dce6
7 changed files with 301 additions and 1 deletions
15
db/knex_migrations/2025-07-15-0000-add-user-type.js
Normal file
15
db/knex_migrations/2025-07-15-0000-add-user-type.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
await knex.schema.alterTable('user', function (table) {
|
||||||
|
table.string('user_type').notNullable().defaultTo('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// If you want to set a default user type for existing users, you can do it here.
|
||||||
|
// For example, set all existing users to 'admin'
|
||||||
|
// await knex('user').update({ user_type: 'admin' });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.alterTable('user', function (table) {
|
||||||
|
table.dropColumn('user_type');
|
||||||
|
});
|
||||||
|
};
|
|
@ -44,10 +44,40 @@ class User extends BeanModel {
|
||||||
static createJWT(user, jwtSecret) {
|
static createJWT(user, jwtSecret) {
|
||||||
return jwt.sign({
|
return jwt.sign({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
// Include user_type in the JWT payload if needed for frontend/client-side checks,
|
||||||
|
// but remember that JWTs can be decoded by the client.
|
||||||
|
// For sensitive authorization, always re-check user_type on the server-side.
|
||||||
|
user_type: user.user_type,
|
||||||
h: shake256(user.password, SHAKE256_LENGTH),
|
h: shake256(user.password, SHAKE256_LENGTH),
|
||||||
}, jwtSecret);
|
}, jwtSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user type.
|
||||||
|
* Note: This assumes the 'user_type' field is populated in the bean.
|
||||||
|
* If you load a user bean partially, ensure 'user_type' is selected.
|
||||||
|
* @returns {string | null} The user type string or null if not set/loaded.
|
||||||
|
*/
|
||||||
|
getUserType() {
|
||||||
|
return this.user_type || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user type.
|
||||||
|
* Remember to save the bean after setting the type for changes to persist.
|
||||||
|
* @param {string} newUserType The new type for the user.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async setUserType(newUserType) {
|
||||||
|
// Basic validation can be added here if desired, e.g.,
|
||||||
|
// const allowedTypes = ['admin', 'editor', 'viewer'];
|
||||||
|
// if (!allowedTypes.includes(newUserType)) {
|
||||||
|
// throw new Error(`Invalid user type: ${newUserType}`);
|
||||||
|
// }
|
||||||
|
this.user_type = newUserType;
|
||||||
|
// Note: This only updates the property on the model instance.
|
||||||
|
// You need to call R.store(userBean) to persist the change to the database.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = User;
|
module.exports = User;
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
const { log } = require("../../src/util");
|
const { log } = require("../../src/util");
|
||||||
const { Settings } = require("../settings");
|
const { Settings } = require("../settings");
|
||||||
const { sendInfo } = require("../client");
|
const { sendInfo } = require("../client");
|
||||||
const { checkLogin } = require("../util-server");
|
const { checkLogin, isAdmin } = require("../util-server"); // Added isAdmin
|
||||||
|
const { R } = require("redbean-node"); // Added R for database operations
|
||||||
|
const User = require("../model/user"); // Added User model
|
||||||
const GameResolver = require("gamedig/lib/GameResolver");
|
const GameResolver = require("gamedig/lib/GameResolver");
|
||||||
const { testChrome } = require("../monitor-types/real-browser-monitor-type");
|
const { testChrome } = require("../monitor-types/real-browser-monitor-type");
|
||||||
const fsAsync = require("fs").promises;
|
const fsAsync = require("fs").promises;
|
||||||
|
@ -136,4 +138,77 @@ module.exports.generalSocketHandler = (socket, server) => {
|
||||||
log.warn("disconnectAllSocketClients", e.message);
|
log.warn("disconnectAllSocketClients", e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User Management Socket Handlers
|
||||||
|
// Only admins should be able to manage users.
|
||||||
|
|
||||||
|
socket.on("getUsers", async (callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
await isAdmin(socket); // Ensure the user is an admin
|
||||||
|
|
||||||
|
const userList = await R.findAll("user", "ORDER BY username");
|
||||||
|
// Avoid sending password hashes to the client
|
||||||
|
const sanitizedUserList = userList.map(user => {
|
||||||
|
const { password, ...sanitizedUser } = user.export();
|
||||||
|
return sanitizedUser;
|
||||||
|
});
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
users: sanitizedUserList,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error("getUsers", e.message);
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("updateUserType", async (userID, newUserType, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
await isAdmin(socket); // Ensure the user is an admin
|
||||||
|
|
||||||
|
if (!userID || !newUserType) {
|
||||||
|
throw new Error("User ID and new user type are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent admin from changing their own type if they are the only admin?
|
||||||
|
// Or prevent changing the type of the main admin user? (e.g., user with ID 1)
|
||||||
|
// For now, let's assume such checks are handled by higher-level logic or are not required.
|
||||||
|
|
||||||
|
const user = await R.findOne("user", "id = ?", [userID]);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Potentially validate newUserType against a list of allowed types
|
||||||
|
const allowedTypes = ["admin", "editor", "viewer"]; // Example types
|
||||||
|
if (!allowedTypes.includes(newUserType)) {
|
||||||
|
throw new Error(`Invalid user type: ${newUserType}. Allowed types are: ${allowedTypes.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.user_type = newUserType;
|
||||||
|
await R.store(user);
|
||||||
|
|
||||||
|
// Optionally, emit an event to other admins that a user type has changed
|
||||||
|
// server.sendToAdmins("userTypeChanged", { userID, newUserType });
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: `User type for user ID ${userID} updated to ${newUserType}.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log.error("updateUserType", e.message);
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -798,6 +798,29 @@ exports.checkLogin = (socket) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is an admin.
|
||||||
|
* Throws an error if the user is not logged in or not an admin.
|
||||||
|
* @param {Socket} socket Socket instance
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws Error if not logged in or not an admin
|
||||||
|
*/
|
||||||
|
exports.isAdmin = async (socket) => {
|
||||||
|
exports.checkLogin(socket); // Ensure user is logged in first
|
||||||
|
|
||||||
|
const user = await R.findOne("user", "id = ? AND active = 1", [socket.userID]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// This case should ideally be caught by checkLogin or represent an anomaly
|
||||||
|
throw new Error("User not found or not active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.user_type !== "admin") {
|
||||||
|
throw new Error("Administrator privileges required.");
|
||||||
|
}
|
||||||
|
// If we reach here, the user is an admin.
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For logged-in users, double-check the password
|
* For logged-in users, double-check the password
|
||||||
* @param {Socket} socket Socket.io instance
|
* @param {Socket} socket Socket.io instance
|
||||||
|
|
149
src/components/settings/UserManagement.vue
Normal file
149
src/components/settings/UserManagement.vue
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
<template>
|
||||||
|
<div class="my-4">
|
||||||
|
<h5 class="my-4 settings-subheading">{{ $t("User Management") }}</h5>
|
||||||
|
|
||||||
|
<div v-if="loadingUsers" class="text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">{{ $t("Loading...") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loadingUsers && users.length === 0" class="alert alert-info">
|
||||||
|
{{ $t("No users found.") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loadingUsers && users.length > 0">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t("Username") }}</th>
|
||||||
|
<th>{{ $t("User Type") }}</th>
|
||||||
|
<th>{{ $t("Actions") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.id">
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="!user.editing">{{ $t(user.user_type) }}</span>
|
||||||
|
<select v-else v-model="user.selected_type" class="form-select form-select-sm w-auto">
|
||||||
|
<option v-for="type in availableUserTypes" :key="type" :value="type">
|
||||||
|
{{ $t(type) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="!user.editing" class="btn btn-sm btn-outline-primary me-2" @click="startEditUserType(user)">
|
||||||
|
<font-awesome-icon icon="edit" /> {{ $t("Edit Type") }}
|
||||||
|
</button>
|
||||||
|
<template v-if="user.editing">
|
||||||
|
<button class="btn btn-sm btn-primary me-2" @click="saveUserType(user)">
|
||||||
|
<font-awesome-icon icon="save" /> {{ $t("Save") }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @click="cancelEditUserType(user)">
|
||||||
|
<font-awesome-icon icon="times" /> {{ $t("Cancel") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<!-- Add other actions like delete user if needed -->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Add User Button - Future enhancement
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-primary" @click="showAddUserDialog = true">
|
||||||
|
<font-awesome-icon icon="plus" /> {{ $t("Add User") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loadingUsers: false,
|
||||||
|
users: [],
|
||||||
|
availableUserTypes: ["admin", "editor", "viewer"], // Should match backend
|
||||||
|
// showAddUserDialog: false, // For future add user functionality
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// Access current logged-in user's type if needed for conditional UI
|
||||||
|
currentUserType() {
|
||||||
|
// This assumes user info including type is available in $root or a store
|
||||||
|
// For now, we'll rely on backend to enforce admin actions
|
||||||
|
return this.$root.userType || "admin"; // Fallback for example
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetchUsers();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchUsers() {
|
||||||
|
this.loadingUsers = true;
|
||||||
|
this.$root.getSocket().emit("getUsers", (res) => {
|
||||||
|
this.loadingUsers = false;
|
||||||
|
if (res.ok) {
|
||||||
|
this.users = res.users.map(user => ({
|
||||||
|
...user,
|
||||||
|
editing: false,
|
||||||
|
selected_type: user.user_type, // For select dropdown
|
||||||
|
original_type: user.user_type, // To revert on cancel
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.$root.toastError(res.msg || this.$t("Failed to load users."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
startEditUserType(user) {
|
||||||
|
// Only allow admins to edit (though backend enforces this too)
|
||||||
|
// if (this.currentUserType !== 'admin') {
|
||||||
|
// this.$root.toastError(this.$t("You do not have permission to edit user types."));
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
user.editing = true;
|
||||||
|
},
|
||||||
|
cancelEditUserType(user) {
|
||||||
|
user.selected_type = user.original_type;
|
||||||
|
user.editing = false;
|
||||||
|
},
|
||||||
|
saveUserType(user) {
|
||||||
|
if (user.id === this.$root.userID && user.selected_type !== "admin") {
|
||||||
|
// Assuming $root.userID holds the current logged-in user's ID
|
||||||
|
const adminUsers = this.users.filter(u => u.original_type === 'admin');
|
||||||
|
if (adminUsers.length === 1 && adminUsers[0].id === user.id) {
|
||||||
|
this.$root.toastError(this.$t("You cannot change the type of the only administrator."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$root.getSocket().emit("updateUserType", user.id, user.selected_type, (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
this.$root.toastSuccess(res.msg || this.$t("User type updated successfully."));
|
||||||
|
user.user_type = user.selected_type;
|
||||||
|
user.original_type = user.selected_type;
|
||||||
|
user.editing = false;
|
||||||
|
// Optional: if the current user's type was changed, may need to update $root.userType or re-fetch user info
|
||||||
|
} else {
|
||||||
|
this.$root.toastError(res.msg || this.$t("Failed to update user type."));
|
||||||
|
// Revert optimistic update if needed, or re-fetch users
|
||||||
|
user.selected_type = user.original_type;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Add methods for addUser, deleteUser in the future if required
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-subheading {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.w-auto {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -110,6 +110,9 @@ export default {
|
||||||
security: {
|
security: {
|
||||||
title: this.$t("Security"),
|
title: this.$t("Security"),
|
||||||
},
|
},
|
||||||
|
"user-management": { // New Menu Item
|
||||||
|
title: this.$t("User Management"),
|
||||||
|
},
|
||||||
"api-keys": {
|
"api-keys": {
|
||||||
title: this.$t("API Keys")
|
title: this.$t("API Keys")
|
||||||
},
|
},
|
||||||
|
|
|
@ -29,6 +29,7 @@ import ReverseProxy from "./components/settings/ReverseProxy.vue";
|
||||||
import Tags from "./components/settings/Tags.vue";
|
import Tags from "./components/settings/Tags.vue";
|
||||||
import MonitorHistory from "./components/settings/MonitorHistory.vue";
|
import MonitorHistory from "./components/settings/MonitorHistory.vue";
|
||||||
const Security = () => import("./components/settings/Security.vue");
|
const Security = () => import("./components/settings/Security.vue");
|
||||||
|
const UserManagement = () => import("./components/settings/UserManagement.vue"); // Added UserManagement
|
||||||
import Proxies from "./components/settings/Proxies.vue";
|
import Proxies from "./components/settings/Proxies.vue";
|
||||||
import About from "./components/settings/About.vue";
|
import About from "./components/settings/About.vue";
|
||||||
import RemoteBrowsers from "./components/settings/RemoteBrowsers.vue";
|
import RemoteBrowsers from "./components/settings/RemoteBrowsers.vue";
|
||||||
|
@ -124,6 +125,10 @@ const routes = [
|
||||||
path: "security",
|
path: "security",
|
||||||
component: Security,
|
component: Security,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "user-management", // New Route
|
||||||
|
component: UserManagement,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "api-keys",
|
path: "api-keys",
|
||||||
component: APIKeys,
|
component: APIKeys,
|
||||||
|
|
Loading…
Add table
Reference in a new issue