feat: users, administrator role, selective access to server, login screen, admin dashboard with user and access management
This commit is contained in:
20 files changed
+762
-72
No files matched your search
+59
-4
@@ -9,11 +9,46 @@ import ToastHost from '@/components/ToastHost.vue'
|
||||
import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue'
|
||||
import UploadsPanel from '@/components/UploadsPanel.vue'
|
||||
import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
|
||||
import AdminLayout from '@/components/AdminLayout.vue'
|
||||
import { useBotStatus } from '@/composables/useBotStatus'
|
||||
import { useUploads } from '@/composables/useUploads'
|
||||
|
||||
const { status, connect, selectGuild } = useBotStatus()
|
||||
const { status, connect, disconnect, selectGuild } = useBotStatus()
|
||||
const { refresh } = useUploads()
|
||||
const user = ref<{ id: number; username: string; is_admin: boolean } | null>(null)
|
||||
const authLoading = ref(true)
|
||||
const loginName = ref('')
|
||||
const loginPassword = ref('')
|
||||
const loginError = ref('')
|
||||
const onAdminRoute = window.location.pathname.startsWith('/admin')
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me')
|
||||
if (response.ok) user.value = await response.json()
|
||||
} catch {
|
||||
user.value = null
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginError.value = ''
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: loginName.value, password: loginPassword.value }),
|
||||
})
|
||||
if (!response.ok) { loginError.value = 'Nieprawidłowa nazwa użytkownika lub hasło'; return }
|
||||
const loggedInUser = await response.json() as { id: number; username: string; is_admin: boolean }
|
||||
user.value = loggedInUser
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
disconnect()
|
||||
user.value = null
|
||||
}
|
||||
|
||||
const libraryOpen = ref(false)
|
||||
const selectedGuildId = ref<string | null>(null)
|
||||
@@ -60,13 +95,26 @@ async function changeGuild(guildId: string) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect()
|
||||
refresh()
|
||||
onMounted(() => { void loadUser() })
|
||||
|
||||
watch(user, (value) => {
|
||||
if (value && !(value.is_admin && onAdminRoute)) { connect(); void refresh() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="authLoading" class="auth-loading" aria-live="polite">Ładowanie sesji…</main>
|
||||
<main v-else-if="!user" class="auth-page">
|
||||
<form class="auth-card" @submit.prevent="login">
|
||||
<h1>ŻyrarBOT</h1>
|
||||
<label>Nazwa użytkownika<input v-model="loginName" autocomplete="username" required></label>
|
||||
<label>Hasło<input v-model="loginPassword" type="password" autocomplete="current-password" required></label>
|
||||
<p v-if="loginError" class="auth-error">{{ loginError }}</p>
|
||||
<button class="login-button" type="submit">Zaloguj</button>
|
||||
</form>
|
||||
</main>
|
||||
<AdminLayout v-else-if="user.is_admin && onAdminRoute" :username="user.username" :user-id="user.id" />
|
||||
<template v-else-if="user">
|
||||
<div class="background-glow bg-glow-1" />
|
||||
<div class="background-glow bg-glow-2" />
|
||||
|
||||
@@ -75,7 +123,9 @@ onMounted(() => {
|
||||
:status="status"
|
||||
:guilds="status?.guilds ?? []"
|
||||
:selected-guild-id="selectedGuildId"
|
||||
:is-admin="user.is_admin"
|
||||
@select-guild="changeGuild"
|
||||
@logout="logout"
|
||||
/>
|
||||
|
||||
<main class="dashboard-grid">
|
||||
@@ -103,4 +153,9 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<ToastHost />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-loading{min-height:60vh;display:grid;place-items:center;color:#a9b1c4;font-size:14px}.auth-page{width:520px;max-width:calc(100vw - 40px);margin:8vh auto;padding:24px}.auth-card{display:grid;gap:16px;padding:30px;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 20px 60px #0006}.auth-card h1{margin:0}.auth-card label{display:grid;gap:7px;color:#d9dcec;font-size:13px;font-weight:600}.auth-card input{width:100%;height:44px;padding:0 12px;border-radius:9px;border:1px solid #42475e;background:#101321;color:#f6f7fc;font:inherit}.auth-card input:focus{outline:2px solid #9b83ff66;border-color:#9b83ff}.login-button{height:44px;border:1px solid #8c70f2;border-radius:9px;background:#7657e8;color:#fff;font:600 14px Inter,ui-sans-serif,system-ui,sans-serif;cursor:pointer}.login-button:hover{background:#886df1}.auth-error{color:#ff4d5e;font-weight:600}.admin-shortcut{display:flex;justify-content:flex-end;padding:0 0 12px}.admin-shortcut a{color:#c8baff}
|
||||
</style>
|
||||
@@ -31,6 +31,11 @@
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
type PanelUser = { id: number; username: string; is_admin: boolean; guild_ids: string[] }
|
||||
type Guild = { guild_id: string; guild_name: string }
|
||||
const users = ref<PanelUser[]>([])
|
||||
const guilds = ref<Guild[]>([])
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const draftAccess = ref<Record<number, string[]>>({})
|
||||
const error = ref('')
|
||||
const panelUsers = computed(() => users.value.filter((user) => !user.is_admin))
|
||||
const dirtyUsers = computed(() => panelUsers.value.filter((user) => {
|
||||
const saved = [...user.guild_ids].sort()
|
||||
const draft = [...(draftAccess.value[user.id] ?? user.guild_ids)].sort()
|
||||
return saved.length !== draft.length || saved.some((id, index) => id !== draft[index])
|
||||
}))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [usersResponse, guildsResponse] = await Promise.all([
|
||||
fetch('/api/auth/users'), fetch('/api/auth/guilds'),
|
||||
])
|
||||
if (!usersResponse.ok || !guildsResponse.ok) throw new Error('Nie udało się pobrać listy użytkowników i serwerów.')
|
||||
;[users.value, guilds.value] = await Promise.all([usersResponse.json(), guildsResponse.json()])
|
||||
draftAccess.value = Object.fromEntries(users.value.map((user) => [user.id, [...user.guild_ids]]))
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Wystąpił błąd.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccess(account: PanelUser, guildId: string, checked: boolean) {
|
||||
const current = draftAccess.value[account.id] ?? [...account.guild_ids]
|
||||
const updated = checked
|
||||
? [...new Set([...current, guildId])]
|
||||
: current.filter((id) => id !== guildId)
|
||||
draftAccess.value = { ...draftAccess.value, [account.id]: updated }
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!dirtyUsers.value.length || saving.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
const results = await Promise.allSettled(dirtyUsers.value.map(async (account) => {
|
||||
const guildIds = [...(draftAccess.value[account.id] ?? [])]
|
||||
const response = await fetch(`/api/auth/users/${account.id}/guilds`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
// Keep Discord snowflakes as decimal strings; JavaScript numbers lose precision.
|
||||
body: JSON.stringify({ guild_ids: guildIds }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${account.username}: nie udało się zapisać uprawnień.`)
|
||||
account.guild_ids = guildIds
|
||||
}))
|
||||
const failed = results.filter((result) => result.status === 'rejected')
|
||||
if (failed.length) {
|
||||
error.value = failed.map((result) => result.status === 'rejected' && result.reason instanceof Error
|
||||
? result.reason.message : 'Nie udało się zapisać uprawnień.').join(' ')
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page-content">
|
||||
<div class="admin-page-heading">
|
||||
<div><p class="admin-eyebrow">UPRAWNIENIA</p><h2>Dostęp do serwerów</h2><p>Zaznacz serwery dostępne dla każdego użytkownika.</p></div>
|
||||
<div class="matrix-actions">
|
||||
<button class="matrix-refresh" type="button" :disabled="loading" @click="load">Odśwież</button>
|
||||
<button class="matrix-save" type="button" :disabled="loading || saving || !dirtyUsers.length" @click="saveChanges">{{ saving ? 'Zapisywanie…' : 'Zapisz zmiany' }}<span v-if="dirtyUsers.length" class="pending-count">{{ dirtyUsers.length }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="matrix-message matrix-error">{{ error }}</p>
|
||||
<section class="matrix-panel">
|
||||
<div v-if="loading" class="matrix-state">Ładowanie uprawnień…</div>
|
||||
<div v-else-if="!guilds.length" class="matrix-state">Bot nie jest obecnie połączony z żadnym serwerem.</div>
|
||||
<div v-else-if="!panelUsers.length" class="matrix-state">Nie ma jeszcze zwykłych użytkowników. Utwórz konto na stronie Użytkownicy.</div>
|
||||
<div v-else class="matrix-scroll">
|
||||
<table class="access-table">
|
||||
<thead><tr><th class="server-heading">Serwer</th><th v-for="account in panelUsers" :key="account.id">{{ account.username }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="guild in guilds" :key="guild.guild_id">
|
||||
<th class="server-name"><span class="server-mark">#</span>{{ guild.guild_name }}</th>
|
||||
<td v-for="account in panelUsers" :key="account.id">
|
||||
<label class="access-check" :aria-label="`${account.username}: ${guild.guild_name}`">
|
||||
<input type="checkbox" :checked="(draftAccess[account.id] ?? account.guild_ids).includes(guild.guild_id)" :disabled="saving" @change="updateAccess(account, guild.guild_id, ($event.target as HTMLInputElement).checked)">
|
||||
<span class="checkmark" />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer v-if="!loading && guilds.length && panelUsers.length" class="matrix-footer">{{ dirtyUsers.length ? `Niezapisane zmiany: ${dirtyUsers.length} użytk.` : 'Wprowadź zmiany i wybierz „Zapisz zmiany”.' }}</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page-content{display:grid;gap:18px}.admin-page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.admin-page-heading h2{margin:2px 0 6px;font-size:28px}.admin-page-heading p:last-child{color:#a9b1c4;margin:0}.admin-eyebrow{font-size:11px;letter-spacing:.14em;color:#9c88ff;font-weight:700;margin:0}.matrix-refresh{height:38px;padding:0 14px;border-radius:9px;border:1px solid #41465c;background:#252a3c;color:#eceeff;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer}.matrix-refresh:hover:not(:disabled){background:#30364b}.matrix-refresh:disabled{opacity:.5;cursor:wait}.matrix-message{margin:0;font-size:13px}.matrix-error{color:#ff8d9b}.matrix-panel{overflow:hidden;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 12px 36px #0003}.matrix-scroll{overflow:auto;max-height:calc(100vh - 280px)}.access-table{width:100%;border-collapse:separate;border-spacing:0;min-width:max-content}.access-table th,.access-table td{height:54px;padding:10px 16px;text-align:center;border-bottom:1px solid #2b2f42;border-right:1px solid #2b2f42}.access-table thead th{position:sticky;top:0;z-index:2;background:#202337;color:#c8cce0;font-size:12px;font-weight:700;min-width:132px}.access-table thead .server-heading{left:0;z-index:3;text-align:left;min-width:240px}.access-table tbody th.server-name{position:sticky;left:0;z-index:1;background:#191c2c;text-align:left;color:#eef0f8;font-weight:600;min-width:240px}.access-table tr:last-child th,.access-table tr:last-child td{border-bottom:0}.access-table th:last-child,.access-table td:last-child{border-right:0}.server-mark{display:inline-grid;place-items:center;width:25px;height:25px;margin-right:10px;border-radius:8px;background:#30294e;color:#beadff}.access-check{position:relative;display:inline-flex;width:22px;height:22px;cursor:pointer}.access-check input{position:absolute;width:1px;height:1px;opacity:0}.checkmark{width:22px;height:22px;border:1px solid #555c72;border-radius:6px;background:#101321;transition:all .14s}.access-check input:checked+.checkmark{border-color:#9678ff;background:#7959e8;box-shadow:0 0 0 3px #8c6cf126}.access-check input:checked+.checkmark:after{content:'';position:absolute;left:8px;top:4px;width:5px;height:9px;border:solid white;border-width:0 2px 2px 0;transform:rotate(45deg)}.access-check input:focus-visible+.checkmark{outline:2px solid #c4b5fd;outline-offset:2px}.access-check input:disabled+.checkmark{opacity:.45;cursor:wait}.matrix-footer{padding:13px 16px;color:#969db1;font-size:12px;border-top:1px solid #2b2f42}.matrix-state{padding:36px 22px;text-align:center;color:#abb1c2}.admin-page-content :deep(*){box-sizing:border-box}@media(max-width:700px){.admin-page-heading{align-items:flex-start}.admin-page-heading h2{font-size:23px}.access-table thead .server-heading,.access-table tbody th.server-name{min-width:180px}}
|
||||
.matrix-actions{display:flex;align-items:center;gap:9px}.matrix-save{display:inline-flex;align-items:center;justify-content:center;gap:9px;min-height:38px;padding:0 14px;border:1px solid #8c70f2;border-radius:9px;background:#7657e8;color:#fff;font-family:inherit;font-size:13px;font-weight:650;cursor:pointer;transition:background .15s,border-color .15s}.matrix-save:hover:not(:disabled){background:#886df1;border-color:#a28cff}.matrix-save:disabled{opacity:.48;cursor:not-allowed}.pending-count{display:inline-grid;place-items:center;min-width:19px;height:19px;padding:0 5px;border-radius:999px;background:#ffffff2a;font-size:11px}@media(max-width:520px){.admin-page-heading{flex-direction:column}.matrix-actions{width:100%}.matrix-actions button{flex:1}}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AdminAccessPage from '@/components/AdminAccessPage.vue'
|
||||
import AdminUsersPage from '@/components/AdminUsersPage.vue'
|
||||
|
||||
defineProps<{ username: string; userId: number }>()
|
||||
const currentPath = ref(window.location.pathname)
|
||||
const activePage = computed(() => currentPath.value.endsWith('/access') ? 'access' : 'users')
|
||||
|
||||
function navigate(path: string) {
|
||||
window.history.pushState({}, '', path)
|
||||
currentPath.value = path
|
||||
}
|
||||
|
||||
function onPopState() { currentPath.value = window.location.pathname }
|
||||
onMounted(() => {
|
||||
if (window.location.pathname === '/admin') navigate('/admin/users')
|
||||
window.addEventListener('popstate', onPopState)
|
||||
})
|
||||
|
||||
async function restartBot() {
|
||||
if (!window.confirm('Czy na pewno zrestartować bota?')) return
|
||||
const response = await fetch('/api/restart', { method: 'POST' })
|
||||
if (!response.ok) window.alert('Nie udało się zrestartować bota.')
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
window.location.assign('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="admin-shell">
|
||||
<aside class="admin-sidebar">
|
||||
<a class="admin-brand" href="/admin/users"><img src="/icon.jpeg" alt=""><span><strong>ŻyrarBOT</strong><small>Panel administracyjny</small></span></a>
|
||||
<div class="sidebar-caption">ZARZĄDZANIE</div>
|
||||
<nav class="sidebar-nav" aria-label="Menu administratora">
|
||||
<a href="/admin/users" title="Użytkownicy" :class="{ active: activePage === 'users' }" @click.prevent="navigate('/admin/users')"><span class="nav-icon">♙</span>Użytkownicy</a>
|
||||
<a href="/admin/access" title="Dostęp do serwerów" :class="{ active: activePage === 'access' }" @click.prevent="navigate('/admin/access')"><span class="nav-icon">▦</span>Dostęp do serwerów</a>
|
||||
</nav>
|
||||
<div class="sidebar-bottom"><div class="admin-identity"><span class="identity-dot"/><span><small>Zalogowano jako</small><strong>{{ username }}</strong></span></div><button class="sidebar-logout" type="button" @click="logout">Wyloguj</button></div>
|
||||
</aside>
|
||||
|
||||
<section class="admin-main">
|
||||
<header class="admin-topbar"><div><span class="topbar-label">ADMINISTRACJA</span><span class="topbar-separator">/</span><span>{{ activePage === 'access' ? 'Dostęp do serwerów' : 'Użytkownicy' }}</span></div><div class="topbar-actions"><a class="topbar-link" href="/">Wróć do panelu</a><button class="admin-button admin-button-danger" type="button" @click="restartBot">Restart bota</button></div></header>
|
||||
<div class="admin-content"><AdminUsersPage v-if="activePage === 'users'" :current-user-id="userId"/><AdminAccessPage v-else/></div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-shell{width:100%;min-height:calc(100vh - 40px);margin:0 auto;display:grid;grid-template-columns:248px minmax(0,1fr);overflow:hidden;border:1px solid #292d40;border-radius:20px;background:#101320;color:#f4f5fb;box-shadow:0 24px 70px #0006;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.admin-sidebar{position:relative;display:flex;flex-direction:column;padding:22px 14px 16px;background:#151827;border-right:1px solid #292d40}.admin-brand{display:flex;align-items:center;gap:11px;padding:3px 9px 27px;color:inherit;text-decoration:none}.admin-brand img{width:39px;height:39px;object-fit:cover;border-radius:12px}.admin-brand span,.admin-identity span{display:grid;gap:3px}.admin-brand strong{font-size:14px}.admin-brand small{font-size:11px;color:#8e95aa}.sidebar-caption{padding:12px 11px 8px;color:#747c93;font-size:10px;font-weight:800;letter-spacing:.12em}.sidebar-nav{display:grid;gap:5px}.sidebar-nav a{display:flex;align-items:center;gap:11px;min-height:41px;padding:0 11px;border:1px solid transparent;border-radius:9px;color:#aeb4c6;text-decoration:none;font-size:12px;font-weight:600;transition:background .15s,color .15s}.sidebar-nav a:hover{background:#202438;color:#fff}.sidebar-nav a.active{background:#2b2645;border-color:#3e3560;color:#d8ceff}.nav-icon{display:grid;place-items:center;width:19px;font-size:18px;color:#9d89ec}.sidebar-bottom{display:grid;gap:12px;margin-top:auto;padding:14px 7px 0;border-top:1px solid #2a2e40}.admin-identity{display:flex;align-items:center;gap:10px}.identity-dot{width:8px;height:8px;border-radius:50%;background:#53d49f;box-shadow:0 0 9px #53d49f88}.admin-identity small{color:#858da2;font-size:10px}.admin-identity strong{font-size:12px;overflow-wrap:anywhere}.sidebar-logout{height:34px;border:1px solid #353a4f;border-radius:8px;background:#202436;color:#cbd0df;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.sidebar-logout:hover{background:#2a3045;color:#fff}.admin-main{min-width:0;display:flex;flex-direction:column;background:radial-gradient(ellipse at 86% 0%,#28213c44,transparent 38%),#101320}.admin-topbar{min-height:66px;display:flex;align-items:center;justify-content:space-between;gap:15px;padding:0 26px;border-bottom:1px solid #292d40;color:#b9bfd0;font-size:12px}.topbar-label{color:#858da3;font-size:10px;font-weight:800;letter-spacing:.1em}.topbar-separator{margin:0 10px;color:#51586d}.topbar-actions{display:flex;align-items:center;gap:16px}.topbar-link{color:#c8c4df;text-decoration:none}.topbar-link:hover{color:#fff}.admin-content{width:100%;max-width:none;margin:0;padding:32px 34px 48px}.admin-button{min-height:36px;padding:0 13px;border-radius:8px;font-family:inherit;font-size:12px;font-weight:650;cursor:pointer;transition:background .15s,border-color .15s,transform .15s}.admin-button-danger{border:1px solid #613743;background:#38242d;color:#ffc1c6}.admin-button-danger:hover{border-color:#a54e5b;background:#4b2934;transform:translateY(-1px)}@media(max-width:760px){.admin-shell{min-height:calc(100vh - 20px);grid-template-columns:68px minmax(0,1fr);border-radius:14px}.admin-sidebar{padding:17px 8px 12px}.admin-brand{justify-content:center;padding:0 0 22px}.admin-brand img{width:38px;height:38px}.admin-brand span,.sidebar-caption,.sidebar-nav a:not(.active),.sidebar-nav a.active{font-size:0}.sidebar-nav a{justify-content:center;padding:0}.sidebar-nav a .nav-icon{font-size:19px}.sidebar-nav a{position:relative}.sidebar-nav a:not(:hover)::after{display:none}.admin-identity{justify-content:center}.admin-identity span{display:none}.sidebar-logout{font-size:0}.sidebar-logout:after{content:'↪';font-size:16px}.admin-topbar{padding:0 15px;min-height:58px}.topbar-actions{gap:9px}.topbar-link{font-size:0}.topbar-link:after{content:'Panel';font-size:11px}.admin-button{padding:0 9px;font-size:11px}.admin-content{padding:22px 14px 30px}}@media(max-width:480px){.admin-shell{grid-template-columns:56px minmax(0,1fr)}.admin-content{padding:18px 10px}.admin-topbar{padding:0 10px}.topbar-label,.topbar-separator{display:none}}
|
||||
.admin-brand span{min-width:0}@media(max-width:760px){.admin-brand span{display:none}}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
type PanelUser = { id: number; username: string; is_admin: boolean }
|
||||
const props = defineProps<{ currentUserId: number }>()
|
||||
const users = ref<PanelUser[]>([])
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
const roleBusy = ref(new Set<number>())
|
||||
const error = ref('')
|
||||
|
||||
async function loadUsers() {
|
||||
const response = await fetch('/api/auth/users')
|
||||
if (response.ok) users.value = await response.json()
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/auth/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.value.trim(), password: password.value }),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się utworzyć konta.')
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
await loadUsers()
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Nie udało się utworzyć konta.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setAdminRole(account: PanelUser) {
|
||||
const isAdmin = !account.is_admin
|
||||
roleBusy.value = new Set(roleBusy.value).add(account.id)
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch(`/api/auth/users/${account.id}/role`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_admin: isAdmin }),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się zmienić roli użytkownika.')
|
||||
await loadUsers()
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Nie udało się zmienić roli użytkownika.'
|
||||
} finally {
|
||||
const pending = new Set(roleBusy.value)
|
||||
pending.delete(account.id)
|
||||
roleBusy.value = pending
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadUsers() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page-content">
|
||||
<div class="admin-page-heading">
|
||||
<div><p class="admin-eyebrow">KONTA</p><h2>Użytkownicy</h2><p>Twórz konta i zarządzaj ich danymi logowania.</p></div>
|
||||
<span class="admin-count">{{ users.length }} kont</span>
|
||||
</div>
|
||||
|
||||
<section class="admin-panel">
|
||||
<h3>Utwórz użytkownika</h3>
|
||||
<form class="admin-user-form" @submit.prevent="createUser">
|
||||
<label class="admin-field"><span>Nazwa użytkownika</span><input v-model="username" autocomplete="off" maxlength="80" required placeholder="np. jan.kowalski"></label>
|
||||
<label class="admin-field"><span>Hasło</span><input v-model="password" type="password" autocomplete="new-password" required placeholder="Wpisz hasło"></label>
|
||||
<button class="admin-button admin-button-primary" type="submit" :disabled="busy">{{ busy ? 'Tworzenie…' : 'Utwórz konto' }}</button>
|
||||
</form>
|
||||
<p v-if="error" class="admin-feedback admin-feedback-error">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel">
|
||||
<div class="admin-section-heading"><h3>Istniejące konta</h3><span>Nazwę lub hasło nowego konta ustawisz podczas tworzenia.</span></div>
|
||||
<div v-if="users.length" class="admin-user-list">
|
||||
<div v-for="account in users" :key="account.id" class="admin-user-row">
|
||||
<span class="admin-avatar">{{ account.username.slice(0, 1).toUpperCase() }}</span>
|
||||
<span class="admin-user-name">{{ account.username }}</span>
|
||||
<label class="admin-role-toggle" :title="account.id === props.currentUserId && account.is_admin ? 'Nie można odebrać roli własnemu kontu' : undefined">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="account.is_admin"
|
||||
:disabled="roleBusy.has(account.id) || (account.id === props.currentUserId && account.is_admin)"
|
||||
@change="setAdminRole(account)"
|
||||
>
|
||||
<span>Administrator</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="admin-empty">Brak kont.</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page-content{display:grid;gap:22px}.admin-page-heading,.admin-section-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.admin-page-heading h2{margin:2px 0 6px;font-size:28px}.admin-page-heading p:last-child,.admin-section-heading span{color:#a9b1c4;margin:0}.admin-eyebrow{font-size:11px;letter-spacing:.14em;color:#9c88ff;font-weight:700;margin:0}.admin-count{padding:8px 12px;border-radius:999px;background:#25263b;color:#ded9ff;font-size:13px}.admin-panel{padding:24px;border:1px solid #303449;background:#171a29;border-radius:16px;box-shadow:0 12px 36px #0003}.admin-panel h3{font-size:16px;margin:0 0 18px}.admin-user-form{display:grid;grid-template-columns:1fr 1fr auto;align-items:end;gap:14px}.admin-field{display:grid;gap:7px;color:#d9dcec;font-size:13px;font-weight:600}.admin-field input{width:100%;height:42px;padding:0 12px;border-radius:9px;border:1px solid #42475e;background:#101321;color:#f6f7fc;font:inherit;outline:none}.admin-field input:focus{border-color:#9b83ff;box-shadow:0 0 0 3px #9175ff24}.admin-field input::placeholder{color:#777f95;font-weight:400}.admin-button{min-height:42px;padding:0 17px;border-radius:9px;border:1px solid transparent;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,transform .15s}.admin-button-primary{color:white;background:#7657e8;border-color:#8c70f2}.admin-button-primary:hover:not(:disabled){background:#886df1;transform:translateY(-1px)}.admin-button:disabled{opacity:.55;cursor:wait}.admin-feedback{margin:13px 0 0;font-size:13px}.admin-feedback-error{color:#ff8d9b}.admin-section-heading{margin-bottom:8px}.admin-section-heading h3{margin:0}.admin-section-heading span{font-size:12px}.admin-user-list{display:grid}.admin-user-row{display:flex;align-items:center;gap:12px;min-height:58px;border-top:1px solid #2b2f42}.admin-avatar{width:32px;height:32px;display:grid;place-items:center;border-radius:10px;background:#332a55;color:#c9baff;font-weight:700}.admin-user-name{flex:1;color:#f3f4f8;font-weight:600}.admin-role-toggle{display:inline-flex;align-items:center;gap:8px;color:#c7cadd;font-size:12px;white-space:nowrap;cursor:pointer}.admin-role-toggle input{width:16px;height:16px;margin:0;accent-color:#886df1;cursor:pointer}.admin-role-toggle input:disabled{opacity:.45;cursor:not-allowed}.admin-empty{color:#9ca3b5}.admin-page-content :deep(*){box-sizing:border-box}@media(max-width:760px){.admin-user-form{grid-template-columns:1fr}.admin-user-form .admin-button{width:100%}.admin-section-heading{align-items:flex-start;flex-direction:column}.admin-user-row{flex-wrap:wrap;padding:10px 0}.admin-user-name{min-width:calc(100% - 48px)}.admin-role-toggle{margin-left:auto}}
|
||||
</style>
|
||||
@@ -1,21 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RotateCw } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import type { BotStatus, GuildSettings } from '@/types/status'
|
||||
import { api } from '@/api/client'
|
||||
import { useToasts } from '@/composables/useToasts'
|
||||
|
||||
const props = defineProps<{
|
||||
status: BotStatus | null
|
||||
guilds: GuildSettings[]
|
||||
selectedGuildId: string | null
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
defineEmits<{ selectGuild: [guildId: string] }>()
|
||||
defineEmits<{ selectGuild: [guildId: string]; logout: [] }>()
|
||||
|
||||
const { notify } = useToasts()
|
||||
const isRestarting = ref(false)
|
||||
const restartInitiatedAt = ref<number | null>(null)
|
||||
let restartTimer: number | null = null
|
||||
|
||||
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
|
||||
|
||||
@@ -25,42 +19,6 @@ const label = computed(() =>
|
||||
: 'Rozłączony',
|
||||
)
|
||||
|
||||
async function handleRestart() {
|
||||
if (isRestarting.value) return
|
||||
if (!window.confirm('Czy na pewno chcesz zrestartować bota?')) return
|
||||
|
||||
isRestarting.value = true
|
||||
restartInitiatedAt.value = Date.now()
|
||||
notify('Wysyłanie polecenia restartu...', 'info')
|
||||
|
||||
try {
|
||||
await api.restart()
|
||||
notify('Wysłano polecenie. Restartowanie bota...', 'info')
|
||||
} catch {
|
||||
notify('Wysłano polecenie restartu.', 'info')
|
||||
}
|
||||
|
||||
if (restartTimer !== null) clearTimeout(restartTimer)
|
||||
restartTimer = window.setTimeout(() => {
|
||||
isRestarting.value = false
|
||||
restartInitiatedAt.value = null
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.status,
|
||||
() => {
|
||||
if (isRestarting.value && restartInitiatedAt.value && Date.now() - restartInitiatedAt.value > 3000) {
|
||||
isRestarting.value = false
|
||||
restartInitiatedAt.value = null
|
||||
if (restartTimer !== null) {
|
||||
clearTimeout(restartTimer)
|
||||
restartTimer = null
|
||||
}
|
||||
notify('Bot został uruchomiony ponownie!', 'success')
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -101,19 +59,10 @@ watch(
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary btn-restart"
|
||||
:disabled="isRestarting"
|
||||
:title="isRestarting ? 'Restartowanie bota...' : 'Zrestartuj bota'"
|
||||
@click="handleRestart"
|
||||
>
|
||||
<RotateCw
|
||||
:size="16"
|
||||
:class="{ 'spin-icon': isRestarting }"
|
||||
/>
|
||||
<span>{{ isRestarting ? 'Restart...' : 'Restart' }}</span>
|
||||
</button>
|
||||
<div class="account-actions">
|
||||
<a v-if="isAdmin" class="account-button account-button-admin" href="/admin/users">Panel admina</a>
|
||||
<button class="account-button" type="button" @click="$emit('logout')">Wyloguj</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -125,6 +74,43 @@ watch(
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.account-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-main);
|
||||
font: 600 0.8rem var(--font-sans);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.account-button:hover {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.account-button-admin {
|
||||
border-color: var(--border-active);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #d8c9ff;
|
||||
}
|
||||
|
||||
.btn-restart {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -166,6 +152,18 @@ watch(
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 0 0;
|
||||
border-top: 1px solid var(--border-glass);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.account-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-restart {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
|
||||
Reference in new issue
Block a user