RBAC backend
This commit is contained in:
+80
-43
@@ -81,14 +81,16 @@ pub fn clear_upload_status(ctx: &ReducerContext, client_id: String) {
|
||||
#[spacetimedb::reducer]
|
||||
pub fn upload_custom_emoji(ctx: &ReducerContext, name: String, category: String, data: Vec<u8>) {
|
||||
if data.len() > 256 * 1024 {
|
||||
panic!("Emoji image exceeds 256KB limit");
|
||||
return report_error(&ctx.db, ctx.sender(), "upload_emoji", "Emoji image exceeds 256KB limit", ctx.timestamp);
|
||||
}
|
||||
// Note: Emojis are currently global.
|
||||
ctx.db.custom_emoji().insert(CustomEmoji {
|
||||
id: 0,
|
||||
name,
|
||||
category,
|
||||
data,
|
||||
});
|
||||
report_success(&ctx.db, ctx.sender(), "upload_emoji", ctx.timestamp);
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
@@ -141,12 +143,16 @@ pub fn upload_server_avatar(
|
||||
data: Vec<u8>,
|
||||
mime_type: String,
|
||||
) {
|
||||
let mut s = ctx
|
||||
.db
|
||||
.server()
|
||||
.id()
|
||||
.find(server_id)
|
||||
.expect("Server not found");
|
||||
if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_SERVER) {
|
||||
return report_error(&ctx.db, ctx.sender(), "update_server", "Insufficient permissions", ctx.timestamp);
|
||||
}
|
||||
let mut s = match ctx.db.server().id().find(server_id) {
|
||||
Some(s) => s,
|
||||
None => return report_error(&ctx.db, ctx.sender(), "update_server", "Server not found", ctx.timestamp),
|
||||
};
|
||||
if data.len() > 4 * 1024 * 1024 {
|
||||
return report_error(&ctx.db, ctx.sender(), "update_server", "Image exceeds 4MB limit", ctx.timestamp);
|
||||
}
|
||||
let img = ctx.db.image().insert(Image {
|
||||
id: 0,
|
||||
mime_type,
|
||||
@@ -158,23 +164,29 @@ pub fn upload_server_avatar(
|
||||
});
|
||||
s.avatar_id = Some(img.id);
|
||||
ctx.db.server().id().update(s);
|
||||
report_success(&ctx.db, ctx.sender(), "update_server", ctx.timestamp);
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn update_server_name(ctx: &ReducerContext, server_id: u64, name: String) {
|
||||
validate_name(&name).expect("Invalid name");
|
||||
let mut s = ctx
|
||||
.db
|
||||
.server()
|
||||
.id()
|
||||
.find(server_id)
|
||||
.expect("Server not found");
|
||||
s.name = name;
|
||||
ctx.db.server().id().update(s);
|
||||
if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_SERVER) {
|
||||
return report_error(&ctx.db, ctx.sender(), "update_server", "Insufficient permissions", ctx.timestamp);
|
||||
}
|
||||
if let Err(e) = validate_name(&name) {
|
||||
return report_error(&ctx.db, ctx.sender(), "update_server", &e, ctx.timestamp);
|
||||
}
|
||||
if let Some(mut s) = ctx.db.server().id().find(server_id) {
|
||||
s.name = name;
|
||||
ctx.db.server().id().update(s);
|
||||
report_success(&ctx.db, ctx.sender(), "update_server", ctx.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn delete_server(ctx: &ReducerContext, server_id: u64) {
|
||||
if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_DELETE_SERVER) {
|
||||
return report_error(&ctx.db, ctx.sender(), "delete_server", "Insufficient permissions", ctx.timestamp);
|
||||
}
|
||||
for c in ctx.db.channel().server_id().filter(server_id) {
|
||||
for msg in ctx.db.message().channel_id().filter(c.id) {
|
||||
ctx.db.message().id().delete(msg.id);
|
||||
@@ -185,6 +197,7 @@ pub fn delete_server(ctx: &ReducerContext, server_id: u64) {
|
||||
ctx.db.server_member().id().delete(m.id);
|
||||
}
|
||||
ctx.db.server().id().delete(server_id);
|
||||
report_success(&ctx.db, ctx.sender(), "delete_server", ctx.timestamp);
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
@@ -202,7 +215,10 @@ pub fn edit_message(ctx: &ReducerContext, message_id: u64, new_text: String) {
|
||||
#[spacetimedb::reducer]
|
||||
pub fn delete_message(ctx: &ReducerContext, message_id: u64) {
|
||||
if let Some(msg) = ctx.db.message().id().find(message_id) {
|
||||
if msg.sender == ctx.sender() {
|
||||
let is_owner = msg.sender == ctx.sender();
|
||||
let can_moderate = has_permission(&ctx.db, ctx.sender(), msg.server_id, PERM_MODERATE_MESSAGES);
|
||||
|
||||
if is_owner || can_moderate {
|
||||
ctx.db.message().id().delete(message_id);
|
||||
}
|
||||
}
|
||||
@@ -210,18 +226,32 @@ pub fn delete_message(ctx: &ReducerContext, message_id: u64) {
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn set_server_public(ctx: &ReducerContext, server_id: u64, public: bool) {
|
||||
let mut s = ctx
|
||||
.db
|
||||
.server()
|
||||
.id()
|
||||
.find(server_id)
|
||||
.expect("Server not found");
|
||||
if s.owner == Some(ctx.sender()) {
|
||||
if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_SERVER) {
|
||||
return report_error(&ctx.db, ctx.sender(), "update_server", "Insufficient permissions", ctx.timestamp);
|
||||
}
|
||||
if let Some(mut s) = ctx.db.server().id().find(server_id) {
|
||||
s.public = public;
|
||||
ctx.db.server().id().update(s);
|
||||
report_success(&ctx.db, ctx.sender(), "update_server", ctx.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn set_member_permissions(ctx: &ReducerContext, server_id: u64, identity: Identity, permissions: u128) {
|
||||
if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_ROLES) {
|
||||
return report_error(&ctx.db, ctx.sender(), "set_permissions", "Insufficient permissions", ctx.timestamp);
|
||||
}
|
||||
|
||||
let existing = ctx.db.server_permission().server_id().filter(server_id).find(|p| p.identity == identity);
|
||||
if let Some(mut p) = existing {
|
||||
p.permissions = permissions;
|
||||
ctx.db.server_permission().id().update(p);
|
||||
} else {
|
||||
ctx.db.server_permission().insert(ServerPermission { id: 0, server_id, identity, permissions });
|
||||
}
|
||||
report_success(&ctx.db, ctx.sender(), "set_permissions", ctx.timestamp);
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn toggle_reaction(
|
||||
ctx: &ReducerContext,
|
||||
@@ -398,8 +428,16 @@ pub fn create_server(ctx: &ReducerContext, name: String) {
|
||||
kind: c2.kind,
|
||||
});
|
||||
ctx.db.server().id().update(s.clone());
|
||||
sync_server_access(&ctx.db, ctx.sender(), s.id);
|
||||
|
||||
// RBAC: Grant all permissions to the creator
|
||||
ctx.db.server_permission().insert(ServerPermission {
|
||||
id: 0,
|
||||
server_id: s.id,
|
||||
identity: ctx.sender(),
|
||||
permissions: u128::MAX,
|
||||
});
|
||||
|
||||
sync_server_access(&ctx.db, ctx.sender(), s.id);
|
||||
report_success(&ctx.db, ctx.sender(), "create_server", ctx.timestamp);
|
||||
}
|
||||
|
||||
@@ -412,19 +450,13 @@ pub fn create_invite(
|
||||
max_uses: Option<u32>,
|
||||
expires_in_hrs: Option<u32>,
|
||||
) {
|
||||
// Only members can invite
|
||||
if !ctx
|
||||
.db
|
||||
.server_member()
|
||||
.identity()
|
||||
.filter(ctx.sender())
|
||||
.any(|m| m.server_id == server_id)
|
||||
{
|
||||
// RBAC: Only members with permission can invite
|
||||
if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_CREATE_INVITES) {
|
||||
return report_error(
|
||||
&ctx.db,
|
||||
ctx.sender(),
|
||||
"create_invite",
|
||||
"Only server members can create invites",
|
||||
"Insufficient permissions",
|
||||
ctx.timestamp,
|
||||
);
|
||||
}
|
||||
@@ -648,6 +680,9 @@ pub fn leave_server(ctx: &ReducerContext, server_id: u64) {
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn create_channel(ctx: &ReducerContext, name: String, server_id: u64, is_voice: bool) {
|
||||
if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_CHANNELS) {
|
||||
return report_error(&ctx.db, ctx.sender(), "create_channel", "Insufficient permissions", ctx.timestamp);
|
||||
}
|
||||
if let Err(e) = validate_name(&name) {
|
||||
return report_error(&ctx.db, ctx.sender(), "create_channel", &e, ctx.timestamp);
|
||||
}
|
||||
@@ -663,15 +698,6 @@ pub fn create_channel(ctx: &ReducerContext, name: String, server_id: u64, is_voi
|
||||
);
|
||||
}
|
||||
};
|
||||
if s.owner != Some(ctx.sender()) {
|
||||
return report_error(
|
||||
&ctx.db,
|
||||
ctx.sender(),
|
||||
"create_channel",
|
||||
"Only owner can create channels",
|
||||
ctx.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
let kind = if is_voice {
|
||||
ChannelKind::Voice
|
||||
@@ -698,6 +724,16 @@ pub fn create_channel(ctx: &ReducerContext, name: String, server_id: u64, is_voi
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn join_voice(ctx: &ReducerContext, channel_id: u64) {
|
||||
let chan = match ctx.db.channel().id().find(channel_id) {
|
||||
Some(c) => c,
|
||||
None => return report_error(&ctx.db, ctx.sender(), "join_voice", "Channel not found", ctx.timestamp),
|
||||
};
|
||||
|
||||
// RBAC: Check voice permission if it's a server channel
|
||||
if chan.server_id != 0 && !has_permission(&ctx.db, ctx.sender(), chan.server_id, PERM_USE_VOICE) {
|
||||
return report_error(&ctx.db, ctx.sender(), "join_voice", "Insufficient permissions", ctx.timestamp);
|
||||
}
|
||||
|
||||
if let Some(mut state) = ctx.db.user_state().identity().find(ctx.sender()) {
|
||||
if state.channel_id != channel_id {
|
||||
clear_signaling_for_user(&ctx.db, ctx.sender());
|
||||
@@ -718,6 +754,7 @@ pub fn join_voice(ctx: &ReducerContext, channel_id: u64) {
|
||||
watching: None,
|
||||
});
|
||||
}
|
||||
report_success(&ctx.db, ctx.sender(), "join_voice", ctx.timestamp);
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
|
||||
@@ -247,6 +247,19 @@ impl From<RecentMessage> for Message {
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = server_permission)]
|
||||
#[derive(Clone)]
|
||||
pub struct ServerPermission {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
#[index(btree)]
|
||||
pub server_id: u64,
|
||||
#[index(btree)]
|
||||
pub identity: Identity,
|
||||
pub permissions: u128,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = custom_emoji, public)]
|
||||
#[derive(Clone)]
|
||||
pub struct CustomEmoji {
|
||||
|
||||
@@ -475,3 +475,24 @@ pub fn report_success_with_payload(
|
||||
last_update: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
pub const PERM_MANAGE_SERVER: u128 = 0x01;
|
||||
pub const PERM_MANAGE_ROLES: u128 = 0x02;
|
||||
pub const PERM_MANAGE_CHANNELS: u128 = 0x04;
|
||||
pub const PERM_CREATE_INVITES: u128 = 0x08;
|
||||
pub const PERM_KICK_MEMBERS: u128 = 0x10;
|
||||
pub const PERM_BAN_MEMBERS: u128 = 0x20;
|
||||
pub const PERM_MODERATE_MESSAGES: u128 = 0x40;
|
||||
pub const PERM_USE_VOICE: u128 = 0x80;
|
||||
pub const PERM_SHARE_SCREEN: u128 = 0x100;
|
||||
pub const PERM_USE_THREADS: u128 = 0x200;
|
||||
pub const PERM_MANAGE_EMOJIS: u128 = 0x400;
|
||||
pub const PERM_DELETE_SERVER: u128 = 0x800;
|
||||
|
||||
pub fn has_permission(db: &Local, identity: Identity, server_id: u64, bit: u128) -> bool {
|
||||
if let Some(perm) = db.server_permission().server_id().filter(server_id)
|
||||
.find(|p| p.identity == identity) {
|
||||
return (perm.permissions & bit) != 0;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -242,6 +242,22 @@ pub fn visible_thread_messages(ctx: &ViewContext) -> Vec<Message> {
|
||||
results
|
||||
}
|
||||
|
||||
#[spacetimedb::view(accessor = visible_server_permissions, public)]
|
||||
pub fn visible_server_permissions(ctx: &ViewContext) -> Vec<ServerPermission> {
|
||||
let identity = ctx.sender();
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Show me everyone's permissions for servers I share with them
|
||||
// (This allows mods to see who else is a mod)
|
||||
for member in ctx.db.server_member().identity().filter(identity) {
|
||||
for perm in ctx.db.server_permission().server_id().filter(member.server_id) {
|
||||
results.push(perm.clone());
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[spacetimedb::view(accessor = visible_images, public)]
|
||||
pub fn visible_images(ctx: &ViewContext) -> Vec<VisibleImageRow> {
|
||||
let image_ids = get_visible_image_ids_read_only(&ctx.db, ctx.sender());
|
||||
|
||||
@@ -158,17 +158,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="voice-status-actions">
|
||||
<button
|
||||
class="icon-btn {webrtc.isSharingScreen ? 'active danger' : ''}"
|
||||
onclick={() => webrtc.isSharingScreen ? webrtc.stopScreenShare() : webrtc.startScreenShare()}
|
||||
title={webrtc.isSharingScreen ? "Stop Screen Share" : "Share Screen"}
|
||||
>
|
||||
<i class="fas fa-desktop"></i>
|
||||
</button>
|
||||
{#if chat.can(Permissions.SHARE_SCREEN)}
|
||||
<button
|
||||
class="icon-btn {webrtc.isSharingScreen ? 'active danger' : ''}"
|
||||
onclick={() => webrtc.isSharingScreen ? webrtc.stopScreenShare() : webrtc.startScreenShare()}
|
||||
title={webrtc.isSharingScreen ? "Stop Screen Share" : "Share Screen"}
|
||||
>
|
||||
<i class="fas fa-desktop"></i>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="icon-btn danger"
|
||||
onclick={() => chat.handleLeaveVoice()}
|
||||
title="Disconnect"
|
||||
title="Leave Voice"
|
||||
>
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
</button>
|
||||
|
||||
@@ -332,6 +332,19 @@
|
||||
const currentText = messageText;
|
||||
const currentStaged = [...stagedImages];
|
||||
|
||||
// RBAC: Thread Creation requires PERM_USE_THREADS
|
||||
if (!activeThreadId && chat.pendingThreadParentMessageId) {
|
||||
if (!chat.can(Permissions.USE_THREADS)) {
|
||||
chat.confirmModal = {
|
||||
title: "Insufficient Permissions",
|
||||
message: "You do not have permission to start threads in this channel.",
|
||||
confirmText: "Close",
|
||||
onConfirm: () => {}
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const byteLength = new TextEncoder().encode(currentText).length;
|
||||
if (byteLength > chat.maxMessageLength) {
|
||||
uploadError = `Message exceeds maximum length of ${chat.maxMessageLength} bytes (${Math.round(chat.maxMessageLength / 1024)}KB).`;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getContext, tick, untrack } from "svelte";
|
||||
import { portal } from "../../portal";
|
||||
import type { ChatService } from "../services/chat.svelte";
|
||||
import { Permissions } from "../services/chat.svelte";
|
||||
import RichText from "./RichText.svelte";
|
||||
import EmojiPicker from "./EmojiPicker.svelte";
|
||||
import Avatar from "./Avatar.svelte";
|
||||
@@ -164,6 +165,10 @@
|
||||
const msgUsername = $derived(chat.getUsername(msg.sender));
|
||||
const existingThread = $derived(chat.allThreads.find((t) => t.parentMessageId === msg.id));
|
||||
|
||||
const isMe = $derived(chat.identity?.isEqual(msg.sender));
|
||||
const canModerate = $derived(chat.can(Permissions.MODERATE_MESSAGES));
|
||||
const canUseThreads = $derived(chat.can(Permissions.USE_THREADS));
|
||||
|
||||
// Reactions and images are now nested in the message object
|
||||
const emojiGroups = $derived.by(() => {
|
||||
const groups: Record<string, Types.Reaction[]> = {};
|
||||
@@ -355,7 +360,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isThread && chat.isFullyAuthenticated}
|
||||
{#if !isThread && (canUseThreads || existingThread)}
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
onclick={() => chat.handleStartThread(msg)}
|
||||
@@ -365,6 +370,28 @@
|
||||
<i class="fas fa-comment"></i>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isMe || canModerate}
|
||||
<button
|
||||
class="toolbar-btn danger"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chat.confirmModal = {
|
||||
title: "Delete Message",
|
||||
message: "Are you sure you want to delete this message? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
isDanger: true,
|
||||
onConfirm: () => chat.handleDeleteMessage(msg.id)
|
||||
};
|
||||
}}
|
||||
title="Delete Message"
|
||||
aria-label="Delete message"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isGrouped}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
if (codeFromUrl) {
|
||||
finalCode = codeFromUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Not a valid URL, treat as raw code
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getContext } from "svelte";
|
||||
import type { ChatService } from "../services/chat.svelte";
|
||||
import { optimizeEmoji } from "../utils";
|
||||
import { Permissions } from "../services/chat.svelte";
|
||||
import Button from "./ui/Button.svelte";
|
||||
import Input from "./ui/Input.svelte";
|
||||
import Switch from "./ui/Switch.svelte";
|
||||
@@ -19,7 +20,8 @@
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
const server = $derived(chat.activeServer);
|
||||
const isOwner = true; // temporarily enabled for everyone
|
||||
const canManageServer = $derived(chat.can(Permissions.MANAGE_SERVER));
|
||||
const canDeleteServer = $derived(chat.can(Permissions.DELETE_SERVER));
|
||||
|
||||
$effect(() => {
|
||||
if (server) {
|
||||
@@ -143,29 +145,31 @@
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
Leave Server
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="medium"
|
||||
class="sidebar-item-btn"
|
||||
onclick={() => {
|
||||
chat.confirmModal = {
|
||||
title: `Delete '${server?.name}'`,
|
||||
message: `Are you sure you want to delete ${server?.name}? This action is permanent and will delete all channels, messages, and associated data. This cannot be undone.`,
|
||||
confirmText: "Delete Server",
|
||||
cancelText: "Cancel",
|
||||
isDanger: true,
|
||||
onConfirm: async () => {
|
||||
if (server) {
|
||||
chat.handleDeleteServer(server.id);
|
||||
onClose();
|
||||
{#if canDeleteServer}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="medium"
|
||||
class="sidebar-item-btn"
|
||||
onclick={() => {
|
||||
chat.confirmModal = {
|
||||
title: `Delete '${server?.name}'`,
|
||||
message: `Are you sure you want to delete ${server?.name}? This action is permanent and will delete all channels, messages, and associated data. This cannot be undone.`,
|
||||
confirmText: "Delete Server",
|
||||
cancelText: "Cancel",
|
||||
isDanger: true,
|
||||
onConfirm: async () => {
|
||||
if (server) {
|
||||
chat.handleDeleteServer(server.id);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
Delete Server
|
||||
</Button>
|
||||
};
|
||||
}}
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
Delete Server
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="settings-main">
|
||||
<div class="settings-content">
|
||||
@@ -192,7 +196,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isOwner}
|
||||
{#if canManageServer}
|
||||
<label class="btn primary small" style="margin-top: 12px; cursor: pointer;">
|
||||
Change Avatar
|
||||
<input type="file" accept="image/*" onchange={handleAvatarChange} disabled={isUploading} style="display: none;" />
|
||||
@@ -205,14 +209,14 @@
|
||||
id="server-name"
|
||||
label="Server Name"
|
||||
bind:value={serverName}
|
||||
disabled={!isOwner || isUploading}
|
||||
disabled={!canManageServer || isUploading}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Public Server"
|
||||
description="Whether this server is visible in the Discovery tab."
|
||||
bind:checked={isPublic}
|
||||
disabled={!isOwner || isUploading}
|
||||
disabled={!canManageServer || isUploading}
|
||||
style="margin-top: 24px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import type { ChatService } from "../../services/chat.svelte";
|
||||
import { Permissions } from "../../services/chat.svelte";
|
||||
|
||||
const chat = getContext<ChatService>("chat");
|
||||
|
||||
let showServerDropdown = $state(false);
|
||||
|
||||
const isOwner = true; // temporarily enabled for everyone
|
||||
const canManageServer = $derived(chat.can(Permissions.MANAGE_SERVER));
|
||||
const canManageChannels = $derived(chat.can(Permissions.MANAGE_CHANNELS));
|
||||
const canCreateInvites = $derived(chat.can(Permissions.CREATE_INVITES));
|
||||
|
||||
function handleOpenSettings() {
|
||||
chat.showServerSettings = true;
|
||||
@@ -31,20 +34,22 @@
|
||||
|
||||
{#if showServerDropdown}
|
||||
<div class="server-dropdown shadow-box">
|
||||
<button
|
||||
class="server-dropdown-item"
|
||||
onclick={() => {
|
||||
chat.showInviteModal = true;
|
||||
showServerDropdown = false;
|
||||
}}
|
||||
style="color: var(--brand); font-weight: bold;"
|
||||
>
|
||||
<i class="fas fa-user-plus" style="width: 16px; margin-right: 8px;"></i>
|
||||
Invite People
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
{#if canCreateInvites}
|
||||
<button
|
||||
class="server-dropdown-item"
|
||||
onclick={() => {
|
||||
chat.showInviteModal = true;
|
||||
showServerDropdown = false;
|
||||
}}
|
||||
style="color: var(--brand); font-weight: bold;"
|
||||
>
|
||||
<i class="fas fa-user-plus" style="width: 16px; margin-right: 8px;"></i>
|
||||
Invite People
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
{#if canManageServer || canManageChannels}
|
||||
<button
|
||||
class="server-dropdown-item"
|
||||
onclick={handleOpenSettings}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import type { ChatService } from "../../services/chat.svelte";
|
||||
import { Permissions } from "../../services/chat.svelte";
|
||||
|
||||
const chat = getContext<ChatService>("chat");
|
||||
</script>
|
||||
@@ -8,17 +9,19 @@
|
||||
<div class="channel-section">
|
||||
<div class="section-header">
|
||||
<span>TEXT CHANNELS</span>
|
||||
<button
|
||||
class="add-btn"
|
||||
onclick={() => {
|
||||
chat.isVoiceChannel = false;
|
||||
chat.showCreateChannelModal = true;
|
||||
}}
|
||||
title="Create Text Channel"
|
||||
aria-label="Create Text Channel"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{#if chat.can(Permissions.MANAGE_CHANNELS)}
|
||||
<button
|
||||
class="add-btn"
|
||||
onclick={() => {
|
||||
chat.isVoiceChannel = false;
|
||||
chat.showCreateChannelModal = true;
|
||||
}}
|
||||
title="Create Text Channel"
|
||||
aria-label="Create Text Channel"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#each chat.textChannels as channel (channel.id.toString())}
|
||||
{@const messageCount = chat.synchronizedMessages.filter(m => m.channelId === channel.id && !m.threadId).length}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { portal } from "../../../portal";
|
||||
import type { ChatService } from "../../services/chat.svelte";
|
||||
import { Permissions } from "../../services/chat.svelte";
|
||||
import type { WebRTCService } from "../../services/webrtc/webrtc.svelte";
|
||||
import Avatar from "../Avatar.svelte";
|
||||
|
||||
@@ -11,6 +12,8 @@
|
||||
let hoveredPeer = $state<string | null>(null);
|
||||
let popoverPos = $state<{ x: number; y: number } | null>(null);
|
||||
|
||||
const canManageChannels = $derived(chat.can(Permissions.MANAGE_CHANNELS));
|
||||
|
||||
const getStatusColor = (
|
||||
status: string | undefined,
|
||||
): "green" | "yellow" | "red" => {
|
||||
@@ -75,17 +78,19 @@
|
||||
<div class="channel-section">
|
||||
<div class="section-header">
|
||||
<span>VOICE CHANNELS</span>
|
||||
<button
|
||||
class="add-btn"
|
||||
onclick={() => {
|
||||
chat.isVoiceChannel = true;
|
||||
chat.showCreateChannelModal = true;
|
||||
}}
|
||||
title="Create Voice Channel"
|
||||
aria-label="Create Voice Channel"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{#if canManageChannels}
|
||||
<button
|
||||
class="add-btn"
|
||||
onclick={() => {
|
||||
chat.isVoiceChannel = true;
|
||||
chat.showCreateChannelModal = true;
|
||||
}}
|
||||
title="Create Voice Channel"
|
||||
aria-label="Create Voice Channel"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#each chat.voiceChannels as channel (channel.id.toString())}
|
||||
{@const messageCount = chat.synchronizedMessages.filter(m => m.channelId === channel.id && !m.threadId).length}
|
||||
|
||||
@@ -16,6 +16,23 @@ import { EncryptionService } from "./encryption.svelte";
|
||||
import { MediaCacheService } from "./media-cache.svelte";
|
||||
import { sounds } from "./sound.svelte";
|
||||
|
||||
export const Permissions = {
|
||||
MANAGE_SERVER: 0x01n,
|
||||
MANAGE_ROLES: 0x02n,
|
||||
MANAGE_CHANNELS: 0x04n,
|
||||
CREATE_INVITES: 0x08n,
|
||||
KICK_MEMBERS: 0x10n,
|
||||
BAN_MEMBERS: 0x20n,
|
||||
MODERATE_MESSAGES: 0x40n,
|
||||
USE_VOICE: 0x80n,
|
||||
SHARE_SCREEN: 0x100n,
|
||||
USE_THREADS: 0x200n,
|
||||
MANAGE_EMOJIS: 0x400n,
|
||||
DELETE_SERVER: 0x800n,
|
||||
} as const;
|
||||
|
||||
export type PermissionBit = typeof Permissions[keyof typeof Permissions];
|
||||
|
||||
export class ChatService {
|
||||
#db: DatabaseService;
|
||||
#nav: NavigationService;
|
||||
@@ -200,6 +217,22 @@ export class ChatService {
|
||||
return this.#nav.availableServers;
|
||||
}
|
||||
|
||||
get myPermissions() {
|
||||
const myId = this.identity;
|
||||
const serverId = this.activeServerId;
|
||||
if (!myId || !serverId) return 0n;
|
||||
|
||||
return this.#db.serverPermissions.find(p =>
|
||||
p.serverId === serverId && p.identity.isEqual(myId)
|
||||
)?.permissions || 0n;
|
||||
}
|
||||
|
||||
can(bit: PermissionBit | bigint | undefined | null): boolean {
|
||||
if (bit === undefined || bit === null) return false;
|
||||
const b = typeof bit === "bigint" ? bit : BigInt(bit);
|
||||
return (this.myPermissions & b) !== 0n;
|
||||
}
|
||||
|
||||
// Facade Getters/Setters for UI
|
||||
get showCreateServerModal() {
|
||||
return this.#ui.showCreateServerModal;
|
||||
|
||||
@@ -16,6 +16,7 @@ export class DatabaseService {
|
||||
directMessages = $state<readonly Types.DirectMessage[]>([]);
|
||||
users = $state<readonly Types.User[]>([]);
|
||||
serverMembers = $state<readonly Types.ServerMember[]>([]);
|
||||
serverPermissions = $state<readonly Types.ServerPermission[]>([]);
|
||||
threadMessages = $state<readonly Types.Message[]>([]);
|
||||
recentMessages = $state<readonly Types.Message[]>([]);
|
||||
|
||||
@@ -114,6 +115,7 @@ export class DatabaseService {
|
||||
const [usersStore, usersReadyStore] = useTable(tables.user);
|
||||
const [userStatesStore] = useTable(tables.visible_user_states);
|
||||
const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members);
|
||||
const [serverPermissionsStore] = useTable(tables.visible_server_permissions);
|
||||
const [recentMessagesStore] = useTable(tables.visible_recent_activity);
|
||||
const [threadMessagesStore] = useTable(tables.visible_thread_messages);
|
||||
const [imagesStore, imagesReadyStore] = useTable(tables.visible_images);
|
||||
@@ -133,6 +135,7 @@ export class DatabaseService {
|
||||
usersReadyStore.subscribe((v) => (this.isUsersReady = v));
|
||||
userStatesStore.subscribe((v) => (this.userStates = v));
|
||||
serverMembersStore.subscribe((v) => (this.serverMembers = v));
|
||||
serverPermissionsStore.subscribe((v) => (this.serverPermissions = v));
|
||||
membersReadyStore.subscribe((v) => (this.isMembersReady = v));
|
||||
recentMessagesStore.subscribe((v) => (this.recentMessages = v));
|
||||
threadMessagesStore.subscribe((v) => (this.threadMessages = v));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { reducers, tables } from "../../module_bindings";
|
||||
import * as Types from "../../module_bindings/types";
|
||||
import { getConnection } from "../../config";
|
||||
import { untrack } from "svelte";
|
||||
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import type { Identity } from "spacetimedb";
|
||||
|
||||
export class MessagingService {
|
||||
|
||||
Reference in New Issue
Block a user