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

+13
View File
@@ -22,6 +22,19 @@ Wymagane zmienne środowiskowe (plik `.env`):
Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze
jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`. jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
### Konta i dostępy panelu
Panel wymaga logowania. Utwórz pierwszego administratora w działającym kontenerze:
```bash
docker compose exec discord-bot python -m zyrarbot create-admin admin
```
Komenda poprosi o hasło (minimum 10 znaków). Administrator ma dostęp do wszystkich
serwerów i strony `/admin`, gdzie może tworzyć konta użytkowników, przypisywać im
dostęp do serwerów oraz restartować bota. Zwykły użytkownik widzi wyłącznie
przypisane mu serwery. Konta i sesje są przechowywane w PostgreSQL.
Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Ustawienia, historia osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Ustawienia, historia
i migawki kolejek przechodzą przez porty persystencji, a PostgreSQL jest obecnym i migawki kolejek przechodzą przez porty persystencji, a PostgreSQL jest obecnym
+59 -4
View File
@@ -9,11 +9,46 @@ import ToastHost from '@/components/ToastHost.vue'
import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue' import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue'
import UploadsPanel from '@/components/UploadsPanel.vue' import UploadsPanel from '@/components/UploadsPanel.vue'
import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue' import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
import AdminLayout from '@/components/AdminLayout.vue'
import { useBotStatus } from '@/composables/useBotStatus' import { useBotStatus } from '@/composables/useBotStatus'
import { useUploads } from '@/composables/useUploads' import { useUploads } from '@/composables/useUploads'
const { status, connect, selectGuild } = useBotStatus() const { status, connect, disconnect, selectGuild } = useBotStatus()
const { refresh } = useUploads() 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 libraryOpen = ref(false)
const selectedGuildId = ref<string | null>(null) const selectedGuildId = ref<string | null>(null)
@@ -60,13 +95,26 @@ async function changeGuild(guildId: string) {
await refresh() await refresh()
} }
onMounted(() => { onMounted(() => { void loadUser() })
connect()
refresh() watch(user, (value) => {
if (value && !(value.is_admin && onAdminRoute)) { connect(); void refresh() }
}) })
</script> </script>
<template> <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-1" />
<div class="background-glow bg-glow-2" /> <div class="background-glow bg-glow-2" />
@@ -75,7 +123,9 @@ onMounted(() => {
:status="status" :status="status"
:guilds="status?.guilds ?? []" :guilds="status?.guilds ?? []"
:selected-guild-id="selectedGuildId" :selected-guild-id="selectedGuildId"
:is-admin="user.is_admin"
@select-guild="changeGuild" @select-guild="changeGuild"
@logout="logout"
/> />
<main class="dashboard-grid"> <main class="dashboard-grid">
@@ -103,4 +153,9 @@ onMounted(() => {
/> />
<ToastHost /> <ToastHost />
</template>
</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); font-family: var(--font-sans);
} }
#app {
width: 100%;
min-width: 0;
}
body { body {
background-color: var(--bg-dark); background-color: var(--bg-dark);
color: var(--text-main); 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"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed } from 'vue'
import { RotateCw } from 'lucide-vue-next'
import type { BotStatus, GuildSettings } from '@/types/status' import type { BotStatus, GuildSettings } from '@/types/status'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
const props = defineProps<{ const props = defineProps<{
status: BotStatus | null status: BotStatus | null
guilds: GuildSettings[] guilds: GuildSettings[]
selectedGuildId: string | null 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) const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
@@ -25,42 +19,6 @@ const label = computed(() =>
: 'Rozłączony', : '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> </script>
<template> <template>
@@ -101,19 +59,10 @@ watch(
/> />
<span>{{ label }}</span> <span>{{ label }}</span>
</div> </div>
<button <div class="account-actions">
type="button" <a v-if="isAdmin" class="account-button account-button-admin" href="/admin/users">Panel admina</a>
class="btn-secondary btn-restart" <button class="account-button" type="button" @click="$emit('logout')">Wyloguj</button>
:disabled="isRestarting" </div>
:title="isRestarting ? 'Restartowanie bota...' : 'Zrestartuj bota'"
@click="handleRestart"
>
<RotateCw
:size="16"
:class="{ 'spin-icon': isRestarting }"
/>
<span>{{ isRestarting ? 'Restart...' : 'Restart' }}</span>
</button>
</div> </div>
</header> </header>
</template> </template>
@@ -125,6 +74,43 @@ watch(
gap: 16px; 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 { .btn-restart {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -166,6 +152,18 @@ watch(
align-items: flex-start; 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 { .btn-restart {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
+36
View File
@@ -1,16 +1,52 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
import logging import logging
import argparse
import getpass
import psycopg
from zyrarbot.application import Application from zyrarbot.application import Application
from zyrarbot.ui.api.auth import hash_password
from zyrarbot.storage.adapters.postgres.database import PostgresDatabase
def create_admin(username: str, password: str) -> None:
database = PostgresDatabase(
host=os.environ.get("POSTGRES_HOST", "localhost"),
port=int(os.environ.get("POSTGRES_PORT", "5432")),
database=os.environ.get("POSTGRES_DB", "zyrarbot"),
user=os.environ.get("POSTGRES_USER", "zyrarbot"),
password=os.environ.get("POSTGRES_PASSWORD", "zyrarbot"),
)
with database.connect() as connection:
connection.execute("INSERT INTO users(username,password_hash,is_admin) VALUES (%s,%s,TRUE)",
(username, hash_password(password)))
print(f"Utworzono administratora: {username}")
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="ŻyrarBOT")
subparsers = parser.add_subparsers(dest="command")
sub = subparsers.add_parser("create-admin", help="Utwórz konto administratora")
sub.add_argument("username")
sub.add_argument("--password")
args = parser.parse_args()
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
) )
if args.command == "create-admin":
password = args.password if args.password is not None else getpass.getpass("Hasło administratora: ")
if not password:
parser.error("Hasło nie może być puste")
try:
create_admin(args.username, password)
except psycopg.errors.UniqueViolation:
parser.error("Użytkownik o tej nazwie już istnieje")
return
asyncio.run(Application().run()) asyncio.run(Application().run())
+18
View File
@@ -23,6 +23,7 @@ from zyrarbot.storage.adapters.filesystem.uploads import UploadsRepository
from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence
from zyrarbot.storage.ports.persistence import Persistence from zyrarbot.storage.ports.persistence import Persistence
from zyrarbot.ui.api.factory import mount_api from zyrarbot.ui.api.factory import mount_api
from zyrarbot.ui.api.auth import AuthRepository
from zyrarbot.ui.bot.client import MusicBot from zyrarbot.ui.bot.client import MusicBot
from zyrarbot.ui.bot.guild_resolver import GuildResolver from zyrarbot.ui.bot.guild_resolver import GuildResolver
@@ -74,6 +75,18 @@ class Application:
self.web = FastAPI(title="ŻyrarBOT") self.web = FastAPI(title="ŻyrarBOT")
self.web.state.restart = self.schedule_restart self.web.state.restart = self.schedule_restart
self.web.state.persistence = self.persistence
self.web.state.auth = AuthRepository(self.persistence.database)
@self.web.middleware("http")
async def require_panel_login(request, call_next):
if request.url.path.startswith("/api/") and request.url.path not in {
"/api/health", "/api/auth/login"
}:
if self.web.state.auth.user_for_token(request.cookies.get("zyrarbot_session")) is None:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Wymagane logowanie"}, status_code=401)
return await call_next(request)
broadcaster = mount_api( broadcaster = mount_api(
self.web, self.web,
settings=self.settings, settings=self.settings,
@@ -111,6 +124,11 @@ class Application:
"""Serve the SPA for a direct link to a guild view.""" """Serve the SPA for a direct link to a guild view."""
return FileResponse(os.path.join(self.settings.static_dir, "index.html")) return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
@self.web.get("/admin/{route_path:path}", include_in_schema=False)
async def frontend_route(route_path: str) -> FileResponse:
"""Serve the SPA for nested frontend routes such as admin pages."""
return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
self.web.mount( self.web.mount(
"/", StaticFiles(directory=self.settings.static_dir, html=True), name="static" "/", StaticFiles(directory=self.settings.static_dir, html=True), name="static"
) )
@@ -0,0 +1,38 @@
from __future__ import annotations
import psycopg
class UsersMigration:
version = 2
name = "users_and_access"
def apply(self, connection: psycopg.Connection) -> None:
connection.execute("""
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
connection.execute("""
CREATE TABLE user_sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
)
""")
connection.execute("""
CREATE TABLE user_guild_access (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
guild_id BIGINT NOT NULL,
PRIMARY KEY (user_id, guild_id)
)
""")
def rollback(self, connection: psycopg.Connection) -> None:
connection.execute("DROP TABLE user_guild_access")
connection.execute("DROP TABLE user_sessions")
connection.execute("DROP TABLE users")
@@ -5,5 +5,6 @@ from importlib import import_module
InitialSchemaMigration = import_module( InitialSchemaMigration = import_module(
".0001_initial_schema", __name__ ".0001_initial_schema", __name__
).InitialSchemaMigration ).InitialSchemaMigration
UsersMigration = import_module(".0002_users", __name__).UsersMigration
MIGRATIONS = (InitialSchemaMigration(),) MIGRATIONS = (InitialSchemaMigration(), UsersMigration())
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, Request
from zyrarbot.storage.adapters.postgres.database import PostgresDatabase
SESSION_DAYS = 14
def hash_password(password: str, salt: bytes | None = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 310_000)
return f"{salt.hex()}:{digest.hex()}"
def verify_password(password: str, encoded: str) -> bool:
try:
salt, expected = encoded.split(":", 1)
actual = hash_password(password, bytes.fromhex(salt)).split(":", 1)[1]
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
class AuthRepository:
def __init__(self, database: PostgresDatabase):
self.database = database
def login(self, username: str, password: str) -> tuple[str, dict] | None:
with self.database.connect() as connection:
row = connection.execute(
"SELECT id, username, password_hash, is_admin FROM users WHERE username=%s",
(username,),
).fetchone()
if not row or not verify_password(password, row[2]):
return None
token = secrets.token_urlsafe(32)
connection.execute(
"INSERT INTO user_sessions(token_hash,user_id,expires_at) VALUES (%s,%s,%s)",
(hashlib.sha256(token.encode()).hexdigest(), row[0],
datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)),
)
return token, {"id": row[0], "username": row[1], "is_admin": row[3]}
def user_for_token(self, token: str | None) -> dict | None:
if not token:
return None
with self.database.connect() as connection:
row = connection.execute("""
SELECT u.id, u.username, u.is_admin FROM user_sessions s
JOIN users u ON u.id=s.user_id
WHERE s.token_hash=%s AND s.expires_at > CURRENT_TIMESTAMP
""", (hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
return {"id": row[0], "username": row[1], "is_admin": row[2]} if row else None
def logout(self, token: str | None) -> None:
if token:
with self.database.connect() as connection:
connection.execute("DELETE FROM user_sessions WHERE token_hash=%s",
(hashlib.sha256(token.encode()).hexdigest(),))
def guild_ids(self, user: dict, available: list[int]) -> list[int]:
if user["is_admin"]:
return available
with self.database.connect() as connection:
rows = connection.execute("SELECT guild_id FROM user_guild_access WHERE user_id=%s",
(user["id"],)).fetchall()
allowed = {row[0] for row in rows}
return [guild_id for guild_id in available if guild_id in allowed]
def current_user(request: Request) -> dict:
user = request.app.state.auth.user_for_token(request.cookies.get("zyrarbot_session"))
if user is None:
raise HTTPException(status_code=401, detail="Wymagane logowanie")
return user
def require_admin(request: Request) -> dict:
user = current_user(request)
if not user["is_admin"]:
raise HTTPException(status_code=403, detail="Wymagane uprawnienia administratora")
return user
+2 -2
View File
@@ -24,11 +24,11 @@ class ConnectionManager:
self._connections.pop(websocket, None) self._connections.pop(websocket, None)
async def broadcast( async def broadcast(
self, message: dict[str, Any] | Callable[[int | None], dict[str, Any]] self, message: dict[str, Any] | Callable[..., dict[str, Any]]
) -> None: ) -> None:
for connection, guild_id in list(self._connections.items()): for connection, guild_id in list(self._connections.items()):
try: try:
payload = message(guild_id) if callable(message) else message payload = message(connection, guild_id) if callable(message) else message
await connection.send_json(payload) await connection.send_json(payload)
except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy
self.disconnect(connection) self.disconnect(connection)
+9 -2
View File
@@ -59,9 +59,16 @@ def get_socket_status_service(websocket: WebSocket) -> StatusService:
def get_active_guild(request: Request, guild_id: int | None = Query(default=None)) -> discord.Guild: def get_active_guild(request: Request, guild_id: int | None = Query(default=None)) -> discord.Guild:
"""Zwraca serwer wskazany przez panel lub pierwszy znany serwer.""" """Zwraca serwer wskazany przez panel lub pierwszy znany serwer."""
guilds = get_guilds(request) guilds = get_guilds(request)
guild = next((item for item in guilds.guilds() if item.id == guild_id), None) user = request.app.state.auth.user_for_token(request.cookies.get("zyrarbot_session"))
permitted = request.app.state.auth.guild_ids(user, [item.id for item in guilds.guilds()]) if user else []
if guild_id is not None and guild_id not in permitted:
raise HTTPException(status_code=403, detail="Brak dostępu do tego serwera")
guild = next((item for item in guilds.guilds() if item.id == guild_id and item.id in permitted), None)
if guild_id is None: if guild_id is None:
guild = guilds.active_guild() active = guilds.active_guild()
guild = active if active and active.id in permitted else next(
(item for item in guilds.guilds() if item.id in permitted), None
)
if guild is None: if guild is None:
raise HTTPException(status_code=404, detail="Unknown Discord server") raise HTTPException(status_code=404, detail="Unknown Discord server")
return guild return guild
+2
View File
@@ -9,6 +9,7 @@ from zyrarbot.storage.ports.guild_config import GuildConfigRepository
from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.storage.ports.uploads import UploadsRepository
from zyrarbot.ui.api.connection_manager import ConnectionManager from zyrarbot.ui.api.connection_manager import ConnectionManager
from zyrarbot.ui.api.routers import ( from zyrarbot.ui.api.routers import (
auth_router,
playback_router, playback_router,
queue_router, queue_router,
status_router, status_router,
@@ -21,6 +22,7 @@ from zyrarbot.ui.api.status_service import StatusService
from zyrarbot.ui.bot.guild_resolver import GuildResolver from zyrarbot.ui.bot.guild_resolver import GuildResolver
ROUTERS = ( ROUTERS = (
auth_router.router,
status_router.router, status_router.router,
playback_router.router, playback_router.router,
queue_router.router, queue_router.router,
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from zyrarbot.ui.api.auth import current_user, require_admin
router = APIRouter(prefix="/api/auth", tags=["auth"])
class Credentials(BaseModel):
username: str = Field(min_length=1, max_length=80)
password: str = Field(min_length=1)
class UserCreate(Credentials):
guild_ids: list[int] = Field(default_factory=list)
class UserUpdate(BaseModel):
guild_ids: list[int]
class UserRoleUpdate(BaseModel):
is_admin: bool
@router.post("/login")
async def login(body: Credentials, request: Request, response: Response) -> dict:
result = request.app.state.auth.login(body.username, body.password)
if result is None:
raise HTTPException(status_code=401, detail="Nieprawidłowa nazwa użytkownika lub hasło")
token, user = result
response.set_cookie("zyrarbot_session", token, httponly=True, samesite="lax",
secure=request.url.scheme == "https", max_age=14 * 86400, path="/")
return user
@router.post("/logout")
async def logout(request: Request, response: Response) -> dict:
request.app.state.auth.logout(request.cookies.get("zyrarbot_session"))
response.delete_cookie("zyrarbot_session", path="/")
return {"status": "ok"}
@router.get("/me")
async def me(user: dict = Depends(current_user)) -> dict:
return user
def _available_guilds(request: Request) -> list[dict]:
return [{"guild_id": str(g.id), "guild_name": g.name} for g in request.app.state.guilds.guilds()]
@router.get("/users")
async def list_users(request: Request, _: dict = Depends(require_admin)) -> list[dict]:
with request.app.state.persistence.database.connect() as connection:
rows = connection.execute("SELECT id,username,is_admin FROM users ORDER BY username").fetchall()
assignments = connection.execute("SELECT user_id,guild_id FROM user_guild_access").fetchall()
by_user: dict[int, list[str]] = {}
for user_id, guild_id in assignments:
by_user.setdefault(user_id, []).append(str(guild_id))
return [{"id": row[0], "username": row[1], "is_admin": row[2],
"guild_ids": by_user.get(row[0], [])} for row in rows]
@router.get("/guilds")
async def list_guilds(request: Request, _: dict = Depends(require_admin)) -> list[dict]:
return _available_guilds(request)
@router.post("/users")
async def create_user(body: UserCreate, request: Request, _: dict = Depends(require_admin)) -> dict:
from zyrarbot.ui.api.auth import hash_password
available = {g.id for g in request.app.state.guilds.guilds()}
if not set(body.guild_ids) <= available:
raise HTTPException(status_code=400, detail="Nieznany serwer")
try:
with request.app.state.persistence.database.connect() as connection:
row = connection.execute("INSERT INTO users(username,password_hash) VALUES (%s,%s) RETURNING id",
(body.username, hash_password(body.password))).fetchone()
for guild_id in set(body.guild_ids):
connection.execute("INSERT INTO user_guild_access(user_id,guild_id) VALUES (%s,%s)",
(row[0], guild_id))
except Exception as error:
if getattr(error, "sqlstate", None) == "23505":
raise HTTPException(status_code=409, detail="Taka nazwa użytkownika już istnieje") from error
raise
return {"id": row[0], "username": body.username, "is_admin": False,
"guild_ids": [str(g) for g in set(body.guild_ids)]}
@router.put("/users/{user_id}/guilds")
async def update_access(user_id: int, body: UserUpdate, request: Request,
_: dict = Depends(require_admin)) -> dict:
available = {g.id for g in request.app.state.guilds.guilds()}
if not set(body.guild_ids) <= available:
raise HTTPException(status_code=400, detail="Nieznany serwer")
with request.app.state.persistence.database.connect() as connection:
exists = connection.execute("SELECT 1 FROM users WHERE id=%s AND is_admin=FALSE",
(user_id,)).fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Nie znaleziono użytkownika")
connection.execute("DELETE FROM user_guild_access WHERE user_id=%s", (user_id,))
for guild_id in set(body.guild_ids):
connection.execute("INSERT INTO user_guild_access(user_id,guild_id) VALUES (%s,%s)",
(user_id, guild_id))
return {"status": "ok"}
@router.put("/users/{user_id}/role")
async def update_role(user_id: int, body: UserRoleUpdate, request: Request,
admin: dict = Depends(require_admin)) -> dict:
if user_id == admin["id"] and not body.is_admin:
raise HTTPException(status_code=400, detail="Nie możesz odebrać uprawnień administratora własnemu kontu")
with request.app.state.persistence.database.connect() as connection:
row = connection.execute(
"UPDATE users SET is_admin=%s WHERE id=%s RETURNING id,username,is_admin",
(body.is_admin, user_id),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Nie znaleziono użytkownika")
return {"id": row[0], "username": row[1], "is_admin": row[2]}
+8 -2
View File
@@ -8,16 +8,22 @@ from fastapi import APIRouter, Depends, Request
from zyrarbot.music.music_player import MusicPlayer from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service
from zyrarbot.ui.api.status_service import StatusService from zyrarbot.ui.api.status_service import StatusService
from zyrarbot.ui.api.auth import current_user, require_admin
router = APIRouter(prefix="/api", tags=["status"]) router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status") @router.get("/status")
async def get_status( async def get_status(
request: Request,
user: dict = Depends(current_user),
guild: discord.Guild = Depends(get_active_guild), guild: discord.Guild = Depends(get_active_guild),
status: StatusService = Depends(get_status_service), status: StatusService = Depends(get_status_service),
) -> dict[str, Any]: ) -> dict[str, Any]:
return status.snapshot(guild.id) snapshot = status.snapshot(guild.id)
permitted = request.app.state.auth.guild_ids(user, [g.id for g in request.app.state.guilds.guilds()])
snapshot["guilds"] = [item for item in snapshot["guilds"] if int(item["guild_id"]) in permitted]
return snapshot
@router.get("/health") @router.get("/health")
@@ -26,7 +32,7 @@ async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any
@router.post("/restart") @router.post("/restart")
async def restart_bot(request: Request) -> dict[str, Any]: async def restart_bot(request: Request, _: dict = Depends(require_admin)) -> dict[str, Any]:
restart_action = getattr(request.app.state, "restart", None) restart_action = getattr(request.app.state, "restart", None)
if restart_action: if restart_action:
restart_action() restart_action()
+9 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import discord import discord
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from zyrarbot.music.music_player import MusicPlayer from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
@@ -16,6 +16,7 @@ from zyrarbot.ui.api.dependencies import (
from zyrarbot.ui.api.requests.connect_request import ConnectRequest from zyrarbot.ui.api.requests.connect_request import ConnectRequest
from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
from zyrarbot.ui.api.auth import current_user
from zyrarbot.ui.bot.guild_resolver import GuildResolver from zyrarbot.ui.bot.guild_resolver import GuildResolver
router = APIRouter(prefix="/api", tags=["voice"]) router = APIRouter(prefix="/api", tags=["voice"])
@@ -50,6 +51,8 @@ async def set_notification_channel(
@router.post("/connect") @router.post("/connect")
async def connect( async def connect(
request: ConnectRequest, request: ConnectRequest,
http_request: Request,
user: dict = Depends(current_user),
guilds: GuildResolver = Depends(get_guilds), guilds: GuildResolver = Depends(get_guilds),
player: MusicPlayer = Depends(get_player), player: MusicPlayer = Depends(get_player),
broadcaster: StatusBroadcaster = Depends(get_broadcaster), broadcaster: StatusBroadcaster = Depends(get_broadcaster),
@@ -62,6 +65,11 @@ async def connect(
channel = guilds.channel(channel_id) channel = guilds.channel(channel_id)
if not isinstance(channel, discord.VoiceChannel): if not isinstance(channel, discord.VoiceChannel):
raise HTTPException(status_code=404, detail="Voice channel not found") raise HTTPException(status_code=404, detail="Voice channel not found")
allowed = http_request.app.state.auth.guild_ids(
user, [item.id for item in guilds.guilds()]
)
if channel.guild.id not in allowed:
raise HTTPException(status_code=403, detail="Brak dostępu do tego serwera")
if channel.guild.me and not channel.permissions_for(channel.guild.me).connect: if channel.guild.me and not channel.permissions_for(channel.guild.me).connect:
raise HTTPException( raise HTTPException(
+14 -1
View File
@@ -20,9 +20,22 @@ async def websocket_endpoint(
status: StatusService = Depends(get_socket_status_service), status: StatusService = Depends(get_socket_status_service),
guild_id: int | None = Query(default=None), guild_id: int | None = Query(default=None),
) -> None: ) -> None:
user = websocket.app.state.auth.user_for_token(websocket.cookies.get("zyrarbot_session"))
if user is None:
await websocket.close(code=4401)
return
available = [g.id for g in websocket.app.state.guilds.guilds()]
permitted = websocket.app.state.auth.guild_ids(user, available)
if guild_id is not None and guild_id not in permitted:
await websocket.close(code=4403)
return
if guild_id is None:
guild_id = permitted[0] if permitted else None
await connections.connect(websocket, guild_id) await connections.connect(websocket, guild_id)
try: try:
await websocket.send_json(status.snapshot(guild_id)) initial = status.snapshot(guild_id)
initial["guilds"] = [item for item in initial["guilds"] if int(item["guild_id"]) in permitted]
await websocket.send_json(initial)
while True: while True:
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie. # Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
await websocket.receive_text() await websocket.receive_text()
+12 -1
View File
@@ -24,10 +24,21 @@ class StatusBroadcaster:
if not self._connections.has_listeners: if not self._connections.has_listeners:
return return
try: try:
await self._connections.broadcast(self._status.snapshot) await self._connections.broadcast(self._snapshot_for_connection)
except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli
logger.error("Błąd rozgłaszania stanu: %s", error) logger.error("Błąd rozgłaszania stanu: %s", error)
def _snapshot_for_connection(self, connection, guild_id: int | None) -> dict:
payload = self._status.snapshot(guild_id)
user = connection.app.state.auth.user_for_token(
connection.cookies.get("zyrarbot_session")
)
allowed = connection.app.state.auth.guild_ids(
user, [guild.id for guild in connection.app.state.guilds.guilds()]
) if user else []
payload["guilds"] = [item for item in payload["guilds"] if int(item["guild_id"]) in allowed]
return payload
def start(self) -> None: def start(self) -> None:
if self._task is None or self._task.done(): if self._task is None or self._task.done():
self._task = asyncio.create_task(self._loop()) self._task = asyncio.create_task(self._loop())