Skip to content

Commit 2a9a5b2

Browse files
Merge operator dashboard and forum UX fixes
Operator dashboard gated on DEFORUM_OPERATOR_SUBS (fails closed): create/ approve/edit sub-forums, cross-forum moderation; front-door Join CTA with the required badge named; sign-out; threaded replies; seed-loss guards; approve/reject race hardened with a concurrency regression test.
2 parents d4cd481 + ff2d9ac commit 2a9a5b2

43 files changed

Lines changed: 3067 additions & 46 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,15 @@ USER_NULLIFIER_HASH_SECRET=replace_me_with_64_hex_chars
2323

2424
# Secret for HMAC-signing the app session cookie. Generate with: openssl rand -hex 32.
2525
SESSION_SECRET=replace_me_with_64_hex_chars
26+
27+
# ── Operator (service-runner) admin dashboard ────────────────────────────────
28+
# Comma-separated allowlist of OPERATOR IDs that may access /admin (create/edit
29+
# sub-forums, approve/reject requests, moderate, manage members/mods). FAIL
30+
# CLOSED: when unset/empty, nobody is an operator and every /admin power 403s.
31+
#
32+
# An "operator ID" is a signed-in user's durable per-account identifier (an HMAC
33+
# of their Minister subject - Deforum never stores the raw subject). Sign in,
34+
# open /admin, and copy the "Your operator ID" value shown there into this list,
35+
# then restart the app. Example:
36+
# DEFORUM_OPERATOR_SUBS=abc123...def,another-operator-id
37+
DEFORUM_OPERATOR_SUBS=

