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) {
|
||||
return jwt.sign({
|
||||
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),
|
||||
}, 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;
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
const { log } = require("../../src/util");
|
||||
const { Settings } = require("../settings");
|
||||
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 { testChrome } = require("../monitor-types/real-browser-monitor-type");
|
||||
const fsAsync = require("fs").promises;
|
||||
|
@ -136,4 +138,77 @@ module.exports.generalSocketHandler = (socket, server) => {
|
|||
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
|
||||
* @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: {
|
||||
title: this.$t("Security"),
|
||||
},
|
||||
"user-management": { // New Menu Item
|
||||
title: this.$t("User Management"),
|
||||
},
|
||||
"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 MonitorHistory from "./components/settings/MonitorHistory.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 About from "./components/settings/About.vue";
|
||||
import RemoteBrowsers from "./components/settings/RemoteBrowsers.vue";
|
||||
|
@ -124,6 +125,10 @@ const routes = [
|
|||
path: "security",
|
||||
component: Security,
|
||||
},
|
||||
{
|
||||
path: "user-management", // New Route
|
||||
component: UserManagement,
|
||||
},
|
||||
{
|
||||
path: "api-keys",
|
||||
component: APIKeys,
|
||||
|
|
Loading…
Add table
Reference in a new issue