From 59236f6dba0744cc42b4d0c3112739ca5709236b Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 6 Jul 2026 17:52:56 +0800 Subject: [PATCH] feat: implement role-based access control Introduce 'gm', 'player', and 'observer' roles to the journal system. This includes: - Restricting command usage and completions to the GM role. - Implementing role-based message emission permissions. - Adding a player list and role indicators in the UI. - Persisting the user's role in localStorage. - Updating the connection logic to include the role in the client ID. --- src/components/journal/ComposePanel.tsx | 25 +-- src/components/journal/JournalInput.tsx | 96 ++++++---- src/components/journal/JournalPanel.tsx | 199 +++++++++++++-------- src/components/journal/StreamMessage.tsx | 6 +- src/components/journal/registry.ts | 10 +- src/components/journal/types/article.tsx | 4 +- src/components/journal/types/narrative.tsx | 1 + src/components/stores/journalStream.ts | 78 +++++++- 8 files changed, 269 insertions(+), 150 deletions(-) diff --git a/src/components/journal/ComposePanel.tsx b/src/components/journal/ComposePanel.tsx index c638523..2489bf7 100644 --- a/src/components/journal/ComposePanel.tsx +++ b/src/components/journal/ComposePanel.tsx @@ -4,12 +4,7 @@ import { Component, createSignal, For, Show, createMemo } from "solid-js"; import { registeredTypes, canEmit } from "./registry"; -import { - sendMessage, - revertLatest, - canRevert, - useJournalStream, -} from "../stores/journalStream"; +import { sendMessage, useJournalStream } from "../stores/journalStream"; import type { MessageTypeDef } from "./registry"; import { DynamicForm } from "./DynamicForm"; @@ -20,7 +15,7 @@ export const ComposePanel: Component = () => { const [error, setError] = createSignal(null); const [sending, setSending] = createSignal(false); - const role = () => stream.myName; + const role = () => stream.myRole; // Available types filtered by role const availableTypes = createMemo(() => { @@ -51,12 +46,6 @@ export const ComposePanel: Component = () => { setSending(false); }; - const handleRevert = () => { - revertLatest(); - }; - - const showRevert = () => canRevert() && stream.myName === "gm"; - return (
{/* Type picker */} @@ -81,16 +70,6 @@ export const ComposePanel: Component = () => { > {sending() ? "..." : "Send"} - - - -
{/* Dynamic form */} diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index de9622f..b39825a 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -21,11 +21,7 @@ import { Match, } from "solid-js"; import { sendMessage, useJournalStream } from "../stores/journalStream"; -import { - useJournalCompletions, - ensureCompletions, - type CompletionsState, -} from "./completions"; +import { useJournalCompletions, ensureCompletions } from "./completions"; // ---- Helpers ---- @@ -78,6 +74,10 @@ export const JournalInput: Component = () => { const stream = useJournalStream(); const comp = useJournalCompletions(); + const isObserver = () => stream.myRole === "observer"; + const isPlayer = () => stream.myRole === "player"; + const isGm = () => stream.myRole === "gm"; + const [text, setText] = createSignal(""); const [error, setError] = createSignal(null); const [sending, setSending] = createSignal(false); @@ -98,6 +98,18 @@ export const JournalInput: Component = () => { const raw = text().trim(); if (!raw) return; + // Players can't use commands — everything is narrative + if (isPlayer() || isObserver()) { + const result = sendMessage("narrative", { text: raw }); + if (!result.success) { + setError(result.error); + } else { + setText(""); + } + textareaRef?.focus(); + return; + } + const parsed = parseInput(raw); if (parsed.error) { setError(parsed.error); @@ -132,6 +144,8 @@ export const JournalInput: Component = () => { function buildCompletions(): CompletionItem[] { const raw = text().trim(); + // Only GM gets completions + if (!isGm()) return []; const data = comp.data; const commands = [ { label: "/roll", kind: "command" as const, insertText: "/roll " }, @@ -235,9 +249,9 @@ export const JournalInput: Component = () => { } return; } - // If not open and starts with /, open completions + // If not open and starts with /, open completions (GM only) const raw = text(); - if (raw.startsWith("/")) { + if (isGm() && raw.startsWith("/")) { e.preventDefault(); setShowCompletions(true); setSelectedIdx(0); @@ -280,8 +294,8 @@ export const JournalInput: Component = () => { const raw = input.value; setText(raw); - // Completions visibility — keep open while user types any / - if (raw.startsWith("/")) { + // Completions visibility — keep open while user types any / (GM only) + if (isGm() && raw.startsWith("/")) { setShowCompletions(true); setSelectedIdx(0); } else { @@ -386,35 +400,49 @@ export const JournalInput: Component = () => { {/* Textarea + actions */}
-