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.
This commit is contained in:
2026-07-06 17:52:56 +08:00
parent e216b94e25
commit 59236f6dba
8 changed files with 269 additions and 150 deletions
+62 -34
View File
@@ -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<string | null>(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 */}
<div class="flex flex-col">
<textarea
ref={textareaRef}
value={text()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder="Type a message, or /roll or /link..."
rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]"
/>
<Show
when={!isObserver()}
fallback={
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
You are observing. Open this panel on another device to join as GM
or player.
</div>
}
>
<textarea
ref={textareaRef}
value={text()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "Type a message, or /roll or /link..."
: "Type a message..."
}
rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]"
/>
{/* Bottom row: error + buttons */}
<div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
</Show>
<div class="flex items-center gap-1 ml-auto">
<button
onClick={handleSend}
disabled={sending() || !text().trim()}
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
{/* Bottom row: error + buttons */}
<div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
</Show>
<div class="flex items-center gap-1 ml-auto">
<button
onClick={handleSend}
disabled={sending() || !text().trim()}
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
transition-colors"
>
Send
</button>
>
Send
</button>
</div>
</div>
</div>
</Show>
</div>
</div>
);