Vibe coding with AI course Β· Module 9: Discord Clone

Roles, Permissions, and Moderation

10 min read
In this lesson3

Blackbeard has sneaked onto the guild server and started flooding the announcements channel with spam. A regular member can't do anything, the captain is away, and the first mate doesn't know whether he is allowed to kick anyone. The server needs clear rules: who can write, who can delete messages, and who can kick and ban. Discord stores them as bitwise permissions with a role hierarchy and channel overrides.

Permissions as bits

Each permission is one bit in a number. We create the value with a bit shift, for example KICK_MEMBERS = 1 << 1 or ADMINISTRATOR = 1 << 3:

// Bitwise permissions like Discord
enum PermissionBits {
  CREATE_INSTANT_INVITE = 1 << 0,
  KICK_MEMBERS = 1 << 1,
  BAN_MEMBERS = 1 << 2,
  ADMINISTRATOR = 1 << 3,
  MANAGE_CHANNELS = 1 << 4,
  MANAGE_SERVER = 1 << 5,
  ADD_REACTIONS = 1 << 6,
  VIEW_AUDIT_LOG = 1 << 7,
  PRIORITY_SPEAKER = 1 << 8, // voice
  STREAM = 1 << 9, // voice
  VIEW_CHANNEL = 1 << 10,
  SEND_MESSAGES = 1 << 11,
  SEND_TTS_MESSAGES = 1 << 12,
  MANAGE_MESSAGES = 1 << 13,
  EMBED_LINKS = 1 << 14,
  ATTACH_FILES = 1 << 15,
  READ_MESSAGE_HISTORY = 1 << 16,
  MENTION_EVERYONE = 1 << 17,
  USE_EXTERNAL_EMOJIS = 1 << 18,
  CONNECT = 1 << 20, // voice
  SPEAK = 1 << 21, // voice
  MUTE_MEMBERS = 1 << 22, // voice
  DEAFEN_MEMBERS = 1 << 23, // voice
  MOVE_MEMBERS = 1 << 24, // voice
  USE_VAD = 1 << 25, // voice activity detection
  CHANGE_NICKNAME = 1 << 26,
  MANAGE_NICKNAMES = 1 << 27,
  MANAGE_ROLES = 1 << 28,
  MANAGE_WEBHOOKS = 1 << 29,
  MANAGE_EMOJIS = 1 << 30,
}

// Check if user has permission
function hasPermission(
  member: Member,
  permission: PermissionBits,
  roles: Role[],
  channel?: Channel
): boolean {
  // Owner has all permissions
  if (member.isOwner) return true;

  // Get all member roles
  const memberRoles = roles.filter(r => member.roles.includes(r.id));

  // Check for ADMINISTRATOR
  if (memberRoles.some(r => r.permissions & PermissionBits.ADMINISTRATOR)) {
    return true;
  }

  // Calculate base permissions from roles
  let permissions = 0;
  memberRoles.forEach(role => {
    permissions |= role.permissions;
  });

  // Apply channel-specific overrides
  if (channel) {
    channel.permissions.forEach(override => {
      if (member.roles.includes(override.roleId)) {
        // Apply denies first
        override.deny.forEach(perm => {
          permissions &= ~perm;
        });
        // Then apply allows
        override.allow.forEach(perm => {
          permissions |= perm;
        });
      }
    });
  }

  return (permissions & permission) === permission;
}

1 << 3 moves the one three places to the left, which gives binary 1000, that is, 8. A role with several permissions simply has several bits turned on. So in this lesson role.permissions is a number, not an array of names as in the lesson about servers.

