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:
@@ -38,7 +38,6 @@ function createTables(db: Database.Database): void {
|
|||||||
CREATE TABLE IF NOT EXISTS server_members (
|
CREATE TABLE IF NOT EXISTS server_members (
|
||||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
role TEXT DEFAULT 'member',
|
|
||||||
nickname TEXT,
|
nickname TEXT,
|
||||||
joined_at INTEGER NOT NULL,
|
joined_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (server_id, user_id)
|
PRIMARY KEY (server_id, user_id)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const servers = sqliteTable('servers', {
|
|||||||
export const serverMembers = sqliteTable('server_members', {
|
export const serverMembers = sqliteTable('server_members', {
|
||||||
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
|
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
role: text('role').default('member'),
|
|
||||||
nickname: text('nickname'),
|
nickname: text('nickname'),
|
||||||
joinedAt: integer('joined_at').notNull(),
|
joinedAt: integer('joined_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export async function seedDatabase(): Promise<void> {
|
|||||||
db.insert(schema.serverMembers).values({
|
db.insert(schema.serverMembers).values({
|
||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
userId: adminId,
|
userId: adminId,
|
||||||
role: 'owner',
|
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
tx.insert(schema.serverMembers).values({
|
tx.insert(schema.serverMembers).values({
|
||||||
serverId,
|
serverId,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
role: 'owner',
|
|
||||||
joinedAt: now,
|
joinedAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
@@ -219,7 +218,6 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return {
|
return {
|
||||||
serverId: m.serverId,
|
serverId: m.serverId,
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
role: (m.role ?? 'member') as MemberWithUser['role'],
|
|
||||||
nickname: m.nickname,
|
nickname: m.nickname,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
@@ -387,7 +385,6 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
db.insert(schema.serverMembers).values({
|
db.insert(schema.serverMembers).values({
|
||||||
serverId: id,
|
serverId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
role: 'member',
|
|
||||||
joinedAt: now,
|
joinedAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
@@ -422,7 +419,6 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
db.insert(schema.serverMembers).values({
|
db.insert(schema.serverMembers).values({
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
role: 'member',
|
|
||||||
joinedAt: now,
|
joinedAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
@@ -460,18 +456,44 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const userMap = new Map(users.map(u => [u.id, u]));
|
const userMap = new Map(users.map(u => [u.id, u]));
|
||||||
|
|
||||||
|
const roles = db.select()
|
||||||
|
.from(schema.roles)
|
||||||
|
.where(eq(schema.roles.serverId, id))
|
||||||
|
.orderBy(schema.roles.position)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const memberRoleRows = db.select()
|
||||||
|
.from(schema.memberRoles)
|
||||||
|
.where(eq(schema.memberRoles.serverId, id))
|
||||||
|
.all();
|
||||||
|
|
||||||
const members: MemberWithUser[] = memberRows
|
const members: MemberWithUser[] = memberRows
|
||||||
.map(m => {
|
.map(m => {
|
||||||
const user = userMap.get(m.userId);
|
const user = userMap.get(m.userId);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
const assignedRoleIds = memberRoleRows
|
||||||
|
.filter(mr => mr.userId === m.userId)
|
||||||
|
.map(mr => mr.roleId);
|
||||||
|
|
||||||
|
const assignedRoles = roles
|
||||||
|
.filter(r => assignedRoleIds.includes(r.id))
|
||||||
|
.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
serverId: r.serverId,
|
||||||
|
name: r.name,
|
||||||
|
color: r.color ?? '#b9bbbe',
|
||||||
|
position: r.position ?? 0,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverId: m.serverId,
|
serverId: m.serverId,
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
role: (m.role ?? 'member') as MemberWithUser['role'],
|
|
||||||
nickname: m.nickname,
|
nickname: m.nickname,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
roles: [] as Role[], // TODO: Fetch member roles
|
roles: assignedRoles,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((m): m is MemberWithUser => m !== null);
|
.filter((m): m is MemberWithUser => m !== null);
|
||||||
@@ -479,12 +501,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send(members);
|
return reply.code(200).send(members);
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/servers/:id/members/:uid - Update member role (owner only)
|
// PATCH /api/servers/:id/members/:uid - Update member roles
|
||||||
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/servers/:id/members/:uid', {
|
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/servers/:id/members/:uid', {
|
||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id, uid } = request.params;
|
const { id, uid } = request.params;
|
||||||
const { role } = request.body;
|
const { roleIds } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
|
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
|
||||||
@@ -497,11 +519,16 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (uid === request.userId) {
|
if (uid === request.userId) {
|
||||||
return reply.code(400).send({ error: 'You cannot change your own role', statusCode: 400 });
|
return reply.code(400).send({ error: 'You cannot change your own roles', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!role || !['admin', 'member'].includes(role)) {
|
if (!Array.isArray(roleIds)) {
|
||||||
return reply.code(400).send({ error: 'Role must be "admin" or "member"', statusCode: 400 });
|
return reply.code(400).send({ error: 'roleIds must be an array of role IDs', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot modify the server owner's roles unless you are the owner
|
||||||
|
if (isServerOwner(id, uid) && !isServerOwner(id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Only the server owner can modify their own roles', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = db.select()
|
const member = db.select()
|
||||||
@@ -516,62 +543,49 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
|
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.update(schema.serverMembers)
|
// Validate all roleIds belong to this server and are not @everyone
|
||||||
.set({ role })
|
if (roleIds.length > 0) {
|
||||||
.where(and(
|
const serverRoles = db.select()
|
||||||
eq(schema.serverMembers.serverId, id),
|
.from(schema.roles)
|
||||||
eq(schema.serverMembers.userId, uid),
|
.where(eq(schema.roles.serverId, id))
|
||||||
))
|
.all();
|
||||||
.run();
|
|
||||||
|
|
||||||
// Bridge legacy role string to bitwise member_roles
|
const serverRoleIds = new Set(serverRoles.map(r => r.id));
|
||||||
if (role === 'admin') {
|
|
||||||
// Find or create Admin role for this server (matches migrate.ts convention)
|
|
||||||
const adminPerms = permissionsToString(ALL_PERMISSIONS);
|
|
||||||
let adminRole = db.select().from(schema.roles)
|
|
||||||
.where(and(
|
|
||||||
eq(schema.roles.serverId, id),
|
|
||||||
eq(schema.roles.name, 'Admin'),
|
|
||||||
eq(schema.roles.permissions, adminPerms),
|
|
||||||
))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!adminRole) {
|
for (const roleId of roleIds) {
|
||||||
const adminRoleId = `${id}-admin`;
|
if (!serverRoleIds.has(roleId)) {
|
||||||
db.insert(schema.roles).values({
|
return reply.code(400).send({ error: `Role ${roleId} does not belong to this server`, statusCode: 400 });
|
||||||
id: adminRoleId,
|
}
|
||||||
serverId: id,
|
if (roleId === id) {
|
||||||
name: 'Admin',
|
return reply.code(400).send({ error: '@everyone role is implicit and cannot be assigned', statusCode: 400 });
|
||||||
color: '#e74c3c',
|
}
|
||||||
position: 1,
|
}
|
||||||
permissions: adminPerms,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
}).run();
|
|
||||||
adminRole = db.select().from(schema.roles).where(eq(schema.roles.id, adminRoleId)).get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminRole) {
|
// Atomically replace member's role assignments
|
||||||
// Assign the Admin role (no-op if already assigned)
|
db.transaction((tx) => {
|
||||||
db.insert(schema.memberRoles).values({
|
// Remove all existing role assignments for this member in this server
|
||||||
serverId: id,
|
tx.delete(schema.memberRoles)
|
||||||
userId: uid,
|
|
||||||
roleId: adminRole.id,
|
|
||||||
}).onConflictDoNothing().run();
|
|
||||||
}
|
|
||||||
} else if (role === 'member') {
|
|
||||||
// Remove all explicit role assignments (demote to @everyone only)
|
|
||||||
// @everyone is implicit via computePermissions, never stored in member_roles
|
|
||||||
db.delete(schema.memberRoles)
|
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(schema.memberRoles.serverId, id),
|
eq(schema.memberRoles.serverId, id),
|
||||||
eq(schema.memberRoles.userId, uid),
|
eq(schema.memberRoles.userId, uid),
|
||||||
))
|
))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
// Insert new role assignments
|
||||||
|
for (const roleId of roleIds) {
|
||||||
|
tx.insert(schema.memberRoles).values({
|
||||||
|
serverId: id,
|
||||||
|
userId: uid,
|
||||||
|
roleId,
|
||||||
|
}).run();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Force target user's client to re-sync with their new permissions
|
// Force target user's client to re-sync with their new permissions
|
||||||
connectionManager.pushReadyPayload(uid);
|
connectionManager.pushReadyPayload(uid);
|
||||||
|
|
||||||
|
// Build response with populated roles
|
||||||
const updatedMember = db.select()
|
const updatedMember = db.select()
|
||||||
.from(schema.serverMembers)
|
.from(schema.serverMembers)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -589,14 +603,39 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
|
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatedRoleRows = db.select()
|
||||||
|
.from(schema.memberRoles)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.memberRoles.serverId, id),
|
||||||
|
eq(schema.memberRoles.userId, uid),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const updatedRoleIds = updatedRoleRows.map(r => r.roleId);
|
||||||
|
const allRoles = db.select()
|
||||||
|
.from(schema.roles)
|
||||||
|
.where(eq(schema.roles.serverId, id))
|
||||||
|
.orderBy(schema.roles.position)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const memberRoles = allRoles
|
||||||
|
.filter(r => updatedRoleIds.includes(r.id))
|
||||||
|
.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
serverId: r.serverId,
|
||||||
|
name: r.name,
|
||||||
|
color: r.color ?? '#b9bbbe',
|
||||||
|
position: r.position ?? 0,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
const result: MemberWithUser = {
|
const result: MemberWithUser = {
|
||||||
serverId: updatedMember.serverId,
|
serverId: updatedMember.serverId,
|
||||||
userId: updatedMember.userId,
|
userId: updatedMember.userId,
|
||||||
role: (updatedMember.role ?? 'member') as MemberWithUser['role'],
|
|
||||||
nickname: updatedMember.nickname,
|
nickname: updatedMember.nickname,
|
||||||
joinedAt: updatedMember.joinedAt,
|
joinedAt: updatedMember.joinedAt,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
roles: [] as Role[], // TODO: Fetch member roles
|
roles: memberRoles,
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
@@ -640,7 +679,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cannot kick the owner
|
// Cannot kick the owner
|
||||||
if (member.role === 'owner') {
|
if (isServerOwner(id, uid)) {
|
||||||
return reply.code(400).send({ error: 'Cannot remove the server owner', statusCode: 400 });
|
return reply.code(400).send({ error: 'Cannot remove the server owner', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import type { MemberRole } from '@opencord/shared';
|
|
||||||
import {
|
import {
|
||||||
PermissionBits,
|
PermissionBits,
|
||||||
ALL_PERMISSIONS,
|
ALL_PERMISSIONS,
|
||||||
@@ -134,11 +133,6 @@ export function isMember(serverId: string, userId: string): boolean {
|
|||||||
return getMember(serverId, userId) !== undefined;
|
return getMember(serverId, userId) !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMemberRole(serverId: string, userId: string): MemberRole | null {
|
|
||||||
const member = getMember(serverId, userId);
|
|
||||||
return member ? (member.role as MemberRole) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isServerOwner(serverId: string, userId: string): boolean {
|
export function isServerOwner(serverId: string, userId: string): boolean {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
|
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
|
||||||
|
|||||||
@@ -651,7 +651,6 @@ function buildReadyPayload(userId: string): {
|
|||||||
return {
|
return {
|
||||||
serverId: m.serverId,
|
serverId: m.serverId,
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
role: (m.role ?? 'member') as MemberWithUser['role'],
|
|
||||||
nickname: m.nickname,
|
nickname: m.nickname,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
user: sanitizeUser(u),
|
user: sanitizeUser(u),
|
||||||
|
|||||||
@@ -36,12 +36,9 @@ export interface ServerWithChannelsAndMembers extends Server {
|
|||||||
|
|
||||||
// ─── Member Types ───────────────────────────────────────────────────────────
|
// ─── Member Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type MemberRole = 'owner' | 'admin' | 'member';
|
|
||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
role: MemberRole;
|
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
}
|
}
|
||||||
@@ -288,7 +285,7 @@ export interface UpdateUserRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
role: MemberRole;
|
roleIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMessageRequest {
|
export interface CreateMessageRequest {
|
||||||
|
|||||||
@@ -123,26 +123,24 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
|
|
||||||
const displayName = message.user.displayName ?? message.user.username;
|
const displayName = message.user.displayName ?? message.user.username;
|
||||||
|
|
||||||
const roleColor = (() => {
|
const servers = useServerStore((s) => s.servers);
|
||||||
const member = members.find(m => m.userId === message.userId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
if (member?.roles && member.roles.length > 0) {
|
const ownerId = servers.find(s => s.id === currentServerId)?.ownerId;
|
||||||
return { color: member.roles[0]!.color };
|
|
||||||
}
|
|
||||||
if (member?.role === 'owner') return { color: '#f23f43' };
|
|
||||||
if (member?.role === 'admin') return { color: '#5865f2' };
|
|
||||||
return { color: '#dcdcdf' };
|
|
||||||
})();
|
|
||||||
|
|
||||||
const replyRoleColor = (msg: any) => {
|
const getMemberDisplayColor = (userId: string) => {
|
||||||
const member = members.find(m => m.userId === msg.userId);
|
const member = members.find(m => m.userId === userId);
|
||||||
if (member?.roles && member.roles.length > 0) {
|
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 (ownerId && userId === ownerId) return { color: '#f23f43' };
|
||||||
if (member?.role === 'admin') return { color: '#5865f2' };
|
|
||||||
return { color: '#dcdcdf' };
|
return { color: '#dcdcdf' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roleColor = getMemberDisplayColor(message.userId);
|
||||||
|
|
||||||
|
const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex px-4 py-0.5 hover:bg-discord-modifier-hover transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`}
|
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 { useUIStore } from '../../stores/uiStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
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() {
|
export function MemberSidebar() {
|
||||||
const members = useServerStore((s) => s.members);
|
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 memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
|
const server = servers.find(s => s.id === currentServerId);
|
||||||
|
const ownerId = server?.ownerId;
|
||||||
|
|
||||||
const { roleGroups, offlineMembers } = useMemo(() => {
|
const { roleGroups, offlineMembers } = useMemo(() => {
|
||||||
const online = members.filter(m => m.user.status !== 'offline');
|
const online = members.filter(m => m.user.status !== 'offline');
|
||||||
const offline = members.filter(m => m.user.status === 'offline');
|
const offline = members.filter(m => m.user.status === 'offline');
|
||||||
|
|
||||||
// Group online members by role
|
// Group online members by their highest role
|
||||||
const groups = new Map<string, MemberWithUser[]>();
|
const groups = new Map<string, { label: string; color: string | undefined; position: number; members: MemberWithUser[] }>();
|
||||||
for (const m of online) {
|
for (const m of online) {
|
||||||
const role = m.role || 'member';
|
const group = getMemberGroup(m, ownerId);
|
||||||
if (!groups.has(role)) groups.set(role, []);
|
if (!groups.has(group.key)) {
|
||||||
groups.get(role)!.push(m);
|
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(
|
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 };
|
return { roleGroups: sorted, offlineMembers: offline };
|
||||||
}, [members]);
|
}, [members, ownerId]);
|
||||||
|
|
||||||
if (!memberListOpen) return null;
|
if (!memberListOpen) return null;
|
||||||
|
|
||||||
const roleColors: Record<string, string> = {
|
const getMemberColor = (member: MemberWithUser): React.CSSProperties | undefined => {
|
||||||
owner: 'text-discord-red',
|
|
||||||
admin: 'text-discord-blurple',
|
|
||||||
member: 'text-discord-text-primary',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMemberColor = (member: MemberWithUser) => {
|
|
||||||
if (member.roles && member.roles.length > 0) {
|
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;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMemberClick = (e: React.MouseEvent, user: any) => {
|
const handleMemberClick = (e: React.MouseEvent, user: MemberWithUser['user']) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
openUserProfile(user, {
|
openUserProfile(user, {
|
||||||
@@ -58,6 +95,7 @@ export function MemberSidebar() {
|
|||||||
|
|
||||||
const renderMember = (member: MemberWithUser, isOffline = false) => {
|
const renderMember = (member: MemberWithUser, isOffline = false) => {
|
||||||
const displayName = member.user.displayName ?? member.user.username;
|
const displayName = member.user.displayName ?? member.user.username;
|
||||||
|
const colorStyle = isOffline ? undefined : getMemberColor(member);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.userId}
|
key={member.userId}
|
||||||
@@ -73,8 +111,8 @@ export function MemberSidebar() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : (!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : '')}`}
|
className={`text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : (!colorStyle ? 'text-discord-text-primary' : '')}`}
|
||||||
style={isOffline ? undefined : getMemberColor(member)}
|
style={colorStyle}
|
||||||
>
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
</div>
|
</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="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{/* Role-based groups */}
|
{/* Role-based groups */}
|
||||||
{roleGroups.map(([role, groupMembers]) => (
|
{roleGroups.map(([key, group]) => (
|
||||||
<div key={role} className="mb-4">
|
<div key={key} className="mb-4">
|
||||||
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
|
<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>
|
</h3>
|
||||||
{groupMembers.map((m) => renderMember(m))}
|
{group.members.map((m) => renderMember(m))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { MemberRole } from '@opencord/shared';
|
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
|
|
||||||
export function ServerSettingsModal() {
|
export function ServerSettingsModal() {
|
||||||
@@ -15,6 +14,7 @@ export function ServerSettingsModal() {
|
|||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const members = useServerStore((s) => s.members);
|
const members = useServerStore((s) => s.members);
|
||||||
|
const roles = useServerStore((s) => s.roles);
|
||||||
const updateServer = useServerStore((s) => s.updateServer);
|
const updateServer = useServerStore((s) => s.updateServer);
|
||||||
const deleteServer = useServerStore((s) => s.deleteServer);
|
const deleteServer = useServerStore((s) => s.deleteServer);
|
||||||
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
||||||
@@ -26,6 +26,7 @@ export function ServerSettingsModal() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
const serverPermissions = useServerStore((s) => s.serverPermissions);
|
const serverPermissions = useServerStore((s) => s.serverPermissions);
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ export function ServerSettingsModal() {
|
|||||||
const isOwnerUser = server?.ownerId === currentUser?.id;
|
const isOwnerUser = server?.ownerId === currentUser?.id;
|
||||||
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
|
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
|
||||||
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (server) {
|
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 {
|
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);
|
await loadServerDetail(currentServerId);
|
||||||
} catch (err) {
|
} 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) => {
|
const handleKick = async (userId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.servers.removeMember(currentServerId, userId);
|
await api.servers.removeMember(currentServerId, userId);
|
||||||
@@ -159,8 +197,13 @@ export function ServerSettingsModal() {
|
|||||||
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
|
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
const displayName = member.user.displayName ?? member.user.username;
|
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 (
|
return (
|
||||||
<div key={member.userId} className="flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={member.user.avatar}
|
src={member.user.avatar}
|
||||||
@@ -170,20 +213,30 @@ export function ServerSettingsModal() {
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">{displayName}</div>
|
<div className="text-sm font-medium">{displayName}</div>
|
||||||
<div className="text-xs text-discord-text-muted capitalize">{member.role}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOwnerUser && member.userId !== currentUser?.id && (
|
{canManageRoles && member.userId !== currentUser?.id && !isOwner && (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
onClick={() => handleKick(member.userId)}
|
onClick={() => handleKick(member.userId)}
|
||||||
className="px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors"
|
className="px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors"
|
||||||
@@ -193,6 +246,45 @@ export function ServerSettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user