Repository: inovex/scrumlr.io (ts)

Issue:
Kick participants from board sessions
**User Stories:**

**For Development:**

> 1. As a user, especially as a moderator, I expect the ability to remove any participant - whether a moderator or a regular participant - from a session and consequently from the board.
> 2. As a user, I expect this functionality to be available in the settings under the "Participants" section. It should be a one-click process, such as through an "X" or a "door" icon.
> 3. Additionally, as a user, I anticipate receiving an extra confirmation prompt (via a pop-up) to ensure my decision. In this prompt, I should be able to either confirm the decision or cancel it.
> 4. As a user, I expect to receive feedback confirming that the action was successful, possibly in the form of a toast notification.


**You can find the Design on Figma. Go on the left menu on "🧩 Components" and then to the section "Kick participant":**



![Image](https://github.com/inovex/scrumlr.io/assets/116653219/fb43b98c-3ac8-4879-b53f-23ae435e81a7)




Pull request:
feat: (Un)ban participants (Frontend Implementation)
<!--
Please make sure the title of your PR starts with a semantic prefix:

feat: A new feature.
fix: A bug fix.
improvement: Implemented enhancements to existing functionality.
dependency: Updates or modifications to project dependencies or external libraries.
localization: Changes related to localization or internationalization of the codebase.
accessibility: Improvements made to enhance the accessibility of the application or website.
docs: Documentation only changes.
style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc).
refactor: A code change that neither fixes a bug nor adds a feature.
perf: A code change that improves performance.
test: Adding missing tests or correcting existing tests.
build: Changes that affect the build system.
chore: Other changes that don't modify src or test files.
revert: Reverts a previous commit.
-->

## Description
<!-- Explain the changes you’ve made. It doesn’t need to be fancy and you don’t have to get too technical. -->
This PR includes the frontend implementation of the ban feature implemented in #3770.
Closes #3618.

## Changelog
<!-- Please provide a detailed list of the changes you have made to the codebase. -->
- Implemented a button to ban and unban a participant or moderator
  - Unbans can be found via the "disconnected" filter on the participants list 
- Added 2 tests to check whether that button exists
- A confirmation window to ensure the action is intended
- A toast confirms the action

- Fixes the backend API to return the correct banned status
- Prevents clickthrough on the Dialog Component and adds a custom classname for specific styling

## Checklist

- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] The light- and dark-theme are both supported and tested
- [x] The design was implemented and is responsive for all devices and screen sizes
- [x] The application was tested in the most commonly used browsers (e.g. Chrome, Firefox, Safari)

