feat: admin functionality modal

This commit is contained in:
ae 2025-05-06 12:08:20 +03:00
parent 6b554cf90b
commit 1c1049fbf4
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
9 changed files with 236 additions and 68 deletions

View File

@ -294,11 +294,11 @@
}
.sidebar-button {
@apply flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--light-accent)] bg-transparent p-0 text-[var(--light-text)] hover:bg-[var(--light-background)]/80;
@apply flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--light-accent)] bg-transparent p-0 text-[var(--light-text)] enabled:hover:bg-[var(--light-background)]/80 disabled:cursor-not-allowed;
}
.dark .sidebar-button {
@apply border-[var(--dark-accent)]/50 text-[var(--dark-text)] hover:bg-[var(--dark-background)]/80;
@apply border-[var(--dark-accent)]/50 text-[var(--dark-text)] enabled:hover:bg-[var(--dark-background)]/80;
}
.sidebar {
@ -383,11 +383,11 @@
@apply text-[var(--dark-text)]/60;
}
.sidebar-list {
.sidebar-list-item {
@apply flex cursor-pointer flex-col rounded-lg border border-[var(--light-text)]/10 bg-transparent px-3 py-2 text-sm text-[var(--light-text)] hover:bg-[var(--light-background)]/50;
}
.dark .sidebar-list {
.dark .sidebar-list-item {
@apply border-[var(--dark-text)]/10 bg-transparent text-[var(--dark-text)] hover:bg-[var(--dark-background)]/80;
}
@ -408,11 +408,11 @@
}
.sidebar-list-item-delete-button {
@apply ml-1 h-5.5 w-5.5 flex-shrink-0 border-[var(--light-accent)]/40 hover:bg-[var(--light-accent)]/20;
@apply ml-1 h-5.5 w-5.5 flex-shrink-0 border-[var(--light-accent)]/40 enabled:hover:bg-[var(--light-accent)]/20;
}
.dark .sidebar-list-item-delete-button {
@apply border-[var(--dark-accent)]/40 hover:bg-[var(--dark-accent)]/20;
@apply border-[var(--dark-accent)]/40 enabled:hover:bg-[var(--dark-accent)]/20;
}
.sidebar-list-item-metadata {
@ -943,14 +943,30 @@
}
.modal-table-row-item {
@apply overflow-x-auto px-4 py-3 text-wrap;
@apply overflow-x-auto px-4 py-3 font-light text-wrap;
}
.modal-table-head {
@apply text-left font-semibold text-[var(--light-text)]/80;
@apply text-left font-medium text-[var(--light-text)]/80;
}
.dark .modal-table-head {
@apply text-[var(--dark-text)]/80;
}
.modal-list-item {
@apply flex w-[90%] flex-col rounded-lg border border-[var(--light-text)]/10 bg-transparent px-3 py-2 text-sm text-[var(--light-text)] hover:bg-[var(--light-foreground)]/50;
}
.dark .modal-list-item {
@apply border-[var(--dark-text)]/10 bg-transparent text-[var(--dark-text)] hover:bg-[var(--dark-foreground)]/80;
}
.modal-list-item-metadata {
@apply mt-1 truncate text-xs text-[var(--light-text)]/40;
}
.dark .modal-list-item-metadata {
@apply text-[var(--dark-text)]/40;
}
}

View File

