init frontend

This commit is contained in:
2025-02-19 15:51:48 +08:00
parent de2d71301e
commit fc46d48490
26 changed files with 5375 additions and 0 deletions

View File

@@ -0,0 +1 @@
VITE_API_HOST=http://localhost:8080

View File

@@ -0,0 +1 @@
VITE_API_HOST=

View 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?

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View 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).

View 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>

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View 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

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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')

View 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']
}
}
})

View 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;
}
}

View 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;

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})