hasPermission calculates permissions in four steps. The owner gets true right away (in the example the isOwner field; in full code you compare server.owner with the member's id). If any of the member's roles, picked with the condition member.roles.includes(role.id), has ADMINISTRATOR, the function also returns true, because ADMINISTRATOR gives full access to all permissions. Then it combines the roles' permissions with the OR operator: permissions |= role.permissions. Finally it applies the channel overrides: first deny, which removes a bit with &= ~perm, then allow, which adds one with |=. Discord follows the same rule: denies before allows.

The last line, (permissions & permission) === permission, checks whether the bit we are looking for is turned on. A call looks like this: hasPermission(member, KICK_MEMBERS, roles).

Watch out for the limit of numbers. Bitwise operators in JavaScript work on 32-bit numbers, and Discord already has permissions on bits above 50 and sends them as text:

console.log(1 << 31); // -2147483648: a negative number
console.log(1 << 32); // 1: the shift wrapped around to the start
console.log(1n << 40n); // 1099511627776n: BigInt has no such limit

Our enum ends at bit 30, so it fits within the range, but with Discord's real API permissions are calculated with the BigInt type.

Role manager

A typical hierarchy, from the highest role: Owner (ADMINISTRATOR), Admin (MANAGE_SERVER), Moderator (KICK_MEMBERS) and Member (SEND_MESSAGES). The manager shows the roles as a list with an editor:

'use client';

import { useState } from 'react';

interface RoleManagerProps {
  server: Server;
  onCreateRole: () => void;
  onEditRole: (roleId: string) => void;
  onDeleteRole: (roleId: string) => void;
}

export default function RoleManager({
  server,
  onCreateRole,
  onEditRole,
  onDeleteRole
}: RoleManagerProps) {
  const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);

  const selectedRole = server.roles.find(r => r.id === selectedRoleId);

  return (
    <div className="flex h-screen bg-gray-800">
      {/* Roles List */}
      <div className="w-64 bg-gray-900 p-4">
        <div className="flex items-center justify-between mb-4">
          <h3 className="font-bold text-white">Roles</h3>
          <button
            onClick={onCreateRole}
            className="w-8 h-8 bg-green-600 hover:bg-green-700 rounded-full text-white"
          >
            +
          </button>
        </div>

        <div className="space-y-1">
          {server.roles
            .sort((a, b) => b.position - a.position)
            .map(role => (
              <button
                key={role.id}
                onClick={() => setSelectedRoleId(role.id)}
                className={`w-full text-left px-3 py-2 rounded flex items-center justify-between ${
                  selectedRoleId === role.id
                    ? 'bg-gray-700'
                    : 'hover:bg-gray-800'
                }`}
              >
                <div className="flex items-center gap-2 flex-1 min-w-0">
                  <div
                    className="w-3 h-3 rounded-full"
                    style={{ backgroundColor: role.color || '#99AAB5' }}
                  />
                  <span className="text-white text-sm truncate">
                    {role.name}
                  </span>
                </div>
                <span className="text-xs text-gray-400">
                  {role.memberCount}
                </span>
              </button>
            ))}
        </div>

        <div className="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-400">
          Drag to reorder roles
        </div>
      </div>

      {/* Role Editor */}
      {selectedRole ? (
        <div className="flex-1 p-6 overflow-y-auto">
          <div className="max-w-2xl">
            <h2 className="text-2xl font-bold text-white mb-6">
              Edit Role: {selectedRole.name}
            </h2>

            {/* Display */}
            <div className="bg-gray-700 rounded-lg p-4 mb-6">
              <h3 className="font-bold text-white mb-3">Display</h3>

              <div className="mb-4">
                <label className="block text-sm text-gray-300 mb-2">
                  Role Name
                </label>
                <input
                  type="text"
                  value={selectedRole.name}
                  className="w-full bg-gray-900 text-white px-3 py-2 rounded"
                />
              </div>

              <div className="mb-4">
                <label className="block text-sm text-gray-300 mb-2">
                  Role Color
                </label>
                <div className="flex items-center gap-3">
                  <input
                    type="color"
                    value={selectedRole.color || '#99AAB5'}
                    className="w-16 h-10 rounded cursor-pointer"
                  />
                  <span className="text-gray-400 text-sm">
                    {selectedRole.color || 'Default'}
                  </span>
                </div>
              </div>

              <div className="space-y-2">
                <label className="flex items-center gap-2 text-sm text-gray-300">
                  <input
                    type="checkbox"
                    checked={selectedRole.isHoisted}
                    className="w-4 h-4"
                  />
                  Display role members separately from online members
                </label>

                <label className="flex items-center gap-2 text-sm text-gray-300">
                  <input
                    type="checkbox"
                    checked={selectedRole.isMentionable}
                    className="w-4 h-4"
                  />
                  Allow anyone to @mention this role
                </label>
              </div>
            </div>

            {/* Permissions */}
            <div className="bg-gray-700 rounded-lg p-4">
              <h3 className="font-bold text-white mb-3">Permissions</h3>

              <div className="space-y-3">
                {Object.entries(PermissionBits).map(([name, bit]) => {
                  const hasPermission = (selectedRole.permissions & bit) === bit;

                  return (
                    <label
                      key={name}
                      className="flex items-center gap-3 text-sm text-gray-300 hover:bg-gray-600 p-2 rounded cursor-pointer"
                    >
                      <input
                        type="checkbox"
                        checked={hasPermission}
                        className="w-4 h-4"
                      />
                      <div className="flex-1">
                        <div className="font-semibold">
                          {name.split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(' ')}
                        </div>
                        <div className="text-xs text-gray-400">
                          {getPermissionDescription(name)}
                        </div>
                      </div>
                    </label>
                  );
                })}
              </div>
            </div>

            {/* Actions */}
            <div className="mt-6 flex gap-3">
              <button
                onClick={() => onEditRole(selectedRole.id)}
                className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-semibold"
              >
                Save Changes
              </button>
              <button
                onClick={() => onDeleteRole(selectedRole.id)}
                className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
              >
                Delete Role
              </button>
            </div>
          </div>
        </div>
      ) : (
        <div className="flex-1 flex items-center justify-center text-gray-500">
          Select a role to edit
        </div>
      )}
    </div>
  );
}