src/lib/components/CommentComposer.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// actor; the client only states intent.
1111
import Button from './Button.svelte';
1212
import TextArea from './TextArea.svelte';
13+
import { invalidateAll } from '$app/navigation';
1314
import {
1415
createPseudonymousComment,
1516
createPublicComment,
@@ -75,8 +76,10 @@
7576
await createPublicComment(postId, { body, parentCommentId });
7677
}
7778
body = '';
79+
// Refresh via SvelteKit's invalidation (keeps scroll + client state) rather
80+
// than a full window reload (P2.2).
7881
if (onCreated) onCreated();
79-
else window.location.reload();
82+
else await invalidateAll();
8083
} catch (e) {
8184
errorMsg = e instanceof Error ? e.message : String(e);
8285
} finally {

src/lib/components/CommentThread.svelte

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,20 @@
1414
import RoleStamp from './RoleStamp.svelte';
1515
import StarButton from './StarButton.svelte';
1616
import ModToolbar from './ModToolbar.svelte';
17+
import CommentComposer from './CommentComposer.svelte';
1718
import { ChevronDown } from './icons';
1819
import { relativeTime } from './format';
20+
import { invalidateAll } from '$app/navigation';
1921
import { stampClass } from '$lib/comment/role-stamp';
20-
import type { CommentNode } from '$lib/view/types';
22+
import type { CommentNode, CommentComposerView } from '$lib/view/types';
2123
2224
let {
2325
node,
2426
depth = 0,
2527
filterClass = 'all',
2628
subforumId = undefined,
29+
postId = undefined,
30+
commentComposer = undefined,
2731
canStar = false,
2832
viewerIsMod = false,
2933
selfPseudonym = null
@@ -33,6 +37,11 @@
3337
filterClass?: 'all' | 'member' | 'non-member';
3438
/** Needed to wire each comment's vote control to the react route. */
3539
subforumId?: string;
40+
/** The thread's post id - needed to post a reply. */
41+
postId?: string;
42+
/** The gate-resolved comment affordances; when the viewer may comment, a Reply
43+
* control opens an inline composer bound to this comment (P1.4). */
44+
commentComposer?: CommentComposerView;
3645
/** Whether the viewer may star a member (the `star` capability). */
3746
canStar?: boolean;
3847
/** Whether the viewer is a current mod (drives the per-comment mod tools). */
@@ -42,6 +51,18 @@
4251
} = $props();
4352
4453
let collapsed = $state(false);
54+
let replying = $state(false);
55+
56+
// A Reply control is offered when the thread is commentable (the composer
57+
// affordances say so) and we have the post id + sub-forum to post against.
58+
const canReply = $derived(
59+
!!commentComposer && commentComposer.canCommentAny && !!postId && !!subforumId
60+
);
61+
62+
async function onReplied() {
63+
replying = false;
64+
await invalidateAll();
65+
}
4566
4667
const cls = $derived(stampClass(node.roleStamp));
4768
const isAnon = $derived(node.attribution === 'anonymous');
@@ -114,6 +135,32 @@
114135
{:else}
115136
<p class="whitespace-pre-wrap break-words text-sm text-ink">{node.body}</p>
116137
{/if}
138+
139+
<!-- Reply affordance (P1.4): opens an inline composer bound to this
140+
comment via parentCommentId, so a member can thread a reply. -->
141+
{#if canReply && !node.hidden}
142+
<div class="mt-1">
143+
<button
144+
type="button"
145+
onclick={() => (replying = !replying)}
146+
class="text-xs font-medium text-ink-muted hover:text-ink"
147+
aria-expanded={replying}
148+
>
149+
{replying ? 'Cancel' : 'Reply'}
150+
</button>
151+
</div>
152+
{#if replying}
153+
<div class="mt-2">
154+
<CommentComposer
155+
postId={postId!}
156+
subforumId={subforumId!}
157+
affordances={commentComposer!}
158+
parentCommentId={node.id}
159+
onCreated={onReplied}
160+
/>
161+
</div>
162+
{/if}
163+
{/if}
117164
</div>
118165
</div>
119166

@@ -125,6 +172,8 @@
125172
depth={depth + 1}
126173
{filterClass}
127174
{subforumId}
175+
{postId}
176+
{commentComposer}
128177
{canStar}
129178
{viewerIsMod}
130179
{selfPseudonym}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<script lang="ts">
2+
// The badge-gating POLICY BUILDER (operator dashboard). Builds a one-level
3+
// allOf / anyOf / atLeast policy over badge leaves, each with its own constraint
4+
// inputs (provider, domain, age, ...). Emits the resulting @ministryofmany/policy
5+
// PolicyNode as JSON via `bind:policyJson` (the form posts it in a hidden input)
6+
// and shows a live plain-language preview so the operator sees exactly what a
7+
// member must prove. The server re-validates with parsePolicy before any write.
8+
import Button from './Button.svelte';
9+
import Tag from './Tag.svelte';
10+
import {
11+
BADGE_CONSTRAINTS,
12+
buildPolicy,
13+
type BuilderLeaf,
14+
type Combinator
15+
} from '$lib/policy/builder';
16+
import { describePolicy } from '$lib/policy/describe';
17+
18+
let {
19+
badgeTypes,
20+
policyJson = $bindable('{"allOf":[]}')
21+
}: {
22+
badgeTypes: string[];
23+
policyJson?: string;
24+
} = $props();
25+
26+
let combinator = $state<Combinator>('allOf');
27+
let atLeastN = $state(1);
28+
let leaves = $state<BuilderLeaf[]>([]);
29+
30+
function addLeaf() {
31+
leaves = [...leaves, { type: badgeTypes[0] ?? 'email-exact', where: {} }];
32+
}
33+
function removeLeaf(i: number) {
34+
leaves = leaves.filter((_, idx) => idx !== i);
35+
}
36+
37+
const policy = $derived(buildPolicy(combinator, leaves, atLeastN));
38+
// Keep the serialized output + the preview in sync.
39+
$effect(() => {
40+
policyJson = JSON.stringify(policy);
41+
});
42+
const preview = $derived(describePolicy(policy));
43+
44+
function fieldsFor(type: string) {
45+
return BADGE_CONSTRAINTS[type] ?? [];
46+
}
47+
</script>
48+
49+
<div class="flex flex-col gap-3">
50+
<div class="flex flex-wrap items-center gap-2">
51+
<span class="text-xs font-medium text-ink-muted">Members must satisfy</span>
52+
<div
53+
class="inline-flex flex-wrap items-center gap-1 rounded-[var(--radius-pill)] bg-surface-2 p-0.5"
54+
>
55+
{#each [{ k: 'allOf', l: 'all of' }, { k: 'anyOf', l: 'any of' }, { k: 'atLeast', l: 'at least N of' }] as opt (opt.k)}
56+
<button
57+
type="button"
58+
onclick={() => (combinator = opt.k as Combinator)}
59+
aria-pressed={combinator === opt.k}
60+
class="rounded-[var(--radius-pill)] px-3 py-1 text-xs font-medium transition-colors {combinator ===
61+
opt.k
62+
? 'bg-accent-soft text-accent'
63+
: 'text-ink-muted hover:bg-surface hover:text-ink'}"
64+
>
65+
{opt.l}
66+
</button>
67+
{/each}
68+
</div>
69+
{#if combinator === 'atLeast'}
70+
<label class="flex items-center gap-1 text-xs text-ink-muted">
71+
N =
72+
<input
73+
type="number"
74+
min="1"
75+
max={Math.max(1, leaves.length)}
76+
bind:value={atLeastN}
77+
class="w-16 rounded-[var(--radius-card)] border border-border bg-surface px-2 py-1 text-ink"
78+
/>
79+
</label>
80+
{/if}
81+
</div>
82+
83+
{#if leaves.length === 0}
84+
<p class="text-xs text-ink-faint">
85+
No badge requirements yet - this sub-forum will be open to anyone signed in. Add a badge to
86+
gate it.
87+
</p>
88+
{/if}
89+
90+
<div class="flex flex-col gap-2">
91+
{#each leaves as leaf, i (i)}
92+
<div class="rounded-[var(--radius-card)] border border-border bg-surface-2/50 p-3">
93+
<div class="flex items-center justify-between gap-2">
94+
<label class="flex flex-1 flex-col gap-1 text-xs">
95+
<span class="font-medium text-ink-muted">Badge type</span>
96+
<select
97+
bind:value={leaf.type}
98+
class="rounded-[var(--radius-card)] border border-border bg-surface px-2 py-1.5 text-sm text-ink"
99+
>
100+
{#each badgeTypes as t (t)}
101+
<option value={t}>{t}</option>
102+
{/each}
103+
</select>
104+
</label>
105+
<Button variant="ghost" size="sm" onclick={() => removeLeaf(i)}>Remove</Button>
106+
</div>
107+
108+
{#if fieldsFor(leaf.type).length > 0}
109+
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
110+
{#each fieldsFor(leaf.type) as field (field.key)}
111+
<label class="flex flex-col gap-1 text-xs">
112+
<span class="font-medium text-ink-muted">{field.label}</span>
113+
{#if field.kind === 'select'}
114+
<select
115+
bind:value={leaf.where[field.key]}
116+
class="rounded-[var(--radius-card)] border border-border bg-surface px-2 py-1.5 text-sm text-ink"
117+
>
118+
<option value="">(any)</option>
119+
{#each field.options ?? [] as opt (opt)}
120+
<option value={String(opt)}>{opt}</option>
121+
{/each}
122+
</select>
123+
{:else}
124+
<input
125+
type={field.kind === 'number' ? 'number' : 'text'}
126+
placeholder={field.placeholder}
127+
bind:value={leaf.where[field.key]}
128+
class="rounded-[var(--radius-card)] border border-border bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-faint"
129+
/>
130+
{/if}
131+
</label>
132+
{/each}
133+
</div>
134+
{:else}
135+
<p class="mt-1 text-xs text-ink-faint">No extra constraints for this badge type.</p>
136+
{/if}
137+
</div>
138+
{/each}
139+
</div>
140+
141+
<div>
142+
<Button variant="secondary" size="sm" onclick={addLeaf}>+ Add badge requirement</Button>
143+
</div>
144+
145+
<div class="rounded-[var(--radius-card)] border border-border bg-surface-2/50 p-2 text-xs">
146+
<span class="mr-1 font-medium text-ink-muted">Preview:</span>
147+
<Tag tone="accent">{preview}</Tag>
148+
</div>
149+
</div>

src/lib/components/PostComposer.svelte

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@
2121
subforumId,
2222
slug,
2323
affordances,
24+
hideDeniedCard = false,
2425
onCreated = undefined
2526
}: {
2627
subforumId: string;
2728
slug: string;
2829
affordances: PostComposerView;
30+
/** Suppress the full-width "no permission to post" card. Set for a non-member,
31+
* where a prominent Join CTA already explains how to gain posting access, so
32+
* the composer stays out of the way (P1.1). */
33+
hideDeniedCard?: boolean;
2934
/** Called with the new post id on success (so the page can navigate). */
3035
onCreated?: (id: string) => void;
3136
} = $props();
@@ -125,11 +130,14 @@
125130
</script>
126131

127132
{#if !affordances.canPostAny}
128-
<Card>
129-
<p class="text-sm text-ink-muted">
130-
You don't have permission to post here. Join the sub-forum or hold the required role to post.
131-
</p>
132-
</Card>
133+
{#if !hideDeniedCard}
134+
<Card>
135+
<p class="text-sm text-ink-muted">
136+
You don't have permission to post here. Join the sub-forum or hold the required role to
137+
post.
138+
</p>
139+
</Card>
140+
{/if}
133141
{:else if !open}
134142
<Button variant="primary" onclick={() => (open = true)}>New post</Button>
135143
{:else}

src/lib/components/PostRow.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import PseudonymChip from './PseudonymChip.svelte';
1010
import RoleStamp from './RoleStamp.svelte';
1111
import Tag from './Tag.svelte';
12-
import { relativeTime, postTypeLabel } from './format';
12+
import { relativeTime, postTypeLabel, postDisplayTitle } from './format';
1313
import type { PostView } from '$lib/view/types';
1414
1515
let {
@@ -48,7 +48,7 @@
4848
<span class="text-accent" title={`pinned (${post.pinnedScope})`}><Pin /></span>
4949
{/if}
5050
<a {href} class="font-medium text-ink hover:text-accent">
51-
{post.title ?? '(untitled)'}
51+
{postDisplayTitle(post)}
5252
</a>
5353
{#if post.type !== 'discussion'}
5454
<Tag tone="accent">{postTypeLabel(post.type)}</Tag>

src/lib/components/TopNav.svelte

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,19 @@
4444
>
4545
Settings
4646
</a>
47+
<a
48+
href="/admin"
49+
class="rounded-[var(--radius-card)] px-2.5 py-1.5 text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink"
50+
>
51+
Admin
52+
</a>
4753
</nav>
4854

4955
<div class="ml-auto flex items-center gap-2">
5056
{#if signedIn}
51-
<span class="text-sm text-ink-muted">signed in</span>
57+
<form method="POST" action="/api/auth/signout">
58+
<Button variant="ghost" size="sm" type="submit">Sign out</Button>
59+
</form>
5260
{:else if ministerEnabled}
5361
<Button variant="primary" size="sm" href="/api/auth/oidc/start"
5462
>Sign in with Minister</Button

0 commit comments

Comments
 (0)