init frontend
This commit is contained in:
parent
de2d71301e
commit
fc46d48490
1
frontend/user-management-frontend/.env.development
Normal file
1
frontend/user-management-frontend/.env.development
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_HOST=http://localhost:8080
|
||||||
1
frontend/user-management-frontend/.env.production
Normal file
1
frontend/user-management-frontend/.env.production
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_HOST=
|
||||||
27
frontend/user-management-frontend/.gitignore
vendored
Normal file
27
frontend/user-management-frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
*.tar.gz
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
src/views
|
||||||
|
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/
|
||||||
|
.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
frontend/user-management-frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/user-management-frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
frontend/user-management-frontend/README.md
Normal file
5
frontend/user-management-frontend/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
13
frontend/user-management-frontend/index.html
Normal file
13
frontend/user-management-frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>User Manager System</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2923
frontend/user-management-frontend/package-lock.json
generated
Normal file
2923
frontend/user-management-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/user-management-frontend/package.json
Normal file
27
frontend/user-management-frontend/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "user-management-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^12.4.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"marked": "^15.0.6",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"vue3-markdown-it": "^1.0.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/user-management-frontend/postcss.config.js
Normal file
6
frontend/user-management-frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
frontend/user-management-frontend/public/vite.svg
Normal file
1
frontend/user-management-frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
440
frontend/user-management-frontend/src/App.vue
Normal file
440
frontend/user-management-frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="route.name === 'login'">
|
||||||
|
<router-view></router-view>
|
||||||
|
</div>
|
||||||
|
<div v-else class="min-h-screen bg-gray-900">
|
||||||
|
<!-- Top Navigation Bar -->
|
||||||
|
<nav class="bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center mr-2">
|
||||||
|
<span class="text-white font-bold">L</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xl font-semibold text-white">System Management Web-GUI</span>
|
||||||
|
<button
|
||||||
|
@click="toggleSidebar"
|
||||||
|
class="ml-4 text-gray-300 hover:text-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': !isSidebarOpen }"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button class="text-gray-300 hover:text-white">
|
||||||
|
<span class="text-sm">🌐</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="navigateTo('/markdown/todo.md')"
|
||||||
|
class="text-gray-300 hover:text-white">
|
||||||
|
<span class="text-sm">❓</span>
|
||||||
|
</button>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<span class="text-sm mr-2">你好, {{ authStore.user?.username || '管理员' }}</span>
|
||||||
|
<button
|
||||||
|
@click="toggleUserMenu"
|
||||||
|
class="hover:text-white focus:outline-none"
|
||||||
|
ref="userMenuButton"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': isUserMenuOpen }"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- User Dropdown Menu -->
|
||||||
|
<div
|
||||||
|
v-if="isUserMenuOpen"
|
||||||
|
class="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-700 ring-1 ring-black ring-opacity-5"
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
<button
|
||||||
|
@click="showModifyPasswordDialog = true"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="logout"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Side Navigation -->
|
||||||
|
<aside
|
||||||
|
class="bg-gray-800 min-h-screen transition-all duration-300"
|
||||||
|
:class="[isSidebarOpen ? 'w-52' : 'w-16']"
|
||||||
|
>
|
||||||
|
<nav class="px-2 py-4">
|
||||||
|
<div
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.name"
|
||||||
|
class="mb-1"
|
||||||
|
>
|
||||||
|
<div v-if="!item.children">
|
||||||
|
<!-- Menu Item as Route -->
|
||||||
|
<div
|
||||||
|
v-if="item.type === 'route'"
|
||||||
|
@click="navigateTo(item.path)"
|
||||||
|
class="flex items-center px-4 py-2 text-gray-300 hover:bg-gray-700 rounded-lg cursor-pointer"
|
||||||
|
:class="{ 'bg-gray-700': route.path === item.path }"
|
||||||
|
:title="!isSidebarOpen ? item.name : ''"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center" :class="{ 'w-full': !isSidebarOpen }">
|
||||||
|
<span class="text-xl" v-html="item.icon"></span>
|
||||||
|
<span
|
||||||
|
class="ml-2 text-sm"
|
||||||
|
:class="{ 'hidden': !isSidebarOpen, 'block': isSidebarOpen }"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu Item as Button -->
|
||||||
|
<div
|
||||||
|
v-else-if="item.type === 'button'"
|
||||||
|
@click="item.action"
|
||||||
|
class="flex items-center px-4 py-2 text-gray-300 hover:bg-gray-700 rounded-lg cursor-pointer"
|
||||||
|
:title="!isSidebarOpen ? item.name : ''"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center" :class="{ 'w-full': !isSidebarOpen }">
|
||||||
|
<span class="text-xl" v-html="item.icon"></span>
|
||||||
|
<span
|
||||||
|
class="ml-2 text-sm"
|
||||||
|
:class="{ 'hidden': !isSidebarOpen, 'block': isSidebarOpen }"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submenu -->
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
@click="toggleSubmenu(item)"
|
||||||
|
class="flex items-center px-4 py-2 text-gray-300 hover:bg-gray-700 rounded-lg cursor-pointer"
|
||||||
|
:class="{ 'bg-gray-700': isSubmenuOpen(item) }"
|
||||||
|
:title="!isSidebarOpen ? item.name : ''"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center w-full">
|
||||||
|
<span class="text-xl" v-html="item.icon"></span>
|
||||||
|
<span
|
||||||
|
class="ml-2 flex-grow text-sm"
|
||||||
|
:class="{ 'hidden': !isSidebarOpen, 'block': isSidebarOpen }"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
v-if="isSidebarOpen"
|
||||||
|
class="w-4 h-4 transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-90': isSubmenuOpen(item) }"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submenu Items -->
|
||||||
|
<div
|
||||||
|
v-show="isSubmenuOpen(item) && isSidebarOpen"
|
||||||
|
class="ml-4 mt-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.name"
|
||||||
|
@click="child.type === 'route' ? navigateTo(child.path) : child.action()"
|
||||||
|
class="flex items-center px-4 py-2 text-gray-300 hover:bg-gray-700 rounded-lg cursor-pointer"
|
||||||
|
:class="{ 'bg-gray-700': child.type === 'route' && route.path === child.path }"
|
||||||
|
>
|
||||||
|
<span class="text-xl" v-html="child.icon"></span>
|
||||||
|
<span class="ml-2 text-sm">{{ child.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="flex-1 p-6">
|
||||||
|
<router-view></router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showModifyPasswordDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg w-full max-w-md text-white">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Modify Password</h3>
|
||||||
|
<form @submit.prevent="modifyCurrentUserPassword">
|
||||||
|
<!-- Add admin form inputs -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="newPasswoed.password"
|
||||||
|
type="password"
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showModifyPasswordDialog = false"
|
||||||
|
class="bg-gray-500 hover:bg-gray-600 px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Modify
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Success Dialog -->
|
||||||
|
<div v-if="showRestartSuccessDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Service Restarted</h3>
|
||||||
|
<p class="mb-4">Samba service has been successfully restarted.</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="closeDialog"
|
||||||
|
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded text-white"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Restart Dialog -->
|
||||||
|
<div v-if="showConfirmRestartDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Settings Saved Successfully</h3>
|
||||||
|
<p class="mb-4">Would you like to restart the Samba service to apply the changes?</p>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
@click="closeDialog"
|
||||||
|
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded text-white"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="restartSambaService"
|
||||||
|
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded text-white"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
>
|
||||||
|
{{ isRestarting ? 'Restarting...' : 'Restart Now' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import axios from './utils/axios'
|
||||||
|
|
||||||
|
const API_BASE = `${import.meta.env.VITE_API_HOST}/api/admin`
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isSidebarOpen = ref(true)
|
||||||
|
const openSubmenus = ref(new Set())
|
||||||
|
const showModifyPasswordDialog = ref(false)
|
||||||
|
const newPasswoed = ref({ password: '' })
|
||||||
|
const isUserMenuOpen = ref(false)
|
||||||
|
const userMenuButton = ref(null)
|
||||||
|
|
||||||
|
const showConfirmRestartDialog = ref(false);
|
||||||
|
const showRestartSuccessDialog = ref(false);
|
||||||
|
const isRestarting = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (userMenuButton.value && !userMenuButton.value.contains(event.target)) {
|
||||||
|
isUserMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUserMenu = () => {
|
||||||
|
isUserMenuOpen.value = !isUserMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = ref([
|
||||||
|
{
|
||||||
|
name: 'DashBoard',
|
||||||
|
path: '/',
|
||||||
|
icon: '📊',
|
||||||
|
type: 'route'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'User Management',
|
||||||
|
path: '/user_manage',
|
||||||
|
icon: '👥',
|
||||||
|
type: 'route'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin Management',
|
||||||
|
path: '/admin',
|
||||||
|
icon: '👤',
|
||||||
|
type: 'route'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Samba Settings',
|
||||||
|
icon: '⚙️',
|
||||||
|
type: 'submenu',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'Global Settings',
|
||||||
|
path: '/system/samba/global',
|
||||||
|
icon: '🌐',
|
||||||
|
type: 'route'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sections Settings',
|
||||||
|
path: '/system/samba/sections',
|
||||||
|
icon: '🔧',
|
||||||
|
type: 'route'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Restart Service',
|
||||||
|
icon: '🔄',
|
||||||
|
type: 'button',
|
||||||
|
action: () => openSuccessDialog()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh Data',
|
||||||
|
icon: '🔄',
|
||||||
|
type: 'button',
|
||||||
|
action: () => alert('Data refreshed!')
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
isSidebarOpen.value = !isSidebarOpen.value
|
||||||
|
if (!isSidebarOpen.value) {
|
||||||
|
openSubmenus.value.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSubmenu = (item) => {
|
||||||
|
if (openSubmenus.value.has(item.name)) {
|
||||||
|
openSubmenus.value.delete(item.name)
|
||||||
|
} else {
|
||||||
|
openSubmenus.value.add(item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubmenuOpen = (item) => {
|
||||||
|
return openSubmenus.value.has(item.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateTo = (path) => {
|
||||||
|
if (path) {
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifyCurrentUserPassword = async () => {
|
||||||
|
try {
|
||||||
|
await axios.put(`${API_BASE}/admins/${authStore.user.id}/password`, { password: newPasswoed.value.password })
|
||||||
|
alert('Password changed successfully!')
|
||||||
|
showModifyPasswordDialog.value = false
|
||||||
|
newPasswoed.value.password = ""
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error changing password: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSuccessDialog = () => {
|
||||||
|
showConfirmRestartDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
showConfirmRestartDialog.value = false;
|
||||||
|
showRestartSuccessDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartSambaService = async () => {
|
||||||
|
try {
|
||||||
|
isRestarting.value = true;
|
||||||
|
await axios.post(`${import.meta.env.VITE_API_HOST}/api/samba/config/restart_samba`);
|
||||||
|
showConfirmRestartDialog.value = false;
|
||||||
|
showRestartSuccessDialog.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error restarting Samba service: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
isRestarting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
isUserMenuOpen.value = false
|
||||||
|
await authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate-180 {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate-90 {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
frontend/user-management-frontend/src/assets/background.png
Normal file
BIN
frontend/user-management-frontend/src/assets/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
1
frontend/user-management-frontend/src/assets/vue.svg
Normal file
1
frontend/user-management-frontend/src/assets/vue.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
msg: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out
|
||||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
|
>create-vue</a
|
||||||
|
>, the official Vue + Vite starter
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Learn more about IDE Support for Vue in the
|
||||||
|
<a
|
||||||
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
|
target="_blank"
|
||||||
|
>Vue Docs Scaling up Guide</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
frontend/user-management-frontend/src/main.css
Normal file
3
frontend/user-management-frontend/src/main.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
14
frontend/user-management-frontend/src/main.js
Normal file
14
frontend/user-management-frontend/src/main.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// src/main.js
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
// import './style.css' // if you have any global CSS
|
||||||
|
import './main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
46
frontend/user-management-frontend/src/stores/auth.js
Normal file
46
frontend/user-management-frontend/src/stores/auth.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// src/stores/auth.js
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: () => ({
|
||||||
|
token: localStorage.getItem('token') || null,
|
||||||
|
user: JSON.parse(localStorage.getItem('user')) || null
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isAuthenticated: (state) => !!state.token,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async login(username, password) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${import.meta.env.VITE_API_HOST}/api/admin/login`, {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
|
||||||
|
this.token = response.data.token
|
||||||
|
this.user = response.data.admin
|
||||||
|
|
||||||
|
localStorage.setItem('token', this.token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(this.user))
|
||||||
|
|
||||||
|
// Set default Authorization header for all future requests
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.error || error.message
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.token = null
|
||||||
|
this.user = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
delete axios.defaults.headers.common['Authorization']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
79
frontend/user-management-frontend/src/style.css
Normal file
79
frontend/user-management-frontend/src/style.css
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
/* min-width: 320px;
|
||||||
|
min-height: 100vh; */
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/user-management-frontend/src/utils/axios.js
Normal file
33
frontend/user-management-frontend/src/utils/axios.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Request Interceptor
|
||||||
|
axios.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
console.warn('No token found in localStorage.');
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}, error => {
|
||||||
|
console.error('Request error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response Interceptor
|
||||||
|
axios.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
error => {
|
||||||
|
console.error('Response error:', error); // Debug log
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Token expired or invalid
|
||||||
|
console.warn('401 Unauthorized - redirecting to login.');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default axios;
|
||||||
262
frontend/user-management-frontend/src/views/AdminManageView.vue
Normal file
262
frontend/user-management-frontend/src/views/AdminManageView.vue
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-4 bg-gray-900 text-white min-h-screen">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Website User Management</h1>
|
||||||
|
|
||||||
|
<!-- Admin List -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">Admin List</h2>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
@click="showAddAdminDialog = true"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add Admin
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="searchAdmin"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search username..."
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admins Table -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full bg-gray-800 border border-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-700">
|
||||||
|
<th class="text-left p-3 border border-gray-600">Username</th>
|
||||||
|
<th class="text-center p-3 border border-gray-600">Active</th>
|
||||||
|
<th class="text-center p-3 border border-gray-600">Created Date</th>
|
||||||
|
<th class="text-center p-3 border border-gray-600">Updated Date</th>
|
||||||
|
<th class="text-center p-3 border border-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="admin in paginatedAdmins" :key="admin.id" class="border-b border-gray-700 hover:bg-gray-750">
|
||||||
|
<td class="p-3 border border-gray-700">{{ admin.username }}</td>
|
||||||
|
<td class="p-3 border border-gray-700 text-center">
|
||||||
|
<span v-if="admin.is_active" class="text-green-500">✔</span>
|
||||||
|
<span v-else class="text-red-500">✘</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border border-gray-700 text-center">{{ formatDate(admin.created_at) }}</td>
|
||||||
|
<td class="p-3 border border-gray-700 text-center">{{ formatDate(admin.updated_at) }}</td>
|
||||||
|
<td class="p-3 border border-gray-700 text-center">
|
||||||
|
<button
|
||||||
|
@click="changePassword(admin)"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded mr-2"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{
|
||||||
|
'bg-green-500 hover:bg-green-600': !admin.is_active,
|
||||||
|
'bg-red-500 hover:bg-red-600': admin.is_active
|
||||||
|
}"
|
||||||
|
class="text-white px-3 py-1 rounded mr-2"
|
||||||
|
@click="toggleActiveStatus(admin)"
|
||||||
|
>
|
||||||
|
{{ admin.is_active ? 'Deactivate' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmDeleteAdmin(admin)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex justify-between items-center mt-4">
|
||||||
|
<span>Total {{ filteredAdmins.length }} admins</span>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="goToPage(currentPage - 1)"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>Page {{ currentPage }} of {{ totalPages }}</span>
|
||||||
|
<button
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Admin Dialog -->
|
||||||
|
<div v-if="showAddAdminDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg w-full max-w-md text-white">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Add New Admin</h3>
|
||||||
|
<form @submit.prevent="createAdmin">
|
||||||
|
<!-- Add admin form inputs -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="newAdmin.username"
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="newAdmin.password"
|
||||||
|
type="password"
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showAddAdminDialog = false"
|
||||||
|
class="bg-gray-500 hover:bg-gray-600 px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add Admin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import axios from '../utils/axios'
|
||||||
|
|
||||||
|
const API_BASE = `${import.meta.env.VITE_API_HOST}/api/admin`
|
||||||
|
|
||||||
|
const admins = ref([])
|
||||||
|
const showAddAdminDialog = ref(false)
|
||||||
|
const newAdmin = ref({ username: '', password: '' })
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = 10
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filteredAdmins = ref([])
|
||||||
|
|
||||||
|
const paginatedAdmins = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage
|
||||||
|
const end = start + itemsPerPage
|
||||||
|
return filteredAdmins.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(filteredAdmins.value.length / itemsPerPage))
|
||||||
|
|
||||||
|
const goToPage = (page) => {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
currentPage.value = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchAdmins()
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchAdmins = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/admins`)
|
||||||
|
admins.value = response.data
|
||||||
|
filteredAdmins.value = admins.value
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error fetching admins: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAdmin = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE}/admins`, newAdmin.value)
|
||||||
|
showAddAdminDialog.value = false
|
||||||
|
alert('Admin successfully added!')
|
||||||
|
newAdmin.value = { username: '', password: '' }
|
||||||
|
await fetchAdmins()
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error creating admin: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteAdmin = async (admin) => {
|
||||||
|
const confirmed = confirm(`Are you sure you want to delete admin: ${admin.username}?`)
|
||||||
|
if (confirmed) {
|
||||||
|
await deleteAdmin(admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAdmin = async (admin) => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_BASE}/admins/${admin.id}`)
|
||||||
|
await fetchAdmins()
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting admin: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchAdmin = () => {
|
||||||
|
if (searchQuery.value.trim() === '') {
|
||||||
|
filteredAdmins.value = admins.value
|
||||||
|
} else {
|
||||||
|
filteredAdmins.value = admins.value.filter(admin =>
|
||||||
|
admin.username.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePassword = async (admin) => {
|
||||||
|
const newPassword = prompt(`Enter new password for ${admin.username}:`)
|
||||||
|
if (newPassword) {
|
||||||
|
try {
|
||||||
|
await axios.put(`${API_BASE}/admins/${admin.id}/password`, { password: newPassword })
|
||||||
|
alert('Password changed successfully!')
|
||||||
|
await fetchAdmins() // Refresh the table
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error changing password: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleActiveStatus = async (admin) => {
|
||||||
|
const newStatus = !admin.is_active
|
||||||
|
try {
|
||||||
|
await axios.put(`${API_BASE}/admins/${admin.id}/active`, { is_active: newStatus })
|
||||||
|
await fetchAdmins() // Refresh the table
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error toggling active status: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
date.setHours(date.getHours() + 8) // Adjust for UTC+8
|
||||||
|
return date.toISOString().replace('T', ' ').split('.')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #1e1e2f;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
frontend/user-management-frontend/src/views/MarkDownView.vue
Normal file
142
frontend/user-management-frontend/src/views/MarkDownView.vue
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-4 bg-gray-900 text-white min-h-screen">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Help Documentation</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center text-gray-400">
|
||||||
|
Loading content...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="text-center text-red-400">
|
||||||
|
Error loading content: {{ error }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="prose prose-invert max-w-none markdown-body">
|
||||||
|
<VueMarkdown
|
||||||
|
:source="markdownContent"
|
||||||
|
:options="{
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
breaks: true
|
||||||
|
}"
|
||||||
|
class="markdown-content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import axios from '../utils/axios'
|
||||||
|
import VueMarkdown from 'vue3-markdown-it'
|
||||||
|
|
||||||
|
const API_BASE = `${import.meta.env.VITE_API_HOST}/api/markdowns`
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filePath: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
const markdownContent = ref('')
|
||||||
|
|
||||||
|
const fetchMarkdown = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/${props.filePath}`)
|
||||||
|
markdownContent.value = response.data
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || 'Failed to load content'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchMarkdown)
|
||||||
|
watch(() => props.filePath, fetchMarkdown)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content {
|
||||||
|
@apply text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists styling */
|
||||||
|
.markdown-content ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li > ul,
|
||||||
|
.markdown-content li > ol {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.markdown-content pre {
|
||||||
|
@apply bg-gray-800 p-4 rounded-lg overflow-x-auto my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
@apply bg-gray-800 px-1 py-0.5 rounded text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers */
|
||||||
|
.markdown-content h1 {
|
||||||
|
@apply text-2xl font-bold mb-4 mt-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h2 {
|
||||||
|
@apply text-xl font-bold mb-3 mt-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h3 {
|
||||||
|
@apply text-lg font-bold mb-2 mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.markdown-content a {
|
||||||
|
@apply text-blue-400 hover:text-blue-300 underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
|
.markdown-content blockquote {
|
||||||
|
@apply border-l-4 border-gray-600 pl-4 italic my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.markdown-content table {
|
||||||
|
@apply w-full border-collapse my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th,
|
||||||
|
.markdown-content td {
|
||||||
|
@apply border border-gray-700 p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content thead {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal rule */
|
||||||
|
.markdown-content hr {
|
||||||
|
@apply border-gray-600 my-6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-4 bg-gray-900 text-white min-h-screen">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Samba Global Settings</h1>
|
||||||
|
|
||||||
|
<!-- Settings List -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Global Settings</h2>
|
||||||
|
|
||||||
|
<!-- Table for displaying settings -->
|
||||||
|
<div class="overflow-x-auto mb-4">
|
||||||
|
<table class="min-w-full bg-gray-800 border border-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-700">
|
||||||
|
<th class="text-left p-3 border border-gray-600">Key</th>
|
||||||
|
<th class="text-left p-3 border border-gray-600">Value</th>
|
||||||
|
<th class="text-center p-3 border border-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(value, key) in displaySettings"
|
||||||
|
:key="key"
|
||||||
|
class="border-b border-gray-700 hover:bg-gray-750"
|
||||||
|
:class="{
|
||||||
|
'line-through opacity-50': deletedSettings[key],
|
||||||
|
'bg-green-900': newSettings[key]
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<td class="p-3 border border-gray-700">
|
||||||
|
{{ key }}
|
||||||
|
<span v-if="newSettings[key]" class="text-green-500 ml-2">(New)</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border border-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="editableSettings[key]"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white w-full"
|
||||||
|
:disabled="deletedSettings[key]"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border border-gray-700 text-center">
|
||||||
|
<div class="flex justify-center space-x-2">
|
||||||
|
<!-- Show Remove for existing settings -->
|
||||||
|
<button
|
||||||
|
v-if="!deletedSettings[key] && !newSettings[key]"
|
||||||
|
@click="markAsDeleted(key)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<!-- Show Undo for deleted settings -->
|
||||||
|
<button
|
||||||
|
v-if="deletedSettings[key]"
|
||||||
|
@click="undoDelete(key)"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
<!-- Show Remove for new settings -->
|
||||||
|
<button
|
||||||
|
v-if="newSettings[key]"
|
||||||
|
@click="removeNewSetting(key)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add New Setting -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Add New Setting</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<label class="block text-sm font-medium mb-1">
|
||||||
|
Key <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="newSetting.key"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter setting key"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white w-full"
|
||||||
|
:class="{'border-red-500': showValidation && !newSetting.key}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<label class="block text-sm font-medium mb-1">
|
||||||
|
Value <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="newSetting.value"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter setting value"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white w-full"
|
||||||
|
:class="{'border-red-500': showValidation && !newSetting.value}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
@click="addSetting"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modified Save Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="saveSettings"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Dialog -->
|
||||||
|
<div v-if="showSuccessDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Settings Saved Successfully</h3>
|
||||||
|
<p class="mb-4">Would you like to restart the Samba service to apply the changes?</p>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
@click="closeSuccessDialog"
|
||||||
|
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded text-white"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="restartSambaService"
|
||||||
|
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded text-white"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
>
|
||||||
|
{{ isRestarting ? 'Restarting...' : 'Restart Now' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Success Dialog -->
|
||||||
|
<div v-if="showRestartSuccessDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Service Restarted</h3>
|
||||||
|
<p class="mb-4">Samba service has been successfully restarted.</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="closeRestartSuccessDialog"
|
||||||
|
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded text-white"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import axios from '../utils/axios';
|
||||||
|
|
||||||
|
const API_BASE = `${import.meta.env.VITE_API_HOST}/api/samba/config`;
|
||||||
|
|
||||||
|
const globalSettings = ref({ name: 'global', settings: {} });
|
||||||
|
const editableSettings = ref({});
|
||||||
|
const deletedSettings = ref({});
|
||||||
|
const newSettings = ref({});
|
||||||
|
const newSetting = ref({ key: '', value: '' });
|
||||||
|
const showValidation = ref(false);
|
||||||
|
|
||||||
|
const showSuccessDialog = ref(false);
|
||||||
|
const showRestartSuccessDialog = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const isRestarting = ref(false);
|
||||||
|
|
||||||
|
// Computed property to combine all settings for display
|
||||||
|
const displaySettings = computed(() => {
|
||||||
|
return {
|
||||||
|
...globalSettings.value.settings,
|
||||||
|
...newSettings.value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchGlobalSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchGlobalSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/global_setting`);
|
||||||
|
globalSettings.value = response.data;
|
||||||
|
editableSettings.value = { ...response.data.settings };
|
||||||
|
// Reset all tracking states
|
||||||
|
deletedSettings.value = {};
|
||||||
|
newSettings.value = {};
|
||||||
|
showValidation.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error fetching global settings: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSetting = () => {
|
||||||
|
showValidation.value = true;
|
||||||
|
|
||||||
|
if (!newSetting.value.key || !newSetting.value.value) {
|
||||||
|
return alert('Please provide both key and value for the new setting.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displaySettings.value[newSetting.value.key]) {
|
||||||
|
return alert('This key already exists. Please use a different key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
newSettings.value[newSetting.value.key] = newSetting.value.value;
|
||||||
|
editableSettings.value[newSetting.value.key] = newSetting.value.value;
|
||||||
|
|
||||||
|
// Reset new setting form
|
||||||
|
newSetting.value = { key: '', value: '' };
|
||||||
|
showValidation.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAsDeleted = (key) => {
|
||||||
|
const confirmed = confirm(`Are you sure you want to remove the setting: ${key}?`);
|
||||||
|
if (confirmed) {
|
||||||
|
deletedSettings.value[key] = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoDelete = (key) => {
|
||||||
|
deletedSettings.value[key] = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNewSetting = (key) => {
|
||||||
|
const confirmed = confirm(`Are you sure you want to remove the new setting: ${key}?`);
|
||||||
|
if (confirmed) {
|
||||||
|
delete newSettings.value[key];
|
||||||
|
delete editableSettings.value[key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modified saveSettings function
|
||||||
|
const saveSettings = async () => {
|
||||||
|
try {
|
||||||
|
isSaving.value = true;
|
||||||
|
const updatedSettings = {
|
||||||
|
name: globalSettings.value.name,
|
||||||
|
settings: Object.entries(editableSettings.value).reduce((acc, [key, value]) => {
|
||||||
|
if (!deletedSettings.value[key]) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
};
|
||||||
|
|
||||||
|
await axios.post(`${API_BASE}/update_global_setting`, {
|
||||||
|
global_settings: JSON.stringify(updatedSettings)
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchGlobalSettings();
|
||||||
|
|
||||||
|
// Show success dialog instead of alert
|
||||||
|
showSuccessDialog.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving settings: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new functions for dialog control
|
||||||
|
const closeSuccessDialog = () => {
|
||||||
|
showSuccessDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRestartSuccessDialog = () => {
|
||||||
|
showRestartSuccessDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartSambaService = async () => {
|
||||||
|
try {
|
||||||
|
isRestarting.value = true;
|
||||||
|
await axios.post(`${API_BASE}/restart_samba`);
|
||||||
|
showSuccessDialog.value = false;
|
||||||
|
showRestartSuccessDialog.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error restarting Samba service: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
isRestarting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #1e1e2f;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,559 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-4 bg-gray-900 text-white min-h-screen">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Samba Section Settings</h1>
|
||||||
|
|
||||||
|
<!-- Modified Section Selector -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium mb-2">Select Section</label>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<select
|
||||||
|
v-model="selectedSection"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white w-64"
|
||||||
|
>
|
||||||
|
<option value="">Select a section...</option>
|
||||||
|
<optgroup label="Active Sections">
|
||||||
|
<option
|
||||||
|
v-for="(section, name) in activeSections"
|
||||||
|
:key="name"
|
||||||
|
:value="name"
|
||||||
|
:class="{'font-bold': newSections[name]}"
|
||||||
|
>
|
||||||
|
{{ name }} {{ newSections[name] ? '(New)' : '' }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup v-if="hasDeletedSections" label="Deleted Sections">
|
||||||
|
<option
|
||||||
|
v-for="(section, name) in deletedSectionsGroup"
|
||||||
|
:key="name"
|
||||||
|
:value="name"
|
||||||
|
>
|
||||||
|
{{ name }} (Deleted)
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
@click="showNewSectionForm = true"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add New Section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modified New Section Form -->
|
||||||
|
<div v-if="showNewSectionForm" class="mb-6 p-4 bg-gray-800 rounded">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Add New Section</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<input
|
||||||
|
v-model="newSectionName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Section Name (e.g., shared_folder)"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div v-for="field in defaultFields" :key="field.key">
|
||||||
|
<label class="block text-sm font-medium mb-1">
|
||||||
|
{{ field.label }}
|
||||||
|
<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="newSectionDefaults[field.key]"
|
||||||
|
type="text"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white w-full"
|
||||||
|
:class="{'border-red-500': showValidation && !newSectionDefaults[field.key]}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2 mt-4">
|
||||||
|
<button
|
||||||
|
@click="addNewSection"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add Section
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="cancelNewSection"
|
||||||
|
class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Settings -->
|
||||||
|
<div v-if="selectedSection" class="mb-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
Settings for: {{ selectedSection }}
|
||||||
|
<span v-if="newSections[selectedSection]" class="text-green-500 ml-2">(New)</span>
|
||||||
|
<span v-if="deletedSections[selectedSection]" class="text-red-500 ml-2">(Deleted)</span>
|
||||||
|
</h2>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<button
|
||||||
|
v-if="!deletedSections[selectedSection]"
|
||||||
|
@click="markSectionAsDeleted(selectedSection)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Remove Section
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="deletedSections[selectedSection]"
|
||||||
|
@click="undoSectionDelete(selectedSection)"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Undo Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Table -->
|
||||||
|
<div class="overflow-x-auto mb-4">
|
||||||
|
<table class="min-w-full bg-gray-800 border border-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-700">
|
||||||
|
<th class="text-left p-3 border border-gray-600">Key</th>
|
||||||
|
<th class="text-left p-3 border border-gray-600">Value</th>
|
||||||
|
<th class="text-center p-3 border border-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(value, key) in currentSectionSettings"
|
||||||
|
:key="key"
|
||||||
|
class="border-b border-gray-700 hover:bg-gray-750"
|
||||||
|
:class="{
|
||||||
|
'line-through opacity-50': deletedSettings[selectedSection]?.[key],
|
||||||
|
'bg-green-900': newSettings[selectedSection]?.[key]
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<td class="p-3 border border-gray-700">{{ key }}</td>
|
||||||
|
<td class="p-3 border border-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="editableSettings[selectedSection][key]"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white w-full"
|
||||||
|
:disabled="deletedSettings[selectedSection]?.[key]"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border border-gray-700 text-center">
|
||||||
|
<button
|
||||||
|
v-if="!deletedSettings[selectedSection]?.[key] && !newSettings[selectedSection]?.[key]"
|
||||||
|
@click="markSettingAsDeleted(selectedSection, key)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="deletedSettings[selectedSection]?.[key]"
|
||||||
|
@click="undoSettingDelete(selectedSection, key)"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="newSettings[selectedSection]?.[key]"
|
||||||
|
@click="removeNewSetting(selectedSection, key)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add New Setting -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Add New Setting</h3>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<input
|
||||||
|
v-model="newSetting.key"
|
||||||
|
type="text"
|
||||||
|
placeholder="Key"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white w-1/2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="newSetting.value"
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white w-1/2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="addSetting"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modified Save Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="saveSettings"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
{{ isSaving ? 'Saving...' : 'Save All Changes' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Dialog -->
|
||||||
|
<div v-if="showSuccessDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Settings Saved Successfully</h3>
|
||||||
|
<p class="mb-4">Would you like to restart the Samba service to apply the changes?</p>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
@click="closeSuccessDialog"
|
||||||
|
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded text-white"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="restartSambaService"
|
||||||
|
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded text-white"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
>
|
||||||
|
{{ isRestarting ? 'Restarting...' : 'Restart Now' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Success Dialog -->
|
||||||
|
<div v-if="showRestartSuccessDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Service Restarted</h3>
|
||||||
|
<p class="mb-4">Samba service has been successfully restarted.</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="closeRestartSuccessDialog"
|
||||||
|
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded text-white"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import axios from '../utils/axios';
|
||||||
|
|
||||||
|
const API_BASE = `${import.meta.env.VITE_API_HOST}/api/samba/config`;
|
||||||
|
|
||||||
|
const sections = ref({});
|
||||||
|
const selectedSection = ref('');
|
||||||
|
const showNewSectionForm = ref(false);
|
||||||
|
const newSectionName = ref('');
|
||||||
|
const newSetting = ref({ key: '', value: '' });
|
||||||
|
|
||||||
|
const editableSettings = ref({});
|
||||||
|
const deletedSettings = ref({});
|
||||||
|
const newSettings = ref({});
|
||||||
|
const deletedSections = ref({});
|
||||||
|
const newSections = ref({});
|
||||||
|
|
||||||
|
const showSuccessDialog = ref(false);
|
||||||
|
const showRestartSuccessDialog = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const isRestarting = ref(false);
|
||||||
|
|
||||||
|
const newSectionDefaults = ref({});
|
||||||
|
const showValidation = ref(false);
|
||||||
|
const defaultFields = [
|
||||||
|
{
|
||||||
|
key: 'path',
|
||||||
|
label: 'Path',
|
||||||
|
placeholder: '/path/to/share (e.g., /mnt/shared)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comment',
|
||||||
|
label: 'Comment',
|
||||||
|
placeholder: 'Description of the share (e.g., Shared Documents)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'browseable',
|
||||||
|
label: 'Browseable',
|
||||||
|
placeholder: 'yes/no (e.g., yes)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'writeable',
|
||||||
|
label: 'Writeable',
|
||||||
|
placeholder: 'yes/no (e.g., yes)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'create mask',
|
||||||
|
label: 'Create Mask',
|
||||||
|
placeholder: 'File permissions (e.g., 0777)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'directory mask',
|
||||||
|
label: 'Directory Mask',
|
||||||
|
placeholder: 'Folder permissions (e.g., 0777)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'read only',
|
||||||
|
label: 'Read Only',
|
||||||
|
placeholder: 'yes/no (e.g., no)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'guest ok',
|
||||||
|
label: 'Guest OK',
|
||||||
|
placeholder: 'yes/no (e.g., no)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'valid users',
|
||||||
|
label: 'Valid Users',
|
||||||
|
placeholder: 'List of users (e.g., @users, john, mary)'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Modified computed properties
|
||||||
|
const activeSections = computed(() => {
|
||||||
|
const allSections = { ...sections.value, ...newSections.value };
|
||||||
|
return Object.keys(allSections).reduce((acc, key) => {
|
||||||
|
if (!deletedSections.value[key]) {
|
||||||
|
acc[key] = allSections[key];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletedSectionsGroup = computed(() => {
|
||||||
|
const allSections = { ...sections.value, ...newSections.value };
|
||||||
|
return Object.keys(allSections).reduce((acc, key) => {
|
||||||
|
if (deletedSections.value[key]) {
|
||||||
|
acc[key] = allSections[key];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasDeletedSections = computed(() => {
|
||||||
|
return Object.keys(deletedSectionsGroup.value).length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// We can keep displaySections for other parts of the component that need all sections
|
||||||
|
const displaySections = computed(() => {
|
||||||
|
return { ...sections.value, ...newSections.value };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed property for current section settings
|
||||||
|
const currentSectionSettings = computed(() => {
|
||||||
|
if (!selectedSection.value) return {};
|
||||||
|
const baseSettings = sections.value[selectedSection.value]?.settings || {};
|
||||||
|
const newSectionSettings = newSettings.value[selectedSection.value] || {};
|
||||||
|
return { ...baseSettings, ...newSectionSettings };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchSections();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSections = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/section_setting`);
|
||||||
|
sections.value = response.data;
|
||||||
|
initializeEditableSettings();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error fetching sections: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeEditableSettings = () => {
|
||||||
|
editableSettings.value = Object.keys(sections.value).reduce((acc, section) => {
|
||||||
|
acc[section] = { ...sections.value[section].settings };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetNewSectionForm = () => {
|
||||||
|
newSectionName.value = '';
|
||||||
|
newSectionDefaults.value = {};
|
||||||
|
showValidation.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelNewSection = () => {
|
||||||
|
resetNewSectionForm();
|
||||||
|
showNewSectionForm.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modified addNewSection function
|
||||||
|
const addNewSection = () => {
|
||||||
|
showValidation.value = true;
|
||||||
|
|
||||||
|
if (!newSectionName.value) {
|
||||||
|
alert('Please provide a section name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.value[newSectionName.value] || newSections.value[newSectionName.value]) {
|
||||||
|
alert('This section already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all required fields are filled
|
||||||
|
const missingFields = defaultFields
|
||||||
|
.filter(field => !newSectionDefaults.value[field.key])
|
||||||
|
.map(field => field.label);
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
alert(`Please fill in all required fields:\n${missingFields.join('\n')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new section with default fields
|
||||||
|
newSections.value[newSectionName.value] = {
|
||||||
|
name: newSectionName.value,
|
||||||
|
settings: { ...newSectionDefaults.value }
|
||||||
|
};
|
||||||
|
|
||||||
|
editableSettings.value[newSectionName.value] = { ...newSectionDefaults.value };
|
||||||
|
newSettings.value[newSectionName.value] = { ...newSectionDefaults.value };
|
||||||
|
|
||||||
|
selectedSection.value = newSectionName.value;
|
||||||
|
resetNewSectionForm();
|
||||||
|
showNewSectionForm.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const markSectionAsDeleted = (section) => {
|
||||||
|
const confirmed = confirm(`Are you sure you want to remove the section: ${section}?`);
|
||||||
|
if (confirmed) {
|
||||||
|
deletedSections.value[section] = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoSectionDelete = (section) => {
|
||||||
|
deletedSections.value[section] = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSetting = () => {
|
||||||
|
if (!newSetting.value.key || !newSetting.value.value) {
|
||||||
|
return alert('Please provide both key and value for the new setting.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newSettings.value[selectedSection.value]) {
|
||||||
|
newSettings.value[selectedSection.value] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editableSettings.value[selectedSection.value]) {
|
||||||
|
editableSettings.value[selectedSection.value] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSectionSettings.value[newSetting.value.key]) {
|
||||||
|
return alert('This key already exists in the section.');
|
||||||
|
}
|
||||||
|
|
||||||
|
newSettings.value[selectedSection.value][newSetting.value.key] = newSetting.value.value;
|
||||||
|
editableSettings.value[selectedSection.value][newSetting.value.key] = newSetting.value.value;
|
||||||
|
newSetting.value = { key: '', value: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const markSettingAsDeleted = (section, key) => {
|
||||||
|
if (!deletedSettings.value[section]) {
|
||||||
|
deletedSettings.value[section] = {};
|
||||||
|
}
|
||||||
|
deletedSettings.value[section][key] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoSettingDelete = (section, key) => {
|
||||||
|
if (deletedSettings.value[section]) {
|
||||||
|
deletedSettings.value[section][key] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNewSetting = (section, key) => {
|
||||||
|
if (newSettings.value[section]) {
|
||||||
|
delete newSettings.value[section][key];
|
||||||
|
delete editableSettings.value[section][key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modified saveSettings function
|
||||||
|
const saveSettings = async () => {
|
||||||
|
try {
|
||||||
|
isSaving.value = true;
|
||||||
|
const updatedSections = {};
|
||||||
|
|
||||||
|
// Process existing and new sections
|
||||||
|
Object.keys({ ...sections.value, ...newSections.value }).forEach(sectionName => {
|
||||||
|
if (!deletedSections.value[sectionName]) {
|
||||||
|
const sectionSettings = {};
|
||||||
|
const currentSettings = editableSettings.value[sectionName] || {};
|
||||||
|
|
||||||
|
Object.keys(currentSettings).forEach(key => {
|
||||||
|
if (!deletedSettings.value[sectionName]?.[key]) {
|
||||||
|
sectionSettings[key] = currentSettings[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updatedSections[sectionName] = {
|
||||||
|
name: sectionName,
|
||||||
|
settings: sectionSettings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.post(`${API_BASE}/update_section_setting`, {
|
||||||
|
section_settings: JSON.stringify(updatedSections)
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchSections();
|
||||||
|
|
||||||
|
// Reset tracking states
|
||||||
|
deletedSettings.value = {};
|
||||||
|
newSettings.value = {};
|
||||||
|
deletedSections.value = {};
|
||||||
|
newSections.value = {};
|
||||||
|
|
||||||
|
// Show success dialog instead of alert
|
||||||
|
showSuccessDialog.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving settings: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new functions for dialog control
|
||||||
|
const closeSuccessDialog = () => {
|
||||||
|
showSuccessDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRestartSuccessDialog = () => {
|
||||||
|
showRestartSuccessDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartSambaService = async () => {
|
||||||
|
try {
|
||||||
|
isRestarting.value = true;
|
||||||
|
await axios.post(`${API_BASE}/restart_samba`);
|
||||||
|
showSuccessDialog.value = false;
|
||||||
|
showRestartSuccessDialog.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error restarting Samba service: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
isRestarting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #1e1e2f;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
421
frontend/user-management-frontend/src/views/UserManageView.vue
Normal file
421
frontend/user-management-frontend/src/views/UserManageView.vue
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-4 bg-gray-900 text-white min-h-screen">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Linux User Management</h1>
|
||||||
|
|
||||||
|
<!-- User List -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">User List</h2>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
@click="showAddUserDialog = true"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="searchUser"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search username..."
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full bg-gray-800 border border-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-700">
|
||||||
|
<th class="text-left p-3 border border-gray-600">Username</th>
|
||||||
|
<th class="text-left p-3 border border-gray-600">UID</th>
|
||||||
|
<th class="text-left p-3 border border-gray-600">GID</th>
|
||||||
|
<th class="text-left p-3 border border-gray-600">Home Directory</th>
|
||||||
|
<th class="text-left p-3 border border-gray-600">Shell</th>
|
||||||
|
<th class="text-center p-3 border border-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in paginatedUsers" :key="user.username" class="border-b border-gray-700 hover:bg-gray-750">
|
||||||
|
<td class="p-3 border border-gray-700">{{ user.username }}</td>
|
||||||
|
<td class="p-3 border border-gray-700">{{ user.uid }}</td>
|
||||||
|
<td class="p-3 border border-gray-700">{{ user.gid }}</td>
|
||||||
|
<td class="p-3 border border-gray-700">{{ user.home_dir }}</td>
|
||||||
|
<td class="p-3 border border-gray-700">{{ user.shell }}</td>
|
||||||
|
<td class="p-3 border border-gray-700 text-center">
|
||||||
|
<button
|
||||||
|
@click="changePassword(user)"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded mr-2"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="openEditGroupsDialog(user)"
|
||||||
|
class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded mr-2"
|
||||||
|
>
|
||||||
|
Edit Groups
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmDeleteUser(user)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex justify-between items-center mt-4">
|
||||||
|
<span>Total {{ filteredUsers.length }} users</span>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="goToPage(currentPage - 1)"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>Page {{ currentPage }} of {{ totalPages }}</span>
|
||||||
|
<button
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dialogs remain unchanged but styled for dark theme -->
|
||||||
|
<div v-if="showAddUserDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg w-full max-w-md text-white">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Add New User</h3>
|
||||||
|
<form @submit.prevent="createUser">
|
||||||
|
<!-- Add user form inputs -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="newUser.username"
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="newUser.password"
|
||||||
|
type="password"
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Group</label>
|
||||||
|
<input
|
||||||
|
v-model="newUser.group"
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Shell</label>
|
||||||
|
<select
|
||||||
|
v-model="newUser.shell"
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
>
|
||||||
|
<option value="/bin/bash">/bin/bash</option>
|
||||||
|
<option value="/sbin/nologin">/sbin/nologin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="newUser.isAdmin"
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox bg-gray-700 border-gray-600 text-green-500"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Administrator</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showAddUserDialog = false"
|
||||||
|
class="bg-gray-500 hover:bg-gray-600 px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Groups Dialog -->
|
||||||
|
<div v-if="showEditGroupsDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg w-full max-w-md text-white">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Edit Groups for {{ selectedUser.username }}</h3>
|
||||||
|
|
||||||
|
<!-- Display existing groups -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Current Groups</label>
|
||||||
|
<ul class="list-none space-y-2">
|
||||||
|
<li v-for="group in userGroups" :key="group" class="flex justify-between items-center">
|
||||||
|
<span>{{ group }}</span>
|
||||||
|
<button
|
||||||
|
@click="removeGroup(group)"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Select Groups to Add -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-1">Select Groups to Add</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedGroupsToAdd"
|
||||||
|
multiple
|
||||||
|
class="w-full border rounded px-3 py-2 bg-gray-700 border-gray-600 text-white"
|
||||||
|
>
|
||||||
|
<option v-for="group in allGroups" :key="group" :value="group">{{ group }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<button
|
||||||
|
@click="addGroups"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Add Groups
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="closeEditGroupsDialog"
|
||||||
|
class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import axios from '../utils/axios'
|
||||||
|
|
||||||
|
const API_BASE = `${import.meta.env.VITE_API_HOST}/api`
|
||||||
|
|
||||||
|
const users = ref([])
|
||||||
|
const showAddUserDialog = ref(false)
|
||||||
|
const newUser = ref({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
group: '',
|
||||||
|
shell: '/bin/bash',
|
||||||
|
isAdmin: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showEditGroupsDialog = ref(false)
|
||||||
|
const selectedUser = ref({})
|
||||||
|
const userGroups = ref([]) // Groups the user currently belongs to
|
||||||
|
const allGroups = ref([]) // All available groups to select from
|
||||||
|
const selectedGroupsToAdd = ref([]) // Groups to be added
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = 10
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filteredUsers = ref([])
|
||||||
|
|
||||||
|
const paginatedUsers = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage
|
||||||
|
const end = start + itemsPerPage
|
||||||
|
return filteredUsers.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(filteredUsers.value.length / itemsPerPage))
|
||||||
|
|
||||||
|
const goToPage = (page) => {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
currentPage.value = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchUsers()
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/users`)
|
||||||
|
users.value = response.data
|
||||||
|
filteredUsers.value = users.value
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error fetching users: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const createUser = async () => {
|
||||||
|
try {
|
||||||
|
if (newUser.value.isAdmin) {
|
||||||
|
// Create admin user
|
||||||
|
await axios.post(`${API_BASE}/admin/admins`, {
|
||||||
|
username: newUser.value.username,
|
||||||
|
password: newUser.value.password
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also create regular user if needed
|
||||||
|
await axios.post(`${API_BASE}/users`, newUser.value)
|
||||||
|
} else {
|
||||||
|
// Create regular user only
|
||||||
|
await axios.post(`${API_BASE}/users`, newUser.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddUserDialog.value = false
|
||||||
|
alert('User successfully added!')
|
||||||
|
newUser.value = { username: '', password: '', group: '', shell: '/bin/bash', isAdmin: false }
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error creating user: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteUser = async (user) => {
|
||||||
|
const confirmed = confirm(`Are you sure you want to delete user: ${user.username}?`)
|
||||||
|
if (confirmed) {
|
||||||
|
await deleteUser(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUser = async (user) => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_BASE}/users/${user.username}`)
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting user: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchUser = () => {
|
||||||
|
if (searchQuery.value.trim() === '') {
|
||||||
|
filteredUsers.value = users.value
|
||||||
|
} else {
|
||||||
|
filteredUsers.value = users.value.filter(user =>
|
||||||
|
user.username.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the Edit Groups dialog and fetch user groups
|
||||||
|
const openEditGroupsDialog = async (user) => {
|
||||||
|
selectedUser.value = user
|
||||||
|
showEditGroupsDialog.value = true
|
||||||
|
try {
|
||||||
|
// Fetch the groups the user is already a member of
|
||||||
|
const response = await axios.get(`${API_BASE}/groups/${user.username}`)
|
||||||
|
userGroups.value = response.data.map(group => group.groupName)
|
||||||
|
|
||||||
|
// Fetch all groups
|
||||||
|
const allGroupsResponse = await axios.get(`${API_BASE}/groups`)
|
||||||
|
allGroups.value = allGroupsResponse.data.map(group => group.groupName)
|
||||||
|
|
||||||
|
// Filter out the groups the user is already a member of
|
||||||
|
allGroups.value = allGroups.value.filter(group => !userGroups.value.includes(group))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error fetching groups: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Add multiple groups to the user
|
||||||
|
const addGroups = async () => {
|
||||||
|
if (selectedGroupsToAdd.value.length === 0) {
|
||||||
|
return alert('Please select at least one group to add!')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send an array of groups to the API
|
||||||
|
await axios.post(`${API_BASE}/groups/${selectedUser.value.username}`, {
|
||||||
|
groups: selectedGroupsToAdd.value
|
||||||
|
})
|
||||||
|
// Add selected groups to userGroups locally
|
||||||
|
userGroups.value.push(...selectedGroupsToAdd.value)
|
||||||
|
alert('Groups added successfully!')
|
||||||
|
selectedGroupsToAdd.value = [] // Reset the selected groups
|
||||||
|
await openEditGroupsDialog(selectedUser.value) // Refresh available groups and update the list
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error adding groups: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a group from the user
|
||||||
|
const removeGroup = async (group) => {
|
||||||
|
const confirmed = confirm(`Are you sure you want to remove ${selectedUser.value.username} from ${group}?`)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_BASE}/groups/${selectedUser.value.username}`, {
|
||||||
|
data: { group }
|
||||||
|
})
|
||||||
|
// Remove the group locally
|
||||||
|
userGroups.value = userGroups.value.filter((g) => g !== group)
|
||||||
|
alert('Group removed successfully!')
|
||||||
|
|
||||||
|
// After removing, refresh the available groups
|
||||||
|
await openEditGroupsDialog(selectedUser.value) // Refresh available groups and update the list
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error removing group: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the Edit Groups dialog
|
||||||
|
const closeEditGroupsDialog = () => {
|
||||||
|
showEditGroupsDialog.value = false
|
||||||
|
selectedUser.value = {}
|
||||||
|
userGroups.value = []
|
||||||
|
allGroups.value = []
|
||||||
|
selectedGroupsToAdd.value = [] // Reset the selection
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePassword = async (user) => {
|
||||||
|
const newPassword = prompt(`Enter new password for ${user.username}:`)
|
||||||
|
if (newPassword) {
|
||||||
|
try {
|
||||||
|
await axios.put(`${API_BASE}/users/${user.username}`, { password: newPassword })
|
||||||
|
alert('Password changed successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error changing password: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #1e1e2f;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
frontend/user-management-frontend/tailwind.config.js
Normal file
12
frontend/user-management-frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
6
frontend/user-management-frontend/vite.config.js
Normal file
6
frontend/user-management-frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user