From e22761946ef7f7bf4d257b189392999d0481df09 Mon Sep 17 00:00:00 2001 From: goosenotduck Date: Sat, 1 Aug 2026 21:10:29 -0400 Subject: [PATCH 01/20] Move checkbox to front of table --- web/src/components/DynamicTable.vue | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/components/DynamicTable.vue b/web/src/components/DynamicTable.vue index ef5aeda..f4e1a6b 100644 --- a/web/src/components/DynamicTable.vue +++ b/web/src/components/DynamicTable.vue @@ -76,17 +76,26 @@ async function deleteRow(row: T) { + - + + @@ -108,15 +117,6 @@ async function deleteRow(row: T) { - -
{{ col }}
+ + {{ row[col] }} - -
From 2e82cbae5898a7a6996ac9f60160c0d3f8dfae97 Mon Sep 17 00:00:00 2001 From: goosenotduck Date: Sat, 1 Aug 2026 21:38:52 -0400 Subject: [PATCH 02/20] Add resources to backend --- database/db.go | 149 +++++++++++++++ database/migrations/0003_resources.up.sql | 14 ++ models/resources.go | 8 + routes/api/resources/resources.go | 212 ++++++++++++++++++++++ routes/routes.go | 2 + 5 files changed, 385 insertions(+) create mode 100644 database/migrations/0003_resources.up.sql create mode 100644 models/resources.go create mode 100644 routes/api/resources/resources.go diff --git a/database/db.go b/database/db.go index 6b566af..42515a5 100644 --- a/database/db.go +++ b/database/db.go @@ -562,3 +562,152 @@ func (database *DatabaseHelper) GetTrainingAnswers(trainingID int) (map[int]stri return answers, nil } + +func (database *DatabaseHelper) GetResource(rID int) (models.Resource, error) { + row := database.DB.QueryRow("SELECT id, name, description, url FROM resources WHERE id = $1", rID) + + var resource models.Resource + + err := row.Scan( + &resource.ID, + &resource.Name, + &resource.Description, + &resource.URL, + ) + + if err != nil { + return models.Resource{}, err + } + + return resource, nil +} + +func (database *DatabaseHelper) GetAllResources() ([]models.Resource, error) { + rows, err := database.DB.Query("SELECT id, name, description, url FROM resources") + if err != nil { + return []models.Resource{}, nil + } + + var resources []models.Resource = []models.Resource{} + + for rows.Next() { + var r models.Resource + + err := rows.Scan( + &r.ID, + &r.Name, + &r.Description, + &r.URL, + ) + + if err != nil { + return []models.Resource{}, nil + } + + resources = append(resources, r) + } + + return resources, nil +} + +func (database *DatabaseHelper) GetAreaResources(area int) ([]models.Resource, error) { + rows, err := database.DB.Query("SELECT resource_id FROM area_resources WHERE area_id = $1", area) + if err != nil { + return []models.Resource{}, nil + } + + var resources []models.Resource = []models.Resource{} + + for rows.Next() { + var resourceID int + + err := rows.Scan( + &resourceID, + ) + + if err != nil { + return []models.Resource{}, err + } + + // fetch this training, add to array + resource, err := database.GetResource(resourceID) + if err != nil { + return []models.Resource{}, err + } + + resources = append(resources, resource) + } + + return resources, nil +} + +func (database *DatabaseHelper) CreateResource(r models.Resource) error { + _, err := database.DB.Exec( + "INSERT INTO resources (name, description, url) VALUES ($1, $2, $3)", + r.Name, + r.Description, + r.URL, + ) + + if err != nil { + return err + } + + return nil +} + +func (database *DatabaseHelper) UpdateResource(r models.Resource, id int) error { + _, err := database.DB.Exec( + "UPDATE resources SET name = $1, description = $2, url = $3 WHERE id = $4", + r.Name, + r.Description, + r.URL, + id, + ) + + if err != nil { + return err + } + + return nil +} + +func (database *DatabaseHelper) DeleteResource(rID int) error { + _, err := database.DB.Exec( + "DELETE FROM resources WHERE id = $1", + rID, + ) + + if err != nil { + return err + } + + return nil +} + +func (database *DatabaseHelper) AddResourceToArea(areaID int, resourceIDs []int) error { + for _, resourceID := range resourceIDs { + _, err := database.DB.Exec( + "INSERT INTO area_resources (area_id, resource_id) VALUES ($1, $2)", + areaID, resourceID, + ) + + if err != nil { + return err + } + } + + return nil +} + +func (database *DatabaseHelper) RemoveResourceFromArea(areaID int, resourceID int) error { + _, err := database.DB.Exec( + "DELETE FROM area_resources WHERE area_id = $1 AND resource_id = $2", + areaID, resourceID, + ) + + if err != nil { + return err + } + return nil +} diff --git a/database/migrations/0003_resources.up.sql b/database/migrations/0003_resources.up.sql new file mode 100644 index 0000000..c6c78ba --- /dev/null +++ b/database/migrations/0003_resources.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE resources ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + url TEXT NOT NULL +); + +CREATE TABLE area_resources ( + area_id INTEGER NOT NULL, + resource_id INTEGER NOT NULL, + PRIMARY KEY (area_id, resource_id), + FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE CASCADE, + FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/models/resources.go b/models/resources.go new file mode 100644 index 0000000..686333d --- /dev/null +++ b/models/resources.go @@ -0,0 +1,8 @@ +package models + +type Resource struct { + ID int `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` +} diff --git a/routes/api/resources/resources.go b/routes/api/resources/resources.go new file mode 100644 index 0000000..df1050f --- /dev/null +++ b/routes/api/resources/resources.go @@ -0,0 +1,212 @@ +package resources + +import ( + "database/sql" + "errors" + "makedotcsh/database" + "makedotcsh/middleware" + "makedotcsh/models" + "strconv" + + "github.com/gin-gonic/gin" +) + +type RemoveResourceRequest struct { + ID int `json:"id"` +} + +// GetAllTrainings godoc +// +// @Summary Get all trainings +// @Description Returns all trainings +// @Tags trainings +// @Produce json +// @Success 200 {array} models.Training +// @Failure 500 {object} models.ErrorResponse +// @Router /trainings [get] +func getAllResources(c *gin.Context) { + rs, err := database.Helper.GetAllResources() + if err != nil { + c.Error(err) + return + } + + c.JSON(200, rs) +} + +// GetTraining godoc +// +// @Summary Get training +// @Description Returns a training by ID +// @Tags trainings +// @Produce json +// @Param id path int true "Training ID" +// @Success 200 {object} models.Training +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /trainings/{id} [get] +func getResource(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + + r, err := database.Helper.GetResource(id) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(404, gin.H{"error": "resource not found"}) + return + } + + c.Error(err) + return + } + + c.JSON(200, r) +} + +// CreateTraining godoc +// +// @Summary Create training +// @Description Create a new training +// @Tags trainings +// @Accept json +// @Param training body models.CreateTrainingRequest true "Training" +// @Success 201 +// @Failure 400 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /trainings [post] +func createResource(c *gin.Context) { + var req models.Resource + + err := c.ShouldBindBodyWithJSON(&req) + if err != nil { + c.JSON(400, gin.H{ + "error": err.Error(), + }) + return + } + + err = database.Helper.CreateResource(req) + if err != nil { + c.Error(err) + return + } + + c.Status(201) +} + +func updateResource(c *gin.Context) { + var req models.Resource + + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(400, gin.H{ + "error": err.Error(), + }) + return + } + + err = c.ShouldBindBodyWithJSON(&req) + if err != nil { + c.JSON(400, gin.H{ + "error": err.Error(), + }) + return + } + + err = database.Helper.UpdateResource(req, id) + if err != nil { + c.Error(err) + return + } + + c.Status(201) +} + +func deleteResource(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(400, gin.H{ + "error": err.Error(), + }) + return + } + + err = database.Helper.DeleteResource(id) + if err != nil { + c.Error(err) + return + } + + c.Status(204) +} + +func getAreaResources(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(400, gin.H{ + "error": err.Error(), + }) + return + } + + resources, err := database.Helper.GetAreaResources(id) + if err != nil { + c.Error(err) + return + } + + c.JSON(200, resources) +} + +func addResourceToArea(c *gin.Context) { + var req []int + id, err := strconv.Atoi(c.Param("id")) + err = c.ShouldBindBodyWithJSON(&req) + if err != nil { + c.JSON(400, gin.H{ + "error": err.Error(), + }) + return + } + + err = database.Helper.AddResourceToArea(id, req) + if err != nil { + c.Error(err) + return + } +} + +func removeResourceFromArea(c *gin.Context) { + var req RemoveResourceRequest + id, err := strconv.Atoi(c.Param("id")) + err = c.ShouldBindBodyWithJSON(&req) + if err != nil { + c.JSON(400, gin.H{ + "error": err.Error(), + }) + return + } + + err = database.Helper.RemoveResourceFromArea(id, req.ID) + if err != nil { + c.Error(err) + return + } + + c.Status(201) +} + +func Routes(route *gin.RouterGroup) { + resources := route.Group("/resources") + resources.GET("/", getAllResources) + resources.GET("/:id", getResource) + resources.GET("/area/:id", getAreaResources) + + resources.PUT("/:id", middleware.RequireGroup("eboard"), updateResource) + + resources.POST("/", middleware.RequireGroup("eboard"), createResource) + resources.POST("/area/:id", middleware.RequireGroup("eboard"), addResourceToArea) + + resources.DELETE("/area/:id", middleware.RequireGroup("eboard"), removeResourceFromArea) + resources.DELETE("/:id", middleware.RequireGroup("eboard"), deleteResource) +} diff --git a/routes/routes.go b/routes/routes.go index ba29fb1..0c2dd82 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -4,6 +4,7 @@ import ( "makedotcsh/routes/api/areas" "makedotcsh/routes/api/me" "makedotcsh/routes/api/members" + "makedotcsh/routes/api/resources" "makedotcsh/routes/api/trainings" "makedotcsh/routes/api/user" @@ -34,5 +35,6 @@ func SetRoutes(router *gin.Engine, auth csh_auth.Auth) { trainings.Routes(api) user.Routes(api) members.Routes(api) + resources.Routes(api) } From 08b3248ecd597ec23758fd503221a093403d311f Mon Sep 17 00:00:00 2001 From: goosenotduck Date: Sat, 1 Aug 2026 21:39:03 -0400 Subject: [PATCH 03/20] Add resource management to frontend --- web/src/components/AddResourcePopup.vue | 111 ++++++++++++++++++ web/src/components/AddTrainingPopup.vue | 1 - web/src/models/resources.ts | 6 + web/src/router/index.ts | 15 +++ web/src/views/AreaView.vue | 17 +-- web/src/views/admin/AdminDashboardView.vue | 17 +++ web/src/views/admin/areas/AreaEditView.vue | 46 +++++++- .../admin/resources/ResourceCreateView.vue | 56 +++++++++ .../admin/resources/ResourceEditView.vue | 83 +++++++++++++ .../admin/resources/ResourceListView.vue | 73 ++++++++++++ 10 files changed, 414 insertions(+), 11 deletions(-) create mode 100644 web/src/components/AddResourcePopup.vue create mode 100644 web/src/models/resources.ts create mode 100644 web/src/views/admin/resources/ResourceCreateView.vue create mode 100644 web/src/views/admin/resources/ResourceEditView.vue create mode 100644 web/src/views/admin/resources/ResourceListView.vue diff --git a/web/src/components/AddResourcePopup.vue b/web/src/components/AddResourcePopup.vue new file mode 100644 index 0000000..570d7fc --- /dev/null +++ b/web/src/components/AddResourcePopup.vue @@ -0,0 +1,111 @@ + + + + diff --git a/web/src/components/AddTrainingPopup.vue b/web/src/components/AddTrainingPopup.vue index dfb127a..373c8ee 100644 --- a/web/src/components/AddTrainingPopup.vue +++ b/web/src/components/AddTrainingPopup.vue @@ -93,7 +93,6 @@ async function save() {

{{ error }}

diff --git a/web/src/models/resources.ts b/web/src/models/resources.ts new file mode 100644 index 0000000..479df7a --- /dev/null +++ b/web/src/models/resources.ts @@ -0,0 +1,6 @@ +export interface Resource { + id: number + name: string + description: string + url: string +} diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 28945fe..dd869cd 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -56,6 +56,21 @@ const router = createRouter({ component: () => import('@/views/admin/user/UserTrainingsListView.vue'), meta: { requiresAdmin: true }, }, + { + path: '/admin/resources/', + component: () => import('@/views/admin/resources/ResourceListView.vue'), + meta: { requiresAdmin: true }, + }, + { + path: '/admin/resources/:id', + component: () => import('@/views/admin/resources/ResourceEditView.vue'), + meta: { requiresAdmin: true }, + }, + { + path: '/admin/resources/create', + component: () => import('@/views/admin/resources/ResourceCreateView.vue'), + meta: { requiresAdmin: true }, + }, { path: '/:pathMatch(.*)*', component: () => import('@/views/NotFoundView.vue'), diff --git a/web/src/views/AreaView.vue b/web/src/views/AreaView.vue index e71592b..1a55706 100644 --- a/web/src/views/AreaView.vue +++ b/web/src/views/AreaView.vue @@ -3,6 +3,7 @@ import { authState } from '@/auth' import LoadingScreen from '@/components/LoadingScreen.vue' import type { Area } from '@/models/areas' import type { Training, UserTraining } from '@/models/trainings' +import type { Resource } from '@/models/resources' import { ref, onMounted, computed } from 'vue' import { useRoute } from 'vue-router' @@ -11,6 +12,7 @@ const user = authState.user const area = ref(null) const trainings = ref(null) const userTrainings = ref(null) +const resources = ref(null) const loading = ref(true) const notFound = ref(false) @@ -35,23 +37,25 @@ onMounted(async () => { try { loading.value = true - const [areaRes, trainingsRes, userTrainingsRes] = await Promise.all([ + const [areaRes, trainingsRes, userTrainingsRes, resourcesRes] = await Promise.all([ fetch(`/api/areas/${route.params.id}`), fetch(`/api/trainings/area/${route.params.id}`), fetch(`/api/user/${user?.uuid}/trainings`), + fetch(`/api/resources/area/${route.params.id}`), ]) if (areaRes.status === 404) { notFound.value = true } - if (!areaRes.ok || !trainingsRes.ok || !userTrainingsRes.ok) { + if (!areaRes.ok || !trainingsRes.ok || !userTrainingsRes.ok || !resourcesRes.ok) { throw new Error('Failed to fetch data') } area.value = await areaRes.json() trainings.value = await trainingsRes.json() userTrainings.value = await userTrainingsRes.json() + resources.value = await resourcesRes.json() } catch (err) { console.error(err) } finally { @@ -165,14 +169,13 @@ onMounted(async () => { -
+
Resources
-
- - - + + +
diff --git a/web/src/views/admin/AdminDashboardView.vue b/web/src/views/admin/AdminDashboardView.vue index d391491..168da9b 100644 --- a/web/src/views/admin/AdminDashboardView.vue +++ b/web/src/views/admin/AdminDashboardView.vue @@ -40,6 +40,23 @@ const sections = [ }, ], }, + { + title: 'Resources', + items: [ + { + title: 'Resources', + description: 'Manage resources.', + path: '/admin/resources', + icon: 'bi-rulers', + }, + { + title: 'Create Resources', + description: 'Create a new resource.', + path: '/admin/resources/create', + icon: 'bi-plus-circle', + }, + ], + }, ] diff --git a/web/src/views/admin/areas/AreaEditView.vue b/web/src/views/admin/areas/AreaEditView.vue index d359901..1125a88 100644 --- a/web/src/views/admin/areas/AreaEditView.vue +++ b/web/src/views/admin/areas/AreaEditView.vue @@ -9,15 +9,19 @@ import DynamicTable from '@/components/DynamicTable.vue' import type { TableOptions } from '@/components/DynamicTable.vue' import AddTrainingPopup from '@/components/AddTrainingPopup.vue' import LoadingScreen from '@/components/LoadingScreen.vue' +import type { Resource } from '@/models/resources' +import AddResourcePopup from '@/components/AddResourcePopup.vue' const area = ref() const trainings = ref() +const resources = ref() const loading = ref(true) const notFound = ref(false) const route = useRoute() const showTrainingModal = ref(false) +const showResourcesModal = ref(false) const formOptions: FormOptions = { fields: { @@ -41,25 +45,45 @@ const tableOptions: TableOptions = { }, }, } + +const resourcesTableOptions: TableOptions = { + fields: { + //id: { hidden: true }, + }, + actions: { + delete: { + handler: async (t) => { + await fetch(`/api/resources/area/${area.value?.id}`, { + body: JSON.stringify({ id: t.id }), + method: 'DELETE', + credentials: 'include', + }) + }, + }, + }, +} + onMounted(async () => { try { loading.value = true - const [areaRes, trainingRes] = await Promise.all([ + const [areaRes, trainingRes, resourcesRes] = await Promise.all([ fetch(`/api/areas/${route.params.id}`), fetch(`/api/trainings/area/${route.params.id}`), + fetch(`/api/resources/area/${route.params.id}`), ]) if (areaRes.status === 404) { notFound.value = true } - if (!areaRes.ok || !trainingRes.ok) { + if (!areaRes.ok || !trainingRes.ok || !resourcesRes.ok) { throw new Error('Failed to fetch data') } area.value = await areaRes.json() trainings.value = await trainingRes.json() + resources.value = await resourcesRes.json() } catch (err) { console.error(err) } finally { @@ -92,11 +116,18 @@ async function saveArea(area: Area) { > + + +
-
+

Editing "{{ area?.name }}"

@@ -111,6 +142,15 @@ async function saveArea(area: Area) {
+ +
+

Associated Resources:

+ +
+ + diff --git a/web/src/views/admin/resources/ResourceCreateView.vue b/web/src/views/admin/resources/ResourceCreateView.vue new file mode 100644 index 0000000..d3843d4 --- /dev/null +++ b/web/src/views/admin/resources/ResourceCreateView.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/web/src/views/admin/resources/ResourceEditView.vue b/web/src/views/admin/resources/ResourceEditView.vue new file mode 100644 index 0000000..0c7ce0d --- /dev/null +++ b/web/src/views/admin/resources/ResourceEditView.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/web/src/views/admin/resources/ResourceListView.vue b/web/src/views/admin/resources/ResourceListView.vue new file mode 100644 index 0000000..d815cdc --- /dev/null +++ b/web/src/views/admin/resources/ResourceListView.vue @@ -0,0 +1,73 @@ + + + + + From 98f29cf285e517018a2cd3470c2c5d85f5c13a80 Mon Sep 17 00:00:00 2001 From: goosenotduck Date: Sat, 1 Aug 2026 22:10:14 -0400 Subject: [PATCH 04/20] Add abiliity to delete area --- database/db.go | 13 ++++++ routes/api/areas/areas.go | 31 ++++++++++++++ web/src/views/AreaView.vue | 82 +++++++++++++++++++------------------- web/src/views/HomeView.vue | 4 +- 4 files changed, 88 insertions(+), 42 deletions(-) diff --git a/database/db.go b/database/db.go index 42515a5..4643934 100644 --- a/database/db.go +++ b/database/db.go @@ -66,6 +66,19 @@ func (database *DatabaseHelper) GetArea(areaID int) (models.Area, error) { return area, nil } +func (database *DatabaseHelper) DeleteArea(areaID int) error { + _, err := database.DB.Exec( + "DELETE FROM areas WHERE id = $1", + areaID, + ) + + if err != nil { + return err + } + + return nil +} + func (database *DatabaseHelper) GetAllAreas() ([]models.Area, error) { rows, err := database.DB.Query("SELECT id, name, description, ldapgroup, photourl FROM areas") if err != nil { diff --git a/routes/api/areas/areas.go b/routes/api/areas/areas.go index fd9de90..2a4ad01 100644 --- a/routes/api/areas/areas.go +++ b/routes/api/areas/areas.go @@ -129,6 +129,35 @@ func updateArea(c *gin.Context) { c.Status(204) } + +// DeleteArea godoc +// +// @Summary Deletes an area +// @Description Deletes an area +// @Tags areas +// @Accept json +// @Param id path int true "Area ID" +// @Success 204 +// @Failure 400 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /areas/{id} [delete] +func deleteArea(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.Error(err) + return + } + + err = database.Helper.DeleteArea(id) + if err != nil { + c.Error(err) + return + } + + c.Status(201) +} + func Routes(route *gin.RouterGroup) { areas := route.Group("/areas") areas.GET("/", getAllAreas) @@ -136,4 +165,6 @@ func Routes(route *gin.RouterGroup) { areas.PUT("/:id", middleware.RequireGroup("eboard"), updateArea) areas.POST("/", middleware.RequireGroup("eboard"), createArea) + + areas.DELETE("/:id", middleware.RequireGroup("eboard"), deleteArea) } diff --git a/web/src/views/AreaView.vue b/web/src/views/AreaView.vue index 1a55706..bc832b1 100644 --- a/web/src/views/AreaView.vue +++ b/web/src/views/AreaView.vue @@ -5,7 +5,7 @@ import type { Area } from '@/models/areas' import type { Training, UserTraining } from '@/models/trainings' import type { Resource } from '@/models/resources' import { ref, onMounted, computed } from 'vue' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' const user = authState.user @@ -31,7 +31,26 @@ const progress = computed(() => { ) return ((completedRequiredTrainings?.length || 0) / requiredTrainings.length) * 100 }) + const route = useRoute() +const router = useRouter() + +async function deleteArea() { + try { + const response = await fetch(`/api/areas/${area.value?.id}`, { + method: 'DELETE', + credentials: 'include', + }) + + if (!response.ok) { + throw new Error(`Failed to delete area (${response.status})`) + } + } catch (error) { + console.error('Error deleting area:', error) + } + + router.push({ path: '/' }) +} onMounted(async () => { try { @@ -76,13 +95,16 @@ onMounted(async () => {
- - - +
+ + + + + +
+

{{ area.name }}

@@ -99,10 +121,7 @@ onMounted(async () => {
Your Status
- + {{ completedAllTrainings ? 'Certified' : 'Incomplete' }}
@@ -120,14 +139,8 @@ onMounted(async () => {
-
+
@@ -140,25 +153,16 @@ onMounted(async () => {
Required Trainings
    -
  • +
  • No trainings associated with area
  • -
  • +
  • {{ training.title }} - + Completed Required @@ -194,13 +198,11 @@ onMounted(async () => { From 5ddf9e58d53998cf0a915ebb2594f07a97e05f1c Mon Sep 17 00:00:00 2001 From: goosenotduck Date: Sat, 1 Aug 2026 22:54:56 -0400 Subject: [PATCH 11/20] Add cursor pointer when hovering username dropdown, remove dual hover animation --- web/src/components/AppNavbar.vue | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/web/src/components/AppNavbar.vue b/web/src/components/AppNavbar.vue index 0aeae25..62fca0d 100644 --- a/web/src/components/AppNavbar.vue +++ b/web/src/components/AppNavbar.vue @@ -39,13 +39,14 @@ function logout() { id="userDropdownLink" data-bs-toggle="dropdown" aria-expanded="false" + role="button" > - {{ user.name }} - + {{ user.name }} +