From 489fe58152bf1f6785d45b0ef965c6ea2bcf9b40 Mon Sep 17 00:00:00 2001 From: Nastasia Date: Sun, 8 Mar 2026 21:59:58 +0200 Subject: [PATCH 1/2] fix: separate admin boards from team boards in store --- server/api/boards.go | 90 +++++++++++++++++++---- webapp/src/components/sidebar/sidebar.tsx | 50 ++++++++----- webapp/src/octoClient.ts | 24 ++++++ webapp/src/store/boards.ts | 7 -- 4 files changed, 133 insertions(+), 38 deletions(-) diff --git a/server/api/boards.go b/server/api/boards.go index 685de7b29ea..f960a44632e 100644 --- a/server/api/boards.go +++ b/server/api/boards.go @@ -14,6 +14,7 @@ import ( func (a *API) registerBoardsRoutes(r *mux.Router) { r.HandleFunc("/teams/{teamID}/boards", a.sessionRequired(a.handleGetBoards)).Methods("GET") + r.HandleFunc("/teams/{teamID}/boards/admin", a.sessionRequired(a.handleGetAllBoardsForTeamAdmin)).Methods("GET") r.HandleFunc("/boards", a.sessionRequired(a.handleCreateBoard)).Methods("POST") r.HandleFunc("/boards/{boardID}", a.attachSession(a.handleGetBoard, false)).Methods("GET") r.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH") @@ -63,24 +64,13 @@ func (a *API) handleGetBoards(w http.ResponseWriter, r *http.Request) { defer a.audit.LogRecord(audit.LevelRead, auditRec) auditRec.AddMeta("teamID", teamID) - isAdmin, err := a.isHardcodedAdmin(userID) + isGuest, err := a.userIsGuest(userID) if err != nil { a.errorResponse(w, r, err) return } - var boards []*model.Board - if isAdmin { - // Admin sees all boards for the team - boards, err = a.app.GetAllBoardsForTeam(teamID) - } else { - isGuest, err := a.userIsGuest(userID) - if err != nil { - a.errorResponse(w, r, err) - return - } - boards, err = a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest) - } + boards, err := a.app.GetBoardsForUserAndTeam(userID, teamID, !isGuest) if err != nil { a.errorResponse(w, r, err) @@ -684,3 +674,77 @@ func (a *API) handleGetBoardMetadata(w http.ResponseWriter, r *http.Request) { auditRec.Success() } + +func (a *API) handleGetAllBoardsForTeamAdmin(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /teams/{teamID}/boards/admin getAllBoardsAdmin + // + // Returns all team boards for admin + // + // --- + // produces: + // - application/json + // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // type: array + // items: + // "$ref": "#/definitions/Board" + // '403': + // description: access denied + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + teamID := mux.Vars(r)["teamID"] + userID := getUserID(r) + + // Check if user is admin + isAdmin, err := a.isHardcodedAdmin(userID) + if err != nil { + a.errorResponse(w, r, err) + return + } + + if !isAdmin { + a.errorResponse(w, r, model.NewErrPermission("access denied - admin only")) + return + } + + auditRec := a.makeAuditRecord(r, "getAllBoardsAdmin", audit.Fail) + defer a.audit.LogRecord(audit.LevelRead, auditRec) + auditRec.AddMeta("teamID", teamID) + + // Get all boards for team + boards, err := a.app.GetAllBoardsForTeam(teamID) + if err != nil { + a.errorResponse(w, r, err) + return + } + + a.logger.Debug("GetAllBoardsAdmin", + mlog.String("teamID", teamID), + mlog.String("adminUserID", userID), + mlog.Int("boardsCount", len(boards)), + ) + + data, err := json.Marshal(boards) + if err != nil { + a.errorResponse(w, r, err) + return + } + + jsonBytesResponse(w, http.StatusOK, data) + + auditRec.AddMeta("boardsCount", len(boards)) + auditRec.Success() +} diff --git a/webapp/src/components/sidebar/sidebar.tsx b/webapp/src/components/sidebar/sidebar.tsx index b2f7800fa74..5025d78eda0 100644 --- a/webapp/src/components/sidebar/sidebar.tsx +++ b/webapp/src/components/sidebar/sidebar.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' -import {FormattedMessage, useIntl} from 'react-intl' +import React, {useCallback, useEffect, useMemo, useState} from 'react' +import {FormattedMessage} from 'react-intl' import {DragDropContext, Droppable, DropResult} from 'react-beautiful-dnd' import {getActiveThemeName, loadTheme} from '../../theme' @@ -9,7 +9,7 @@ import IconButton from '../../widgets/buttons/iconButton' import HamburgerIcon from '../../widgets/icons/hamburger' import HideSidebarIcon from '../../widgets/icons/hideSidebar' import ShowSidebarIcon from '../../widgets/icons/showSidebar' -import {getAllSortedBoards, getCurrentBoard, getMySortedBoards} from '../../store/boards' +import {getCurrentBoard, getMySortedBoards} from '../../store/boards' import {useAppDispatch, useAppSelector} from '../../store/hooks' import {Utils} from '../../utils' import {IUser} from '../../user' @@ -72,22 +72,10 @@ const Sidebar = (props: Props) => { const [isHidden, setHidden] = useState(false) const [userHidden, setUserHidden] = useState(false) const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()) - const isUserAdmin = useAppSelector(isAdmin); - const myBoards = useAppSelector(getMySortedBoards) - const allBoards = useAppSelector(getAllSortedBoards) - // For regular category display, always use myBoards (even for admin) - const boards = myBoards - - // Admin-only boards = allBoards minus myBoards - const adminOnlyBoards = useMemo((): Board[] => { - if (!isUserAdmin) { - return [] - } - - const myBoardIDs = new Set(myBoards.map(b => b.id)) - return allBoards.filter((board) => !myBoardIDs.has(board.id)) - }, [isUserAdmin, myBoards, allBoards]) + const [adminOnlyBoards, setAdminOnlyBoards] = useState([]) + const isUserAdmin = useAppSelector(isAdmin); + const boards = useAppSelector(getMySortedBoards) const dispatch = useAppDispatch() const sidebarCategories = useAppSelector(getSidebarCategories) const me = useAppSelector(getMe) @@ -96,6 +84,32 @@ const Sidebar = (props: Props) => { const teamId = useAppSelector(getCurrentTeamId) const team = useAppSelector(getCurrentTeam) + // Fetch admin-only boards separately + useEffect(() => { + const fetchAdminBoards = async () => { + if (!isUserAdmin || !team) { + setAdminOnlyBoards([]) + return + } + + try { + // Fetch all boards for team (admin endpoint) + const allBoards = await octoClient.getAllBoardsAdmin(team.id) + + // Filter out boards the admin is already a member of + const myBoardIDs = new Set(boards.map(b => b.id)) + const otherBoards = allBoards.filter((board: Board) => !myBoardIDs.has(board.id)) + + setAdminOnlyBoards(otherBoards) + } catch (err) { + console.error('Failed to fetch admin boards:', err) + setAdminOnlyBoards([]) + } + } + + fetchAdminBoards() + }, [isUserAdmin, team?.id, boards]) + useEffect(() => { const categoryOnChangeHandler = (_: WSClient, categories: Category[]) => { dispatch(updateCategories(categories)) diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index c5aca88dea3..d43fdf29449 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -380,6 +380,18 @@ class OctoClient { return boards } + async getAllBoardsForTeamAdmin(teamId: string): Promise { + const path = `/api/v2/teams/${teamId}/boards/admin` + const response = await fetch(this.getBaseURL() + path, { + method: 'GET', + headers: this.headers(), + }) + if (response.status !== 200) { + return [] + } + return (await response.json()) as Board[] + } + private async getBoardMembersWithPath(path: string): Promise { const response = await fetch(this.getBaseURL() + path, {headers: this.headers()}) if (response.status !== 200) { @@ -789,6 +801,18 @@ class OctoClient { return this.getBoardsWithPath(path) } + async getAllBoardsAdmin(teamId: string): Promise { + const path = `/api/v2/teams/${teamId}/boards/admin` + const response = await fetch(this.getBaseURL() + path, { + method: 'GET', + headers: this.headers(), + }) + if (response.status !== 200) { + return [] + } + return (await response.json()) as Board[] + } + async getBoard(boardID: string): Promise { let path = `/api/v2/boards/${boardID}` const readToken = Utils.getReadToken() diff --git a/webapp/src/store/boards.ts b/webapp/src/store/boards.ts index 525c8735e24..5f3c6633c46 100644 --- a/webapp/src/store/boards.ts +++ b/webapp/src/store/boards.ts @@ -239,13 +239,6 @@ export const getMySortedBoards = createSelector( }, ) -export const getAllSortedBoards = createSelector( - getBoards, - (boards) => { - return Object.values(boards).sort((a, b) => a.title.localeCompare(b.title)) - }, -) - export const getTemplates = (state: RootState): {[key: string]: Board} => state.boards.templates export const getSortedTemplates = createSelector( From fd67307695fb24e78f1f6b451b8ce82579249ddb Mon Sep 17 00:00:00 2001 From: Nastasia Date: Mon, 9 Mar 2026 00:03:22 +0200 Subject: [PATCH 2/2] fix: admin actually exports archive --- server/api/archive.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/api/archive.go b/server/api/archive.go index bc4bc5199ae..c07dea0a2a7 100644 --- a/server/api/archive.go +++ b/server/api/archive.go @@ -56,8 +56,15 @@ func (a *API) handleArchiveExportBoard(w http.ResponseWriter, r *http.Request) { boardID := vars["boardID"] userID := getUserID(r) + // Check if user is admin + isAdmin, err := a.isHardcodedAdmin(userID) + if err != nil { + a.errorResponse(w, r, err) + return + } + // check user has permission to board - if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { + if !isAdmin && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { // if this user has `manage_system` permission and there is a license with the compliance // feature enabled, then we will allow the export. license := a.app.GetLicense()