feat: users, administrator role, selective access to server, login screen, admin dashboard with user and access management

This commit is contained in:
mmacierzanka committed 2026-09-30 19:29:38 +02:00
1 parent 1584b5fbd0
commit 6e39bfab12
20 files changed
+762 -72

No files matched your search

+59 -4
View File
@@ -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>
+5
View File
@@ -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);
+108
View File
@@ -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>
+55
View File
@@ -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>
+105
View File
@@ -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>
+56 -58
View File
@@ -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;