Vibe coding with AI course Β· Module 9: Discord Clone
Roles, Permissions, and Moderation
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 limitOur 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. How do permissions work in Discord?
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