@ -0,0 +1,142 @@
<script lang="ts">
import { currentUser } from "$lib/logic/client"
import Close from "$lib/icons/Close.svelte"
import { onMount } from "svelte"
import type { User } from "$lib/logic/model"
import Delete from "$lib/icons/sidebar/Delete.svelte"
import { formatDateShort } from "$lib/util/contentVisual"
// props
export let onClose: () => void
export let adminDeleteUser: (userID: string) => Promise<void>
export let users: User[] | null = null
let modalContent: HTMLDivElement | null = null
let previouslyFocusedElement: HTMLElement | null = null
onMount(() => {
// store the element that had focus before opening the modal
previouslyFocusedElement = document.activeElement as HTMLElement
// focus the first focusable element in the modal
if (modalContent !== null) {
const focusableElements = (modalContent as HTMLDivElement).querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusableElements.length > 0) {
;(focusableElements[0] as HTMLElement).focus()
} else {
;(modalContent as HTMLDivElement).focus()
}
}
// when component unmounts, restore focus
return () => {
if (previouslyFocusedElement) {
previouslyFocusedElement.focus()
}
}
})
const handleDeleteUser = (event: MouseEvent, userID: string, username: string) => {
event.stopPropagation()
if (confirm(`Are you sure you want to delete user '${username}'`)) {
adminDeleteUser(userID)
}
}
const handleClickOutside = (event: MouseEvent) => {
// close the modal if user clicks outside of it
const target = event.target as HTMLElement
if (target.classList.contains("modal-backdrop")) {
onClose()
}
}
const handleModalContentKeydown = (_event: KeyboardEvent) => {
// accessibility compliance handler, actual handling is done by the global keydown handler
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose()
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<!-- TODO: user result pagination (if needed) -->
<div
class="modal-backdrop"
on:click={handleClickOutside}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={modalContent}
class="modal-content max-w-xl"
on:click|stopPropagation
on:keydown={handleModalContentKeydown}
role="document"
tabindex="-1"
>
<!-- Header (title + close button) -->
<div class="modal-section flex items-center justify-between" role="heading" aria-level="1">
<h2 class="text-xl font-bold">Administration</h2>
<button on:click={onClose} class="sidebar-button" aria-label="Close settings">
<Close />
</button>
</div>
<!-- Account information -->
<div class="modal-section border-b-0">
<h3 class="modal-section-title">Active users</h3>
<div class="flex flex-col justify-center">
{#if users !== null && users.length > 0}
<ul class="flex flex-col items-center space-y-1" role="listbox">
{#each users as user}
<li class="modal-list-item">
<div class="flex w-full items-center justify-between">
<h3 class="modal-list-item-title">{user.username}</h3>
<!-- Delete button -->
{#if $currentUser !== null && user.id === $currentUser.id}
<button
disabled
class="sidebar-button sidebar-list-item-delete-button"
aria-label="Delete note"
>
<Delete classString="h-3 w-3" />
</button>
{:else}
<button
on:click={(e) => handleDeleteUser(e, user.id, user.username)}
class="sidebar-button sidebar-list-item-delete-button"
aria-label="Delete note"
>
<Delete classString="h-3 w-3" />
</button>
{/if}
</div>
<p class="modal-list-item-metadata">{user.id}</p>
<p class="modal-list-item-metadata">
Created: {formatDateShort(user.createdAt)}, Updated: {formatDateShort(
user.updatedAt
)}
</p>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
</div>

View File

@ -191,7 +191,7 @@
</h1>
{/if}
<!-- TODO: handle versions pagination support later if it becomes an issue (`util/itemPagination.ts`) -->
<!-- TODO: note version metadata pagination (if needed) -->
<!-- Note action buttons -->
<div class="note-action-container">

View File

@ -11,11 +11,13 @@
availableNotes,
versionHistory,
cError,
cSuccess
cSuccess,
availableUsers
} from "$lib/logic/client"
import Close from "$lib/icons/Close.svelte"
import { generateGreeting } from "$lib/util/greetMessage"
import { hashContent } from "$lib/util/contentValidation"
import AdminModal from "./AdminModal.svelte"
// error/success notification display durations
const ERR_NOTIFICATION_DUR = 8 * 1000 // 8 s.
@ -23,7 +25,8 @@
// local state
let isComponentReady = false
let showSettings = false
let isSettingsVisible = false
let isAdminViewVisible = false
let isSidebarOpen = window.innerWidth > 768
let isSidebarUserMenuOpen = false
let isVersionDropdownOpen = false
@ -46,7 +49,9 @@
return
}
await adminLoadUsers() // will do nothing if the user isn't an admin
await loadNotes()
username = $currentUser.username
greetMessage = generateGreeting(
// capitalization for the message
@ -104,6 +109,16 @@
keysPressed.clear()
}
const adminLoadUsers = async () => {
if ($currentUser !== null && $currentUser.isAdmin) {
const users = await apiClient.adminListAll()
if (users) {
availableUsers.set(users)
}
}
}
const loadNotes = async () => {
const notes = await apiClient.listNotes()
@ -114,12 +129,12 @@
const toggleSettingsModal = () => {
isSidebarUserMenuOpen = false
showSettings = !showSettings
isSettingsVisible = !isSettingsVisible
}
const toggleAdminModal = () => {
isSidebarUserMenuOpen = false
console.log("[DBG] Admin view is not implemented yet")
isAdminViewVisible = !isAdminViewVisible
}
const toggleWebhookModal = () => {
@ -127,11 +142,6 @@
console.log("[DBG] Webhooks aren't implemented yet")
}
const toggleTagModal = () => {
isSidebarUserMenuOpen = false
console.log("[DBG] Tags aren't implemented yet")
}
const createNewNote = async () => {
const newNote = await apiClient.createNote()
@ -221,6 +231,15 @@
}
}
const adminDeleteUser = async (userID: string) => {
await apiClient.adminDeleteUser(userID)
// refresh the list due to updates being pushed to server
await adminLoadUsers()
$cSuccess = "User deleted successfully."
}
const deleteNote = async (noteID: string) => {
await apiClient.deleteNote(noteID)
@ -321,7 +340,6 @@
{toggleSettingsModal}
{toggleAdminModal}
{toggleWebhookModal}
{toggleTagModal}
{logout}
{createNewNote}
{selectNote}
@ -358,8 +376,13 @@
</div>
<!-- Settings Modal -->
{#if showSettings}
{#if isSettingsVisible}
<SettingsModal onClose={toggleSettingsModal} />
{/if}
<!-- Admin view -->
{#if isAdminViewVisible}
<AdminModal users={$availableUsers} {adminDeleteUser} onClose={toggleAdminModal} />
{/if}
</div>
{/if}

View File

@ -8,8 +8,6 @@
// props
export let onClose: () => void
const userData = $currentUser
let currentPassword = ""
let newPassword = ""
let confirmPassword = ""
@ -113,7 +111,7 @@
}
}
const handleModalContentKeydown = (event: KeyboardEvent) => {
const handleModalContentKeydown = (_event: KeyboardEvent) => {
// accessibility compliance handler, actual handling is done by the global keydown handler
}
@ -137,7 +135,7 @@
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={modalContent}
class="modal-content max-w-md"
class="modal-content max-w-xl"
on:click|stopPropagation
on:keydown={handleModalContentKeydown}
role="document"
@ -158,24 +156,28 @@
<tbody>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Username</th>
<td class="modal-table-row-item">{userData?.username}</td>
<td class="modal-table-row-item">{$currentUser?.username}</td>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Account created</th>
<td class="modal-table-row-item"
>{userData?.createdAt !== undefined ? formatDateLong(userData?.createdAt) : ""}</td
>{$currentUser?.createdAt !== undefined
? formatDateLong($currentUser?.createdAt)
: ""}</td
>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Password updated</th>
<td class="modal-table-row-item"
>{userData?.createdAt !== undefined ? formatDateLong(userData?.updatedAt) : ""}</td
>{$currentUser?.createdAt !== undefined
? formatDateLong($currentUser?.updatedAt)
: ""}</td
>
</tr>
{#if userData?.isAdmin}
{#if $currentUser?.isAdmin}
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Admin permissions</th>
<td class="modal-table-row-item">{userData?.isAdmin}</td>
<td class="modal-table-row-item">{$currentUser?.isAdmin}</td>
</tr>
{/if}
</tbody>
@ -233,7 +235,7 @@
</div>
<!-- "Danger zone" -->
<div class="modal-section">
<div class="modal-section border-b-0">
<h3 class="modal-section-title text-red-500">Danger Zone</h3>
{#if deleteUserModalError}

View File

@ -6,7 +6,6 @@
import User from "$lib/icons/sidebar/User.svelte"
import ChevronDown from "$lib/icons/ChevronDown.svelte"
import ChevronUp from "$lib/icons/ChevronUp.svelte"
import Tag from "$lib/icons/sidebar/Tag.svelte"
import WebhookTruck from "$lib/icons/sidebar/WebhookTruck.svelte"
import Search from "$lib/icons/sidebar/Search.svelte"
import SettingsGear from "$lib/icons/sidebar/SettingsGear.svelte"
@ -25,7 +24,6 @@
export let toggleSettingsModal: () => void
export let toggleAdminModal: () => void
export let toggleWebhookModal: () => void
export let toggleTagModal: () => void
export let logout: () => Promise<void>
export let createNewNote: () => Promise<void>
export let selectNote: (
@ -66,7 +64,7 @@
: notes
</script>
<!-- TODO: handle pagination support later if it becomes an issue (`util/itemPagination.ts`) -->
<!-- TODO: note metadata pagination (if needed) -->
<!-- Outmost sidebar container with collapsible functionality -->
<div
@ -98,14 +96,6 @@
<button on:click={toggleWebhookModal} class="sidebar-button" aria-label="View webhooks">
<WebhookTruck classString="h-5 w-5" />
</button>
<button
on:click={toggleTagModal}
class="sidebar-button"
aria-label="View tags & expiration"
>
<Tag classString="h-5 w-5" />
</button>
</div>
{/if}
@ -129,7 +119,14 @@
<!-- Header with app logo and display name -->
<div class="sidebar-header-container">
<!-- svelte-ignore a11y_invalid_attribute -->
<a href="#" class="sidebar-header-link" on:click={() => selectNote("", false, true)}>
<a
href="#"
class="sidebar-header-link"
on:click={() => {
selectNote("", false, true)
toggleSidebar()
}}
>
<img src="favicon.svg" alt="Logo" class="sidebar-header-logo" />
<span class="sidebar-header-text">QueNote</span>
</a>
@ -148,11 +145,6 @@
<WebhookTruck classString="mr-3 h-5 w-5" />
Webhooks
</button>
<button on:click={toggleTagModal} class="sidebar-action-button">
<Tag classString="mr-3 h-5 w-5" />
Note tags
</button>
</div>
<div class="relative flex flex-col px-2 pb-3">
@ -174,7 +166,7 @@
<ul class="space-y-1" role="listbox">
{#each filteredNotes as note}
<li
class="sidebar-list"
class="sidebar-list-item"
class:sidebar-list-active={currentNote && note.id === currentNote.id}
on:click={() => selectNote(note.id, false, false)}
on:keydown={(e) => handleNoteKeydown(e, note.id)}

View File

@ -1,18 +0,0 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M7.0498 7.0498H7.0598M10.5118 3H7.8C6.11984 3 5.27976 3 4.63803 3.32698C4.07354 3.6146 3.6146 4.07354 3.32698 4.63803C3 5.27976 3 6.11984 3 7.8V10.5118C3 11.2455 3 11.6124 3.08289 11.9577C3.15638 12.2638 3.27759 12.5564 3.44208 12.8249C3.6276 13.1276 3.88703 13.387 4.40589 13.9059L9.10589 18.6059C10.2939 19.7939 10.888 20.388 11.5729 20.6105C12.1755 20.8063 12.8245 20.8063 13.4271 20.6105C14.112 20.388 14.7061 19.7939 15.8941 18.6059L18.6059 15.8941C19.7939 14.7061 20.388 14.112 20.6105 13.4271C20.8063 12.8245 20.8063 12.1755 20.6105 11.5729C20.388 10.888 19.7939 10.2939 18.6059 9.10589L13.9059 4.40589C13.387 3.88703 13.1276 3.6276 12.8249 3.44208C12.5564 3.27759 12.2638 3.15638 11.9577 3.08289C11.6124 3 11.2455 3 10.5118 3ZM7.5498 7.0498C7.5498 7.32595 7.32595 7.5498 7.0498 7.5498C6.77366 7.5498 6.5498 7.32595 6.5498 7.0498C6.5498 6.77366 6.77366 6.5498 7.0498 6.5498C7.32595 6.5498 7.5498 6.77366 7.5498 7.0498Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View File

@ -32,6 +32,7 @@ const COOKIE_SECURE = import.meta.env.PROD ? true : false
export const currentUser: Writable<User | null> = writable(null)
export const currentFullNote: Writable<FullNote | null> = writable(null)
export const availableNotes: Writable<NoteMetadata[] | null> = writable(null)
export const availableUsers: Writable<User[] | null> = writable(null)
export const versionHistory: Writable<VersionMetadata[] | null> = writable(null)
export const accessToken: Writable<string | null> = writable(null)
export const csrfToken: Writable<string | null> = writable(null)
@ -441,7 +442,13 @@ class ApiClient {
}
})
const users = await this.handleResponse<User[]>(response, { useBearerAuth: true })
let users: User[] = []
const data = await this.handleResponse<ApiUserResponse[]>(response, { useBearerAuth: true })
if (users) {
users = User.fromApiResponseArray(data)
}
console.log(`[ADMIN] Got ${users.length} user results`)
return users
@ -493,7 +500,7 @@ class ApiClient {
})
let notes: NoteMetadata[] = []
let data = await this.handleResponse<ApiNoteMetadataResponse[]>(response, {
const data = await this.handleResponse<ApiNoteMetadataResponse[]>(response, {
useBearerAuth: true
})

View File

@ -20,6 +20,10 @@ export class User {
this.createdAt = new Date(apiResponse.created_at)
this.updatedAt = new Date(apiResponse.updated_at)
}
static fromApiResponseArray(apiResponses: ApiUserResponse[]): User[] {
return apiResponses.map((res) => new User(res))
}
}
export interface ApiFullNoteResponse {