function getPermissionDescription(permission: string): string {
  const descriptions: Record<string, string> = {
    ADMINISTRATOR: 'Full access to all channels and settings',
    MANAGE_SERVER: 'Change server name, region, and other settings',
    MANAGE_ROLES: 'Create, edit, and delete roles lower than this one',
    MANAGE_CHANNELS: 'Create, edit, and delete channels',
    KICK_MEMBERS: 'Remove members from the server',
    BAN_MEMBERS: 'Ban members from the server',
    SEND_MESSAGES: 'Send messages in text channels',
    MANAGE_MESSAGES: 'Delete messages sent by other members',
    MENTION_EVERYONE: 'Use @everyone and @here mentions',
    CONNECT: 'Connect to voice channels',
    SPEAK: 'Speak in voice channels',
    MUTE_MEMBERS: 'Mute members in voice channels',
    DEAFEN_MEMBERS: 'Deafen members in voice channels',
    // Add more descriptions...
  };

  return descriptions[permission] || 'Permission description';
}

The list sorts roles by position in descending order, so the most important one is at the top, and the dot next to the name has the role's color. The checkbox next to a permission is checked when (selectedRole.permissions & bit) === bit, exactly the same test as in hasPermission. The form fields don't have onChange yet, so for now this is a preview, and saving will be handled by onEditRole and the server.

Moderator tools

The ModerationAction type covers the moderator actions kick, ban, timeout and warn, plus unban to undo a ban:

interface ModerationAction {
  id: string;
  type: 'kick' | 'ban' | 'timeout' | 'warn' | 'unban';
  targetUserId: string;
  moderatorId: string;
  reason: string;
  duration?: number; // for timeout/ban (seconds)
  createdAt: Date;
}

interface ModeratorToolsProps {
  server: Server;
  targetMember: Member;
  currentUserId: string;
  onKick: (reason: string) => void;
  onBan: (reason: string, duration?: number) => void;
  onTimeout: (duration: number, reason: string) => void;
}

