refactor: purge legacy server_members.role column, single source of truth via member_roles

Remove the legacy TEXT role column ('owner'/'admin'/'member') from
server_members and make the bitwise RBAC member_roles junction table
the sole authority for role assignments. Owner detection now uses
servers.ownerId exclusively.

- Remove MemberRole type and role field from shared types
- Remove role from Drizzle schema, raw SQL CREATE TABLE, and seed data
- Rewrite PATCH /members/:uid to accept { roleIds: string[] }
- Fix GET /members to populate roles array (was TODO)
- Replace member.role === 'owner' guard with isServerOwner()
- Remove getMemberRole() helper and legacy bridge code
- MemberSidebar groups by highest-positioned role instead of legacy string
- ServerSettings replaces admin/member dropdown with role checkboxes
- Message.tsx derives color from roles[] with owner fallback via ownerId
- Existing DBs keep vestigial column (Drizzle ignores it); new DBs omit it
This commit is contained in:
Jannis Braun
2026-02-25 22:46:50 +01:00
parent 76b8a43be2
commit 0a157de162
10 changed files with 295 additions and 141 deletions
+12 -14
View File
@@ -123,26 +123,24 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const displayName = message.user.displayName ?? message.user.username;
const roleColor = (() => {
const member = members.find(m => m.userId === message.userId);
if (member?.roles && member.roles.length > 0) {
return { color: member.roles[0]!.color };
}
if (member?.role === 'owner') return { color: '#f23f43' };
if (member?.role === 'admin') return { color: '#5865f2' };
return { color: '#dcdcdf' };
})();
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const ownerId = servers.find(s => s.id === currentServerId)?.ownerId;
const replyRoleColor = (msg: any) => {
const member = members.find(m => m.userId === msg.userId);
const getMemberDisplayColor = (userId: string) => {
const member = members.find(m => m.userId === userId);
if (member?.roles && member.roles.length > 0) {
return { color: member.roles[0]!.color };
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
return { color: sorted[0]!.color };
}
if (member?.role === 'owner') return { color: '#f23f43' };
if (member?.role === 'admin') return { color: '#5865f2' };
if (ownerId && userId === ownerId) return { color: '#f23f43' };
return { color: '#dcdcdf' };
};
const roleColor = getMemberDisplayColor(message.userId);
const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId);
const content = (
<div
className={`group relative flex px-4 py-0.5 hover:bg-discord-modifier-hover transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`}
@@ -4,50 +4,87 @@ import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
const ROLE_ORDER: Record<string, number> = { owner: 0, admin: 1, member: 2 };
const ROLE_LABELS: Record<string, string> = { owner: 'OWNER', admin: 'ADMIN', member: 'MEMBER' };
/**
* Derives the display group for a member based on their highest-positioned role
* or owner status. Returns { key, label, color, position }.
*/
function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
if (ownerId && member.userId === ownerId) {
// Owner always sorts first — position Infinity so it's above all roles
const ownerRole = member.roles?.find(r => r.position > 0);
return {
key: '__owner__',
label: 'OWNER',
color: ownerRole?.color ?? '#f23f43',
position: Infinity,
};
}
if (member.roles && member.roles.length > 0) {
// Sort by position descending — highest position = most important role
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
const top = sorted[0]!;
return {
key: top.id,
label: top.name.toUpperCase(),
color: top.color,
position: top.position,
};
}
// No explicit roles — just @everyone
return {
key: '__online__',
label: 'ONLINE',
color: undefined,
position: -1,
};
}
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const server = servers.find(s => s.id === currentServerId);
const ownerId = server?.ownerId;
const { roleGroups, offlineMembers } = useMemo(() => {
const online = members.filter(m => m.user.status !== 'offline');
const offline = members.filter(m => m.user.status === 'offline');
// Group online members by role
const groups = new Map<string, MemberWithUser[]>();
// Group online members by their highest role
const groups = new Map<string, { label: string; color: string | undefined; position: number; members: MemberWithUser[] }>();
for (const m of online) {
const role = m.role || 'member';
if (!groups.has(role)) groups.set(role, []);
groups.get(role)!.push(m);
const group = getMemberGroup(m, ownerId);
if (!groups.has(group.key)) {
groups.set(group.key, { label: group.label, color: group.color, position: group.position, members: [] });
}
groups.get(group.key)!.members.push(m);
}
// Sort groups by role hierarchy
// Sort groups by position descending (highest role first), then ONLINE last
const sorted = [...groups.entries()].sort(
(a, b) => (ROLE_ORDER[a[0]] ?? 99) - (ROLE_ORDER[b[0]] ?? 99)
(a, b) => b[1].position - a[1].position
);
return { roleGroups: sorted, offlineMembers: offline };
}, [members]);
}, [members, ownerId]);
if (!memberListOpen) return null;
const roleColors: Record<string, string> = {
owner: 'text-discord-red',
admin: 'text-discord-blurple',
member: 'text-discord-text-primary',
};
const getMemberColor = (member: MemberWithUser) => {
const getMemberColor = (member: MemberWithUser): React.CSSProperties | undefined => {
if (member.roles && member.roles.length > 0) {
return { color: member.roles[0]!.color };
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
return { color: sorted[0]!.color };
}
if (ownerId && member.userId === ownerId) {
return { color: '#f23f43' };
}
return undefined;
};
const handleMemberClick = (e: React.MouseEvent, user: any) => {
const handleMemberClick = (e: React.MouseEvent, user: MemberWithUser['user']) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
@@ -58,6 +95,7 @@ export function MemberSidebar() {
const renderMember = (member: MemberWithUser, isOffline = false) => {
const displayName = member.user.displayName ?? member.user.username;
const colorStyle = isOffline ? undefined : getMemberColor(member);
return (
<div
key={member.userId}
@@ -73,8 +111,8 @@ export function MemberSidebar() {
/>
<div className="flex-1 min-w-0">
<div
className={`text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : (!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : '')}`}
style={isOffline ? undefined : getMemberColor(member)}
className={`text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : (!colorStyle ? 'text-discord-text-primary' : '')}`}
style={colorStyle}
>
{displayName}
</div>
@@ -90,12 +128,12 @@ export function MemberSidebar() {
<div className="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar">
<div className="p-3">
{/* Role-based groups */}
{roleGroups.map(([role, groupMembers]) => (
<div key={role} className="mb-4">
{roleGroups.map(([key, group]) => (
<div key={key} className="mb-4">
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
{ROLE_LABELS[role] ?? role.toUpperCase()} {groupMembers.length}
{group.label} {group.members.length}
</h3>
{groupMembers.map((m) => renderMember(m))}
{group.members.map((m) => renderMember(m))}
</div>
))}
@@ -6,7 +6,6 @@ import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
import type { MemberRole } from '@opencord/shared';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
export function ServerSettingsModal() {
@@ -15,6 +14,7 @@ export function ServerSettingsModal() {
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const roles = useServerStore((s) => s.roles);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
@@ -26,6 +26,7 @@ export function ServerSettingsModal() {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
const serverPermissions = useServerStore((s) => s.serverPermissions);
@@ -34,6 +35,10 @@ export function ServerSettingsModal() {
const isOwnerUser = server?.ownerId === currentUser?.id;
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
const canManageRoles = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_ROLES);
// Assignable roles: exclude @everyone (where role.id === serverId)
const assignableRoles = roles.filter(r => r.id !== currentServerId);
React.useEffect(() => {
if (server) {
@@ -69,15 +74,48 @@ export function ServerSettingsModal() {
}
};
const handleRoleChange = async (userId: string, role: MemberRole) => {
const getMemberRoleIds = (member: typeof members[number]): Set<string> => {
// Check for pending (unsaved) changes first
const pending = pendingRoleChanges.get(member.userId);
if (pending) return pending;
return new Set(member.roles?.map(r => r.id) ?? []);
};
const handleRoleToggle = (userId: string, roleId: string, currentRoleIds: Set<string>) => {
const updated = new Set(currentRoleIds);
if (updated.has(roleId)) {
updated.delete(roleId);
} else {
updated.add(roleId);
}
setPendingRoleChanges(prev => new Map(prev).set(userId, updated));
};
const handleSaveRoles = async (userId: string) => {
const roleIds = pendingRoleChanges.get(userId);
if (!roleIds) return;
try {
await api.servers.updateMember(currentServerId, userId, { role });
await api.servers.updateMember(currentServerId, userId, { roleIds: Array.from(roleIds) });
setPendingRoleChanges(prev => {
const next = new Map(prev);
next.delete(userId);
return next;
});
await loadServerDetail(currentServerId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
setError(err instanceof Error ? err.message : 'Failed to update roles');
}
};
const handleCancelRoleChange = (userId: string) => {
setPendingRoleChanges(prev => {
const next = new Map(prev);
next.delete(userId);
return next;
});
};
const handleKick = async (userId: string) => {
try {
await api.servers.removeMember(currentServerId, userId);
@@ -159,37 +197,91 @@ export function ServerSettingsModal() {
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
{members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
const isOwner = member.userId === server.ownerId;
const memberRoleIds = getMemberRoleIds(member);
const hasPendingChanges = pendingRoleChanges.has(member.userId);
return (
<div key={member.userId} className="flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover">
<div className="flex items-center gap-2">
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div>
<div className="text-sm font-medium">{displayName}</div>
<div className="text-xs text-discord-text-muted capitalize">{member.role}</div>
<div key={member.userId} className="p-2 rounded hover:bg-discord-bg-hover">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div>
<div className="text-sm font-medium">{displayName}</div>
<div className="flex items-center gap-1 flex-wrap">
{isOwner && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-discord-red/20 text-discord-red font-medium">
Owner
</span>
)}
{member.roles?.filter(r => r.id !== currentServerId).map(r => (
<span
key={r.id}
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ backgroundColor: `${r.color}20`, color: r.color }}
>
{r.name}
</span>
))}
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentServerId).length === 0) && (
<span className="text-[10px] text-discord-text-muted">No roles</span>
)}
</div>
</div>
</div>
{canManageRoles && member.userId !== currentUser?.id && !isOwner && (
<div className="flex items-center gap-2">
<button
onClick={() => handleKick(member.userId)}
className="px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors"
>
Kick
</button>
</div>
)}
</div>
{isOwnerUser && member.userId !== currentUser?.id && (
<div className="flex items-center gap-2">
<select
value={member.role}
onChange={(e) => handleRoleChange(member.userId, e.target.value as MemberRole)}
className="px-2 py-1 bg-discord-bg-tertiary rounded text-xs text-discord-text-secondary outline-none"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button
onClick={() => handleKick(member.userId)}
className="px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors"
>
Kick
</button>
{/* Role checkboxes — shown for non-self, non-owner members when user can manage roles */}
{canManageRoles && member.userId !== currentUser?.id && !isOwner && assignableRoles.length > 0 && (
<div className="mt-2 ml-10 space-y-1">
{assignableRoles.map(role => (
<label key={role.id} className="flex items-center gap-2 cursor-pointer group/role">
<input
type="checkbox"
checked={memberRoleIds.has(role.id)}
onChange={() => handleRoleToggle(member.userId, role.id, memberRoleIds)}
className="w-3.5 h-3.5 rounded border-discord-text-muted accent-discord-blurple"
/>
<span
className="text-xs font-medium"
style={{ color: role.color !== '#b9bbbe' ? role.color : undefined }}
>
{role.name}
</span>
</label>
))}
{hasPendingChanges && (
<div className="flex items-center gap-2 mt-1.5">
<button
onClick={() => handleSaveRoles(member.userId)}
className="px-2 py-0.5 text-xs bg-discord-blurple hover:bg-discord-blurple-hover text-white rounded transition-colors"
>
Save
</button>
<button
onClick={() => handleCancelRoleChange(member.userId)}
className="px-2 py-0.5 text-xs text-discord-text-muted hover:text-discord-text-secondary transition-colors"
>
Cancel
</button>
</div>
)}
</div>
)}
</div>