## (Optional) Visual Changes
<!-- If available, please provide a before and after screenshot of your UI related changes. -->
![image](https://github.com/inovex/scrumlr.io/assets/44020029/19c8ffb8-be08-48bf-9583-837f31d1b089)
![image](https://github.com/inovex/scrumlr.io/assets/44020029/e7e4d221-8418-4702-9a0d-2fa0ddb03382)
![image](https://github.com/inovex/scrumlr.io/assets/44020029/22941995-24b4-47b2-bc06-3251e00ba217)
![image](https://github.com/inovex/scrumlr.io/assets/44020029/ed8767a5-18ba-4591-bd68-ae97ee18472f)
![image](https://github.com/inovex/scrumlr.io/assets/44020029/0c262564-5c67-4c51-9009-caea9e8a2129)



Patch:
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 822f5c6b..3fb40498 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -62,6 +62,8 @@
     "deleteNoteButton": "Kärtchen löschen",
     "deleteStackButton": "Stapel löschen",
     "deleteOptions": "Was möchtest du löschen?",
+    "banParticipant": "Bist du dir sicher, dass du den Teilnehmer \"{{user}}\" aus der Session entfernen möchtest?",
+    "unbanParticipant": "Bist du dir sicher, dass du den Teilnehmer \"{{user}}\" wieder zur Session zulassen möchtest?",
     "warning": "Die Aktion ist nicht umkehrbar.",
     "yes": "Ja",
     "no": "Nein",
@@ -281,6 +283,8 @@
     "allParticipantsDone": "Alle Teilnehmer sind fertig!",
     "moderatorResetReadyStates": "Markierung der Teilnehmer als fertig zurücksetzen?",
     "moderatorResetReadyStatesButton": "Zurücksetzen!",
+    "bannedParticipant": "Teilnehmer \"{{user}}\" wurde entfernt",
+    "unbannedParticipant": "Teilnehmer \"{{user}}\" wird wieder zur Session zugelassen",
     "noteToHiddenColumn": "Du hast gerade eine Karte einer ausgeblendeten Spalte hinzugefügt!",
     "noteToHiddenColumnButton": "Spalte für alle anzeigen",
     "noteMovedToStack": "Die Karte wurde in einen Stapel verschoben!",
@@ -369,7 +373,9 @@
     "ChangeRoleToParticipantTooltip": "Ändere Rolle zu Teilnehmer",
     "ChangeRoleToModeratorTooltip": "Ändere Rolle zu Moderator",
     "ResetBannerText": "Fertige Teilnehmer zurücksetzen.",
-    "ResetBannerButton": "Zurücksetzen"
+    "ResetBannerButton": "Zurücksetzen",
+    "BanParticipantTooltip": "Teilnehmer \"{{user}}\" bannen",
+    "UnbanParticipantTooltip": "Teilnehmer \"{{user}}\" entbannen"
   },
   "UserRole": {
     "Owner": "Besitzer",
@@ -420,6 +426,7 @@
     "unstackNote": "Es tut uns leid, aber wir haben ein Problem beim Entstapeln des Kärtchens. Bitte versuche es erneut.",
     "setRaiseHand": "Es tut uns leid, aber wir haben ein Problem beim Heben deiner Hand. Bitte versuche es erneut.",
     "setUserReady": "Es tut uns leid, aber wir haben ein Problem beim Aktualisieren deines Statuses. Bitte versuche es erneut.",
+    "setUserBanned": "Es tut uns leid, aber wir haben ein Problem beim Aktualisieren dieses Nutzerstatus. Bitte versuche es erneut.",
     "setShowHiddenColumns": "Es tut uns leid, aber wir haben ein Problem beim Aktualisieren der Spaltensichtbarkeit. Bitte versuche es erneut.",
     "changePermission": "Es tut uns leid, aber wir haben ein Problem beim Aktualisieren der Berechtigungen. Bitte versuche es erneut.",
     "logout": "Es tut uns leid, aber wir haben Probleme dich abzumelden.",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 54834656..072d59d9 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -62,6 +62,8 @@
     "deleteNoteButton": "Delete Note",
     "deleteStackButton": "Delete Stack",
     "deleteOptions": "What do you want to delete?",
+    "banParticipant": "Are you sure you want to remove participant '{{user}}' from the session?",
+    "unbanParticipant": "Are you sure you want to allow participant '{{user}}' to join the session again?",
     "warning": "This action cannot be undone.",
     "yes": "Yes",
     "no": "No",
@@ -250,7 +252,7 @@
     "3min": "3 minutes",
     "5min": "5 minutes",
     "min": "minutes",
-    "customTime":"Custom time",
+    "customTime": "Custom time",
     "cancelTimer": "Cancel timer"
   },
   "BoardReactionsMenu": {
@@ -281,6 +283,8 @@
     "allParticipantsDone": "All participants are ready!",
     "moderatorResetReadyStates": "Do you want to reset the readiness of the participants?",
     "moderatorResetReadyStatesButton": "Reset!",
+    "bannedParticipant": "Participant '{{user}}' was removed",
+    "unbannedParticipant": "The ban for participant '{{user}}' was revoked",
     "noteToHiddenColumn": "You just added a note to a hidden column!",
     "noteToHiddenColumnButton": "Reveal column",
     "noteMovedToStack": "The note was moved to a stack!",
@@ -369,7 +373,9 @@
     "ChangeRoleToParticipantTooltip": "Change role to participant",
     "ChangeRoleToModeratorTooltip": "Change role to moderator",
     "ResetBannerText": "Reset ready states of all users.",
-    "ResetBannerButton": "Reset"
+    "ResetBannerButton": "Reset",
+    "BanParticipantTooltip": "Ban participant '{{user}}'",
+    "UnbanParticipantTooltip": "Revoke the ban on participant '{{user}}'"
   },
   "UserRole": {
     "Owner": "Owner",
@@ -420,6 +426,7 @@
     "unstackNote": "Sorry, but we're having trouble unstacking the note. Please try again.",
     "setRaiseHand": "Sorry, but we're having trouble raising your hand. Please try again.",
     "setUserReady": "Sorry, but we're having trouble setting you ready. Please try again.",
+    "setUserBanned": "Sorry, but we're having trouble banning/unbanning this user. Please try again.",
     "setShowHiddenColumns": "Sorry, but we're having trouble changing the column visibility. Please try again.",
     "changePermission": "Sorry, but we're having trouble updating the permissions. Please try again.",
     "logout": "Sorry, but we're having trouble signing you out.",
diff --git a/server/src/database/board_sessions.go b/server/src/database/board_sessions.go
index 9dbf2a90..bcf7742e 100644
--- a/server/src/database/board_sessions.go
+++ b/server/src/database/board_sessions.go
@@ -86,7 +86,7 @@ func (d *Database) CreateBoardSession(boardSession BoardSessionInsert) (BoardSes
 		With("insertQuery", insertQuery).
 		Model((*BoardSession)(nil)).
 		ModelTableExpr("\"insertQuery\" AS s").
-		ColumnExpr("s.board, s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role").
+		ColumnExpr("s.board, s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role, s.banned").
 		Where("s.board = ?", boardSession.Board).
 		Where("s.user = ?", boardSession.User).
 		Join("INNER JOIN users AS u ON u.id = s.user").
@@ -161,7 +161,7 @@ func (d *Database) UpdateBoardSessions(update BoardSessionUpdate) ([]BoardSessio
 		With("updateQuery", updateQuery).
 		Model((*BoardSession)(nil)).
 		ModelTableExpr("\"updateQuery\" AS s").
-		ColumnExpr("s.board, s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role").
+		ColumnExpr("s.board, s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role, s.banned").
 		Where("s.board = ?", update.Board).
 		Join("INNER JOIN users AS u ON u.id = s.user").
 		Scan(context.Background(), &sessions)
@@ -193,7 +193,7 @@ func (d *Database) GetBoardSession(board, user uuid.UUID) (BoardSession, error)
 	var session BoardSession
 	err := d.db.NewSelect().
 		TableExpr("board_sessions AS s").
-		ColumnExpr("s.board, s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role").
+		ColumnExpr("s.board, s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role, s.banned").
 		Where("s.board = ?", board).
 		Where("s.user = ?", user).
 		Join("INNER JOIN users AS u ON u.id = s.user").
@@ -204,7 +204,7 @@ func (d *Database) GetBoardSession(board, user uuid.UUID) (BoardSession, error)
 func (d *Database) GetBoardSessions(board uuid.UUID, filter ...filter.BoardSessionFilter) ([]BoardSession, error) {
 	query := d.db.NewSelect().
 		TableExpr("board_sessions AS s").
-		ColumnExpr("s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role").
+		ColumnExpr("s.user, u.avatar, u.name, s.connected, s.show_hidden_columns, s.ready, s.raised_hand, s.role, s.banned").
 		Where("s.board = ?", board).
 		Join("INNER JOIN users AS u ON u.id = s.user")
 
diff --git a/src/assets/icon-kick-participant.svg b/src/assets/icon-kick-participant.svg
new file mode 100644
index 00000000..fe92a0d4
--- /dev/null
+++ b/src/assets/icon-kick-participant.svg
@@ -0,0 +1,11 @@
+<svg viewBox="0 0 77 77" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path
+    d="M36.106 19.25H54.793C56.7075 19.25 58.5436 20.0105 59.8974 21.3643C61.2512 22.7181 62.0117 24.5542 62.0117 26.4688V52.9375C62.0117 54.852 61.2512 56.6882 59.8974 58.0419M21.1055 26.4688V52.9375C21.1055 54.852 21.866 56.6882 23.2198 58.0419C24.5736 59.3957 26.4097 60.1563 28.3242 60.1563H34.8909"
+    stroke="currentColor" stroke-opacity="0.5" stroke-width="3.5" />
+  <path d="M14.4375 19.25H28.875" stroke="currentColor" stroke-width="4" stroke-linecap="square" />
+  <path
+    d="M47.2812 31.0142C48.471 31.0142 49.6341 31.367 50.6234 32.028C51.6126 32.689 52.3837 33.6285 52.839 34.7277C53.2943 35.8269 53.4134 37.0365 53.1813 38.2034C52.9492 39.3703 52.3762 40.4422 51.5349 41.2835C50.6936 42.1248 49.6218 42.6977 48.4548 42.9298C47.2879 43.1619 46.0784 43.0428 44.9792 42.5875C43.88 42.1322 42.9404 41.3612 42.2794 40.3719C41.6184 39.3826 41.2656 38.2196 41.2656 37.0298C41.2656 35.4343 41.8994 33.9042 43.0276 32.7761C44.1557 31.6479 45.6858 31.0142 47.2812 31.0142Z"
+    stroke="currentColor" stroke-width="4" />
+  <path d="M34.1003 63.205C34.1003 63.1568 33.1209 51.0967 47.313 51.0967C61.505 51.0967 60.454 63.205 60.454 63.205"
+    stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
+</svg>
\ No newline at end of file
diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx
index 8fec5aa5..d42b449c 100644
--- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx
+++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx
@@ -5,6 +5,7 @@ import {dialogTransitionConfig} from "utils/transitionConfig";
 import {Portal} from "components/Portal";
 import {ReactComponent as CloseIcon} from "assets/icon-close.svg";
 import {ReactComponent as WarningIcon} from "assets/icon-warning.svg";
+import classNames from "classnames";
 import "./ConfirmationDialog.scss";
 
 type ConfirmationDialogProps = {
@@ -17,6 +18,7 @@ type ConfirmationDialogProps = {
   onExtraOptionLabel?: string;
   icon?: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
   warning?: boolean;
+  className?: string;
 };
 
 export const ConfirmationDialog: FC<ConfirmationDialogProps> = (props) => {
@@ -25,10 +27,10 @@ export const ConfirmationDialog: FC<ConfirmationDialogProps> = (props) => {
   return (
     <Portal onClose={props.onDecline}>
       <div className="confirmation-dialog__background" />
-      <div className="confirmation-dialog__wrapper">
+      <div className={classNames("confirmation-dialog__wrapper", props.className)}>
         <Transition {...dialogTransitionConfig}>
           {(styles) => (
-            <animated.aside aria-modal="true" aria-label={props.title} className="confirmation-dialog" role="dialog" style={styles}>
+            <animated.aside aria-modal="true" aria-label={props.title} className="confirmation-dialog" role="dialog" style={styles} onClick={(e) => e.stopPropagation()}>
               <button aria-label="Close dialog" className="confirmation-dialog__close-button" onClick={() => props.onDecline()} type="button">
                 <CloseIcon className="dialog__close-icon" />
               </button>
diff --git a/src/components/SettingsDialog/Participants/Participants.scss b/src/components/SettingsDialog/Participants/Participants.scss
index f4869f54..46618eb0 100644
--- a/src/components/SettingsDialog/Participants/Participants.scss
+++ b/src/components/SettingsDialog/Participants/Participants.scss
@@ -141,6 +141,10 @@
   &:not(:last-child) {
     border-bottom: 1px solid $color-lighter-gray;
   }
+
+  &.banned > *:not(.participant__join-icon) {
+    opacity: 0.6;
+  }
 }
 
 .participant__avatar {
@@ -153,6 +157,7 @@
   flex-direction: column;
   gap: 4px;
   overflow: hidden;
+  justify-content: center;
 }
 
 .participant__name {
@@ -266,6 +271,37 @@
   }
 }
 
+.participant__join-icon,
+.participant__kick-icon {
+  width: $icon--large;
+  height: $icon--large;
+  padding: 0;
+  margin-left: auto; // align to the very right
+  border-radius: $rounded--default;
+  border: none;
+  background-color: $color-white;
+  flex-shrink: 0;
+
+  cursor: pointer;
+
+  &--active {
+    background-color: var(--accent-color);
+    color: $color-white;
+  }
+  &:focus {
+    outline: 2px solid var(--accent-color);
+  }
+
+  &:hover {
+    background-color: var(--accent-color--desaturated-light);
+  }
+}
+
+.participants__ban-dialog .confirmation-dialog__icon {
+  width: 77px;
+  height: 77px;
+}
+
 @media screen and (min-width: 920px) {
   // SettingsDialog changes margin here, so we have to do the same
   .participants-reset-state-banner__container {
@@ -339,4 +375,14 @@
       background-color: $color-dark-one;
     }
   }
+
+  .participant__join-icon,
+  .participant__kick-icon {
+    color: $color-white;
+    background-color: $color-dark-mode-note;
+
+    &:hover {
+      background-color: var(--accent-color--desaturated-dark);
+    }
+  }
 }
diff --git a/src/components/SettingsDialog/Participants/Participants.tsx b/src/components/SettingsDialog/Participants/Participants.tsx
index 446ffbdc..36b93b79 100644
--- a/src/components/SettingsDialog/Participants/Participants.tsx
+++ b/src/components/SettingsDialog/Participants/Participants.tsx
@@ -1,16 +1,20 @@
+import {ReactComponent as ReadyCheckIcon} from "assets/icon-check.svg";
+import {ReactComponent as UnbanIcon} from "assets/icon-join.svg";
+import {ReactComponent as BanIcon} from "assets/icon-kick-participant.svg";
+import {ReactComponent as MagnifyingGlassIcon} from "assets/icon-magnifying-glass.svg";
+import {ReactComponent as WifiIconDisabled} from "assets/icon-wifi-disabled.svg";
+import classNames from "classnames";
+import {UserAvatar} from "components/BoardUsers";
+import {ConfirmationDialog} from "components/ConfirmationDialog";
 import {useEffect, useRef, useState} from "react";
 import {useTranslation} from "react-i18next";
 import {useDispatch} from "react-redux";
-import classNames from "classnames";
 import {useAppSelector} from "store";
 import {Actions} from "store/action";
+import {Participant} from "types/participant";
+import _ from "underscore";
 import {useDebounce} from "utils/hooks/useDebounce";
-import {UserAvatar} from "components/BoardUsers";
-import {ReactComponent as WifiIconDisabled} from "assets/icon-wifi-disabled.svg";
-import {ReactComponent as MagnifyingGlassIcon} from "assets/icon-magnifying-glass.svg";
-import {ReactComponent as ReadyCheckIcon} from "assets/icon-check.svg";
 import "./Participants.scss";
-import _ from "underscore";
 
 export const Participants = () => {
   const {t} = useTranslation();
@@ -27,6 +31,9 @@ export const Participants = () => {
   const participants = useAppSelector((state) => [state.participants!.self, ...(state.participants?.others ?? [])], _.isEqual);
   const existsAtLeastOneReadyUser = participants.some((p) => p.ready);
 
+  const [showBanParticipantConfirmation, setShowBanParticipantConfirmation] = useState(false);
+  const [selectedParticipant, setSelectedParticipant] = useState<{participant: Participant; banned: boolean}>();
+
   useEffect(() => {
     const listWrapperHeight = listWrapperRef.current?.offsetWidth;
     const listHeight = listRef.current?.offsetHeight;
@@ -41,6 +48,21 @@ export const Participants = () => {
     participants.forEach((p) => dispatch(Actions.setUserReadyStatus(p.user.id, false)));
   };
 
+  const banParticipant = (participant: Participant, banned: boolean) => {
+    setSelectedParticipant({participant, banned});
+    setShowBanParticipantConfirmation(true);
+  };
+
+  const resetBanProcess = () => {
+    setSelectedParticipant(undefined);
+    setShowBanParticipantConfirmation(false);
+  };
+
+  const confirmBan = ({user}: Participant, banned: boolean) => {
+    dispatch(Actions.setUserBanned(user, banned));
+    resetBanProcess();
+  };
+
   return (
     <section className="settings-dialog__container accent-color__poker-purple">
       <header className="settings-dialog__header">
@@ -94,17 +116,18 @@ export const Participants = () => {
             .filter((participant) => participant.role === permissionFilter || permissionFilter === "ALL")
             .filter((participant) => participant.connected === onlineFilter)
             .map((participant) => (
-              <li key={participant.user.id} className="participants__list-item">
+              <li key={participant.user.id} className={classNames("participants__list-item", {banned: participant.banned})}>
                 <UserAvatar avatar={participant.user.avatar} className="participant__avatar" id={participant.user.id} ready={participant.ready} title={participant.user.name} />
                 <div className="participant__name-role-wrapper">
                   <span className="participant__name">{participant.user.name}</span>
-                  {participant.role === "OWNER" || !isModerator || participant.user.id === self.user.id ? (
+                  {(participant.role === "OWNER" || !isModerator || participant.user.id === self.user.id) && (
                     <span className="participant__role">
                       {participant.role === "OWNER" && t("UserRole.Owner")}
                       {participant.role === "MODERATOR" && t("UserRole.Moderator")}
                       {participant.role === "PARTICIPANT" && t("UserRole.Participant")}
                     </span>
-                  ) : (
+                  )}
+                  {participant.role !== "OWNER" && isModerator && participant.user.id !== self.user.id && !participant.banned && (
                     <div className="participant__role-buttons">
                       <button
                         className={classNames("participant__role", {"participant__role--active": participant.role === "MODERATOR"})}
@@ -125,6 +148,28 @@ export const Participants = () => {
                     </div>
                   )}
                 </div>
+                {isModerator &&
+                  self.user.id !== participant.user.id &&
+                  ["PARTICIPANT", "MODERATOR"].includes(participant.role) &&
+                  (participant.banned ? (
+                    <button
+                      aria-label={t("Participants.UnbanParticipantTooltip", {user: participant.user.name})}
+                      title={t("Participants.UnbanParticipantTooltip", {user: participant.user.name})}
+                      className="participant__join-icon"
+                      onClick={() => banParticipant(participant, false)}
+                    >
+                      <UnbanIcon />
+                    </button>
+                  ) : (
+                    <button
+                      aria-label={t("Participants.BanParticipantTooltip", {user: participant.user.name})}
+                      title={t("Participants.BanParticipantTooltip", {user: participant.user.name})}
+                      className="participant__kick-icon"
+                      onClick={() => banParticipant(participant, true)}
+                    >
+                      <BanIcon />
+                    </button>
+                  ))}
               </li>
             ))}
         </ul>
@@ -140,6 +185,16 @@ export const Participants = () => {
           </button>
         </footer>
       )}
+
+      {showBanParticipantConfirmation && selectedParticipant && (
+        <ConfirmationDialog
+          title={t(selectedParticipant.banned ? "ConfirmationDialog.banParticipant" : "ConfirmationDialog.unbanParticipant", {user: selectedParticipant.participant.user.name})}
+          onAccept={() => confirmBan(selectedParticipant.participant, selectedParticipant.banned)} // assertion: selectedParticipant is set
+          onDecline={() => resetBanProcess()}
+          icon={BanIcon}
+          className="participants__ban-dialog"
+        />
+      )}
     </section>
   );
 };
diff --git a/src/routes/Board/Board.tsx b/src/routes/Board/Board.tsx
index 7630aec6..a638a333 100644
--- a/src/routes/Board/Board.tsx
+++ b/src/routes/Board/Board.tsx
@@ -54,6 +54,11 @@ export const Board = () => {
 
   const currentUserIsModerator = state.participants?.self.role === "OWNER" || state.participants?.self.role === "MODERATOR";
 
+  if (state.participants?.self.banned) {
+    window.location.reload();
+    return <LoadingScreen />; // fallback
+  }
+
   if (state.board.status === "pending") {
     return <LoadingScreen />;
   }
diff --git a/src/store/action/participants.ts b/src/store/action/participants.ts
index aca3b18a..16580dd9 100644
--- a/src/store/action/participants.ts
+++ b/src/store/action/participants.ts
@@ -12,13 +12,12 @@ export const ParticipantAction = {
   SetParticipants: "scrumlr.io/setParticipants" as const,
   CreatedParticipant: "scrumlr.io/createdParticipant" as const,
   UpdatedParticipant: "scrumlr.io/updatedParticipant" as const,
-
+  SetUserBanned: "scrumlr.io/setUserBanned" as const,
   SetUserReadyStatus: "scrumlr.io/setUserReadyStatus" as const,
   SetRaisedHandStatus: "scrumlr.io/setRaisedHandStatus" as const,
   SetShowHiddenColumns: "scrumlr.io/setShowHiddenColumns" as const,
   EditSelf: "scrumlr.io/editSelf" as const,
   ChangePermission: "scrumlr.io/changePermission" as const,
-
   SetFocusInitiator: "scrumlr.io/setFocusInitiator" as const,
   ClearFocusInitiator: "scrumlr.io/clearFocusInitiator" as const,
 };
@@ -74,6 +73,15 @@ export const ParticipantActionFactory = {
     showHiddenColumns,
   }),
 
+  /**
+   * Set the ban status of a participant from a board session.
+   */
+  setUserBanned: (user: Auth, banned: boolean) => ({
+    type: ParticipantAction.SetUserBanned,
+    user,
+    banned,
+  }),
+
   /**
    * Edits a user. It will be applied immediately on the local client and send to the server via the middleware and an API request.
    * @param user
@@ -112,6 +120,7 @@ export type ParticipantReduxAction =
   | ReturnType<typeof ParticipantActionFactory.setUserReadyStatus>
   | ReturnType<typeof ParticipantActionFactory.setRaisedHand>
   | ReturnType<typeof ParticipantActionFactory.setShowHiddenColumns>
+  | ReturnType<typeof ParticipantActionFactory.setUserBanned>
   | ReturnType<typeof ParticipantActionFactory.editSelf>
   | ReturnType<typeof ParticipantActionFactory.changePermission>
   | ReturnType<typeof ParticipantActionFactory.setFocusInitiator>
diff --git a/src/store/middleware/participants.tsx b/src/store/middleware/participants.tsx
index 4df43c02..98131263 100644
--- a/src/store/middleware/participants.tsx
+++ b/src/store/middleware/participants.tsx
@@ -37,6 +37,23 @@ export const passParticipantsMiddleware = (stateAPI: MiddlewareAPI<Dispatch, App
     });
   }
 
+  if (action.type === Action.SetUserBanned) {
+    API.editParticipant(action.context.board!, action.user.id, {
+      banned: action.banned,
+    }).then(
+      () => {
+        Toast.info({title: i18n.t(action.banned ? "Toast.bannedParticipant" : "Toast.unbannedParticipant", {user: action.user.name})});
+      },
+      () => {
+        Toast.error({
+          title: i18n.t("Error.setUserBanned"),
+          buttons: [i18n.t("Error.retry")],
+          firstButtonOnClick: () => store.dispatch(Actions.setUserBanned(action.user, action.banned)),
+        });
+      }
+    );
+  }
+
   if (action.type === Action.ChangePermission) {
     API.editParticipant(action.context.board!, action.userId, {
       role: action.moderator ? "MODERATOR" : "PARTICIPANT",
diff --git a/src/types/participant.ts b/src/types/participant.ts
index 61f9099e..0c9cb0eb 100644
--- a/src/types/participant.ts
+++ b/src/types/participant.ts
@@ -9,6 +9,7 @@ export interface Participant {
   raisedHand: boolean;
   showHiddenColumns: boolean;
   role: ParticipantRole;
+  banned?: boolean;
 }
 
 export type ParticipantsState = null | {


Test patch:
diff --git a/src/components/SettingsDialog/Participants/__tests__/Participants.test.tsx b/src/components/SettingsDialog/Participants/__tests__/Participants.test.tsx
index f6e2b46e..39b1099d 100644
--- a/src/components/SettingsDialog/Participants/__tests__/Participants.test.tsx
+++ b/src/components/SettingsDialog/Participants/__tests__/Participants.test.tsx
@@ -1,8 +1,10 @@
+import {within} from "@testing-library/react";
+import {t} from "i18next";
 import {Provider} from "react-redux";
 import {MockStoreEnhanced} from "redux-mock-store";
 import {render} from "testUtils";
-import getTestStore from "utils/test/getTestStore";
 import {ApplicationState} from "types";
+import getTestStore from "utils/test/getTestStore";
 import {Participants} from "../Participants";
 
 const createParticipants = (store: MockStoreEnhanced<ApplicationState>) => (
@@ -16,4 +18,18 @@ describe("Participants", () => {
     const {container} = render(createParticipants(getTestStore()));
     expect(container.firstChild).toMatchSnapshot();
   });
+
+  describe("Ban Feature", () => {
+    test("ban button not available on self", () => {
+      const {container} = render(createParticipants(getTestStore()));
+      const participants = container.getElementsByClassName("participants__list-item");
+      expect(within(participants[0]).queryByLabelText(t("Participants.BanParticipantTooltip"))).not.toBeInTheDocument();
+    });
+
+    test("ban button available on moderator", () => {
+      const {container} = render(createParticipants(getTestStore()));
+      const participants = container.getElementsByClassName("participants__list-item");
+      expect(within(participants[1]).getByLabelText(t("Participants.BanParticipantTooltip"))).toBeInTheDocument();
+    });
+  });
 });
diff --git a/src/components/SettingsDialog/Participants/__tests__/__snapshots__/Participants.test.tsx.snap b/src/components/SettingsDialog/Participants/__tests__/__snapshots__/Participants.test.tsx.snap
index b4867fbb..315b540e 100644
--- a/src/components/SettingsDialog/Participants/__tests__/__snapshots__/Participants.test.tsx.snap
+++ b/src/components/SettingsDialog/Participants/__tests__/__snapshots__/Participants.test.tsx.snap
@@ -1013,6 +1013,15 @@ exports[`Participants should render filter and participants correctly 1`] = `
             </button>
           </div>
         </div>
+        <button
+          aria-label="Participants.BanParticipantTooltip"
+          class="participant__kick-icon"
+          title="Participants.BanParticipantTooltip"
+        >
+          <svg>
+            icon-kick-participant.svg
+          </svg>
+        </button>
       </li>
     </ul>
   </div>