export default function ModeratorTools({
  server,
  targetMember,
  currentUserId,
  onKick,
  onBan,
  onTimeout
}: ModeratorToolsProps) {
  const [action, setAction] = useState<'kick' | 'ban' | 'timeout' | null>(null);
  const [reason, setReason] = useState('');
  const [duration, setDuration] = useState(3600); // 1 hour default

  const currentMember = server.members.find(m => m.userId === currentUserId);
  if (!currentMember) return null;

  const canKick = hasPermission(currentMember, PermissionBits.KICK_MEMBERS, server.roles);
  const canBan = hasPermission(currentMember, PermissionBits.BAN_MEMBERS, server.roles);

  return (
    <div className="bg-gray-800 rounded-lg p-4">
      <h3 className="font-bold text-white mb-4">Moderator Actions</h3>

      <div className="space-y-2 mb-4">
        {canKick && (
          <button
            onClick={() => setAction('kick')}
            className="w-full py-2 bg-orange-600 hover:bg-orange-700 text-white rounded font-semibold"
          >
            Kick from Server
          </button>
        )}

        {canBan && (
          <>
            <button
              onClick={() => setAction('ban')}
              className="w-full py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
            >
              Ban from Server
            </button>

            <button
              onClick={() => setAction('timeout')}
              className="w-full py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded font-semibold"
            >
              Timeout
            </button>
          </>
        )}
      </div>

      {action && (
        <div className="border-t border-gray-700 pt-4">
          <h4 className="text-white font-semibold mb-3">
            {action === 'kick' && 'Kick Member'}
            {action === 'ban' && 'Ban Member'}
            {action === 'timeout' && 'Timeout Member'}
          </h4>

          {action === 'timeout' && (
            <div className="mb-3">
              <label className="block text-sm text-gray-300 mb-2">
                Duration
              </label>
              <select
                value={duration}
                onChange={(e) => setDuration(parseInt(e.target.value))}
                className="w-full bg-gray-900 text-white px-3 py-2 rounded"
              >
                <option value={60}>60 seconds</option>
                <option value={300}>5 minutes</option>
                <option value={600}>10 minutes</option>
                <option value={3600}>1 hour</option>
                <option value={86400}>1 day</option>
                <option value={604800}>1 week</option>
              </select>
            </div>
          )}

          <div className="mb-3">
            <label className="block text-sm text-gray-300 mb-2">
              Reason
            </label>
            <textarea
              value={reason}
              onChange={(e) => setReason(e.target.value)}
              className="w-full bg-gray-900 text-white px-3 py-2 rounded resize-none"
              rows={3}
            />
          </div>

          <div className="flex gap-2">
            <button
              onClick={() => {
                if (action === 'kick') onKick(reason);
                if (action === 'ban') onBan(reason);
                if (action === 'timeout') onTimeout(duration, reason);
                setAction(null);
                setReason('');
              }}
              disabled={!reason.trim()}
              className="flex-1 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 text-white rounded font-semibold"
            >
              Confirm
            </button>
            <button
              onClick={() => setAction(null)}
              className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded"
            >
              Cancel
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

A kick removes a member from the server, but they can come back with a new invite. A ban blocks the way back. A timeout temporarily takes away the right to write and speak; the durations on the list, from the shortest, are 60 seconds, 5 minutes, 10 minutes, 1 hour, 1 day and 1 week, and Discord allows at most 28 days. Only someone with the permission sees the buttons, because canKick and canBan are calculated by hasPermission. Confirming requires a reason: disabled={!reason.trim()}.

Captain Redbeard's tip: hiding a button is not security. The server has to check hasPermission again on every kick and ban, because a clever pirate will send the request bypassing your view.

Remember: permissions are bits summed from roles, and channel overrides first take away, then add. Pirate's Cove now has everything we built step by step: servers with categories and channels, voice channels with rich presence, and roles with moderation. In the editor below you have the crew's role manager: pick a role, look at its color and the list of permissions to check, then solve the tasks.

Code for this lesson: App.jsx
// Role Manager
import { useState } from 'react';

const roles = [
  { id: 1, name: 'Captain', color: '#e74c3c', memberCount: 3, position: 4 },
  { id: 2, name: 'First Mate', color: '#3498db', memberCount: 5, position: 3 },
  { id: 3, name: 'Navigator', color: '#2ecc71', memberCount: 8, position: 2 },
  { id: 4, name: 'Crew', color: '#95a5a6', memberCount: 45, position: 1 }
];

const permissions = [
  'ADMINISTRATOR',
  'MANAGE_SERVER',
  'MANAGE_ROLES',
  'MANAGE_CHANNELS',
  'KICK_MEMBERS',
  'BAN_MEMBERS',
  'SEND_MESSAGES',
  'MANAGE_MESSAGES'
];

export default function App() {
  const [selectedRoleId, setSelectedRoleId] = useState(1);

  const selectedRole = roles.find(r => r.id === selectedRoleId);

  return (
    <div className="flex h-screen bg-gray-800">
      {/* Roles List */}
      <div className="w-64 bg-gray-900 p-4">
        <div className="flex items-center justify-between mb-4">
          <h3 className="font-bold text-white">Roles</h3>
          <button className="w-8 h-8 bg-green-600 hover:bg-green-700 rounded-full text-white">
            +
          </button>
        </div>

        <div className="space-y-1">
          {roles.sort((a, b) => b.position - a.position).map(role => (
            <button
              key={role.id}
              onClick={() => setSelectedRoleId(role.id)}
              className={`w-full text-left px-3 py-2 rounded flex items-center justify-between ${
                selectedRoleId === role.id ? 'bg-gray-700' : 'hover:bg-gray-800'
              }`}
            >
              <div className="flex items-center gap-2 flex-1">
                <div
                  className="w-3 h-3 rounded-full"
                  style={{ backgroundColor: role.color }}
                />
                <span className="text-white text-sm truncate">{role.name}</span>
              </div>
              <span className="text-xs text-gray-400">{role.memberCount}</span>
            </button>
          ))}
        </div>
      </div>

      {/* Role Editor */}
      {selectedRole && (
        <div className="flex-1 p-6 overflow-y-auto">
          <h2 className="text-2xl font-bold text-white mb-6">
            Edit Role: {selectedRole.name}
          </h2>

          {/* Display */}
          <div className="bg-gray-700 rounded-lg p-4 mb-6">
            <h3 className="font-bold text-white mb-3">Display</h3>

            <div className="mb-4">
              <label className="block text-sm text-gray-300 mb-2">Role Name</label>
              <input
                type="text"
                key={selectedRole.id}
                defaultValue={selectedRole.name}
                className="w-full bg-gray-900 text-white px-3 py-2 rounded"
              />
            </div>

            <div className="mb-4">
              <label className="block text-sm text-gray-300 mb-2">Role Color</label>
              <div className="flex items-center gap-3">
                <input
                  type="color"
                  key={selectedRole.id}
                  defaultValue={selectedRole.color}
                  className="w-16 h-10 rounded cursor-pointer"
                />
                <span className="text-gray-400 text-sm">{selectedRole.color}</span>
              </div>
            </div>

            <label className="flex items-center gap-2 text-sm text-gray-300 mb-2">
              <input type="checkbox" className="w-4 h-4" />
              Display role members separately
            </label>

            <label className="flex items-center gap-2 text-sm text-gray-300">
              <input type="checkbox" className="w-4 h-4" />
              Allow anyone to @mention this role
            </label>
          </div>

          {/* Permissions */}
          <div className="bg-gray-700 rounded-lg p-4">
            <h3 className="font-bold text-white mb-3">Permissions</h3>

            <div className="space-y-2">
              {permissions.map(permission => (
                <label
                  key={permission}
                  className="flex items-center gap-3 text-sm text-gray-300 hover:bg-gray-600 p-2 rounded cursor-pointer"
                >
                  <input type="checkbox" className="w-4 h-4" />
                  <span>{permission.split('_').join(' ')}</span>
                </label>
              ))}
            </div>
          </div>

          {/* Actions */}
          <div className="mt-6 flex gap-3">
            <button className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-semibold">
              Save Changes
            </button>
            <button className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold">
              Delete Role
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. How do permissions work in Discord?

  2. 2. How is a permission value, e.g. KICK_MEMBERS, defined in the PermissionBits enum?

These are 2 of 8 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Click in order

    Click to build the permission bit value: ADMINISTRATOR = 1 << 3

  • Code editor

    Role management interface

  • Horizontal ordering

    Arrange the role hierarchy (from highest):

  • Vertical ordering

    Arrange the steps for computing permissions in the hasPermission function:

  • Click in order

    Click to build adding a role's permission to the permissions variable:

  • Horizontal ordering

    Arrange the check whether a member has a given role: member.roles.includes(role.id)

  • Vertical ordering

    Arrange the available timeout durations (from shortest):

  • Code editor

    Moderation panel

  • Vertical ordering

    Arrange the steps of how the getHighestRole function works:

  • Horizontal ordering

    Arrange the arguments of the call: hasPermission(member, KICK_MEMBERS, roles)

  • Click in order

    Click to build the check whether a permission is set: (permissions & permission) === permission

Useful articles