@commonpub/layer 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -256,10 +256,15 @@ function apply(): void {
256
256
  border: var(--border-width-default, 2px) dashed var(--accent);
257
257
  }
258
258
 
259
- /* Style vue-advanced-cropper internals to the design system. */
260
- .cpub-cropper :deep(.vue-advanced-cropper__background),
259
+ /* Crop scrim: a theme-INDEPENDENT dark overlay so the crop bounds read clearly
260
+ on every theme. (`--color-surface-scrim` is near-white in light themes, which
261
+ washed the bounds out — a cropper surround must always darken.) The area
262
+ outside the crop frame is dimmed; the crop window stays bright. */
263
+ .cpub-cropper :deep(.vue-advanced-cropper__background) {
264
+ background: #1a1a1a;
265
+ }
261
266
  .cpub-cropper :deep(.vue-advanced-cropper__foreground) {
262
- background: var(--color-surface-scrim, rgba(0, 0, 0, 0.55));
267
+ background: rgba(0, 0, 0, 0.6);
263
268
  }
264
269
  /* Belt-and-suspenders with handlers:{}/lines:{}: never show the resize chrome. */
265
270
  .cpub-cropper :deep(.vue-line-wrapper),
@@ -239,6 +239,10 @@ const dateRange = computed<string>(() => {
239
239
  /* ── BANNER BAND ── full-width, clean, like other content pages' hero banner. */
240
240
  .cpub-hero-banner {
241
241
  width: 100%;
242
+ /* Match the 4:1 upload crop so the banner shows exactly as framed (WYSIWYG)
243
+ instead of being re-cropped by a fixed height. */
244
+ aspect-ratio: 4 / 1;
245
+ max-height: 360px;
242
246
  background: var(--surface2);
243
247
  border-bottom: var(--border-width-default) solid var(--border);
244
248
  overflow: hidden;
@@ -246,9 +250,8 @@ const dateRange = computed<string>(() => {
246
250
  .cpub-hero-banner img {
247
251
  display: block;
248
252
  width: 100%;
249
- max-height: 300px;
253
+ height: 100%;
250
254
  object-fit: cover;
251
- margin: 0 auto;
252
255
  }
253
256
 
254
257
  /* ── HERO BODY ── the contest's dark, patterned section. */
@@ -318,7 +321,7 @@ const dateRange = computed<string>(() => {
318
321
  @media (max-width: 768px) {
319
322
  .cpub-hero-body { padding: 32px 0; }
320
323
  .cpub-hero-inner { padding: 0 16px; }
321
- .cpub-hero-banner img { max-height: 200px; }
324
+ .cpub-hero-banner { max-height: 200px; }
322
325
  .cpub-hero-title { font-size: 24px; }
323
326
  .cpub-hero-meta { gap: 10px; }
324
327
  }
@@ -4,6 +4,8 @@ import type { Serialized, ContestDetail } from '@commonpub/server';
4
4
  const props = defineProps<{
5
5
  contest: Serialized<ContestDetail> | null;
6
6
  isOwner?: boolean;
7
+ /** Viewer can edit this contest (owner / editor / contest.manage). Shows Edit. */
8
+ canManage?: boolean;
7
9
  /** True when the viewer is an accepted, non-guest judge able to score. */
8
10
  canJudge?: boolean;
9
11
  }>();
@@ -89,7 +91,7 @@ function statusClass(status: string): string {
89
91
  </div>
90
92
  </div>
91
93
 
92
- <NuxtLink v-if="isOwner" :to="`/contests/${contest?.slug}/edit`" class="cpub-btn cpub-sb-link">
94
+ <NuxtLink v-if="canManage || isOwner" :to="`/contests/${contest?.slug}/edit`" class="cpub-btn cpub-sb-link">
93
95
  <i class="fa-solid fa-pen-to-square"></i> Edit Contest
94
96
  </NuxtLink>
95
97
 
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { ContestStakeholderItem } from '@commonpub/server';
3
+ import type { StakeholderRole } from '@commonpub/schema';
3
4
 
4
5
  const props = defineProps<{ contestSlug: string }>();
5
6
 
@@ -12,8 +13,12 @@ const searchQuery = ref('');
12
13
  const searchResults = ref<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>([]);
13
14
  const searching = ref(false);
14
15
  const adding = ref(false);
16
+ // Role applied to the next person added.
17
+ const addRole = ref<StakeholderRole>('reviewer');
15
18
  let searchTimeout: ReturnType<typeof setTimeout> | null = null;
16
19
 
20
+ const roleLabel = (r: StakeholderRole): string => (r === 'editor' ? 'Editor' : 'Reviewer');
21
+
17
22
  function handleSearch(): void {
18
23
  if (searchTimeout) clearTimeout(searchTimeout);
19
24
  if (!searchQuery.value || searchQuery.value.length < 2) { searchResults.value = []; return; }
@@ -31,36 +36,46 @@ function handleSearch(): void {
31
36
  }, 300);
32
37
  }
33
38
 
34
- async function addStakeholder(userId: string): Promise<void> {
39
+ async function grant(userId: string, role: StakeholderRole): Promise<void> {
35
40
  adding.value = true;
36
41
  try {
37
- await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId } });
38
- toast.success('Reviewer added');
42
+ await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId, role } });
43
+ toast.success(`${roleLabel(role)} added`);
39
44
  searchQuery.value = '';
40
45
  searchResults.value = [];
41
46
  await refresh();
42
47
  } catch {
43
- toast.error('Failed to add reviewer');
48
+ toast.error(`Failed to add ${roleLabel(role).toLowerCase()}`);
44
49
  } finally {
45
50
  adding.value = false;
46
51
  }
47
52
  }
48
53
 
54
+ async function changeRole(userId: string, role: StakeholderRole): Promise<void> {
55
+ try {
56
+ await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId, role } });
57
+ toast.success(`Role changed to ${roleLabel(role)}`);
58
+ await refresh();
59
+ } catch {
60
+ toast.error('Failed to change role');
61
+ }
62
+ }
63
+
49
64
  async function removeStakeholder(userId: string): Promise<void> {
50
- if (!confirm('Remove this reviewer’s access?')) return;
65
+ if (!confirm('Remove this person’s access to the contest?')) return;
51
66
  try {
52
67
  await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders/${userId}`, { method: 'DELETE' });
53
- toast.success('Reviewer removed');
68
+ toast.success('Access removed');
54
69
  await refresh();
55
70
  } catch {
56
- toast.error('Failed to remove reviewer');
71
+ toast.error('Failed to remove access');
57
72
  }
58
73
  }
59
74
  </script>
60
75
 
61
76
  <template>
62
77
  <div class="cpub-sh">
63
- <p class="cpub-sh-hint">Reviewers can view this contest (even while private/unpublished) but can't edit it or judge.</p>
78
+ <p class="cpub-sh-hint">Reviewers can view this contest (even while private/unpublished) but can't edit or judge. Editors can fully edit this contest. Neither gets any access to the rest of the site.</p>
64
79
  <div v-if="stakeholders?.length" class="cpub-sh-list">
65
80
  <div v-for="s in stakeholders" :key="s.id" class="cpub-sh-row">
66
81
  <NuxtLink :to="`/u/${s.userUsername}`" class="cpub-sh-link">
@@ -70,23 +85,38 @@ async function removeStakeholder(userId: string): Promise<void> {
70
85
  </span>
71
86
  <span class="cpub-sh-name">{{ s.userName }}</span>
72
87
  </NuxtLink>
88
+ <select
89
+ class="cpub-sh-role"
90
+ :value="s.role"
91
+ :aria-label="`Role for ${s.userName}`"
92
+ @change="changeRole(s.userId, ($event.target as HTMLSelectElement).value as StakeholderRole)"
93
+ >
94
+ <option value="reviewer">Reviewer</option>
95
+ <option value="editor">Editor</option>
96
+ </select>
73
97
  <button class="cpub-sh-remove" :aria-label="`Remove ${s.userName}`" @click="removeStakeholder(s.userId)">
74
98
  <i class="fa-solid fa-xmark"></i>
75
99
  </button>
76
100
  </div>
77
101
  </div>
78
- <p v-else class="cpub-sh-empty">No reviewers yet.</p>
102
+ <p v-else class="cpub-sh-empty">No collaborators yet.</p>
79
103
 
80
104
  <div class="cpub-sh-search">
81
- <input
82
- v-model="searchQuery"
83
- class="cpub-sh-input"
84
- placeholder="Search users by name or username..."
85
- aria-label="Search users to add as reviewers"
86
- @input="handleSearch"
87
- />
105
+ <div class="cpub-sh-search-row">
106
+ <input
107
+ v-model="searchQuery"
108
+ class="cpub-sh-input"
109
+ placeholder="Search users by name or username..."
110
+ aria-label="Search users to add as a collaborator"
111
+ @input="handleSearch"
112
+ />
113
+ <select v-model="addRole" class="cpub-sh-role" aria-label="Role to grant">
114
+ <option value="reviewer">Reviewer</option>
115
+ <option value="editor">Editor</option>
116
+ </select>
117
+ </div>
88
118
  <div v-if="searchResults.length" class="cpub-sh-dropdown">
89
- <button v-for="u in searchResults" :key="u.id" class="cpub-sh-option" :disabled="adding" @click="addStakeholder(u.id)">
119
+ <button v-for="u in searchResults" :key="u.id" class="cpub-sh-option" :disabled="adding" @click="grant(u.id, addRole)">
90
120
  <span class="cpub-sh-av cpub-sh-av-sm">
91
121
  <img v-if="u.avatarUrl" :src="u.avatarUrl" :alt="u.displayName || u.username" />
92
122
  <span v-else>{{ (u.displayName || u.username).charAt(0) }}</span>
@@ -110,11 +140,14 @@ async function removeStakeholder(userId: string): Promise<void> {
110
140
  .cpub-sh-av img { width: 100%; height: 100%; object-fit: cover; }
111
141
  .cpub-sh-av-sm { width: 20px; height: 20px; font-size: 8px; }
112
142
  .cpub-sh-name { font-size: 12px; font-weight: 600; }
143
+ .cpub-sh-role { font-size: 11px; padding: 4px 6px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; flex-shrink: 0; }
144
+ .cpub-sh-role:focus { border-color: var(--accent); }
113
145
  .cpub-sh-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 6px; min-height: 28px; }
114
146
  .cpub-sh-remove:hover { color: var(--red); }
115
147
  .cpub-sh-empty { font-size: 12px; color: var(--text-faint); font-style: italic; margin-bottom: 12px; }
116
148
  .cpub-sh-search { position: relative; }
117
- .cpub-sh-input { font-size: 12px; padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; width: 100%; }
149
+ .cpub-sh-search-row { display: flex; gap: 8px; }
150
+ .cpub-sh-input { font-size: 12px; padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; flex: 1; min-width: 0; }
118
151
  .cpub-sh-input:focus { border-color: var(--accent); }
119
152
  .cpub-sh-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-top: 2px; max-height: 200px; overflow-y: auto; }
120
153
  .cpub-sh-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: none; border: none; width: 100%; text-align: left; cursor: pointer; }
@@ -62,7 +62,9 @@ const isCompanyHub = computed(() => hubType.value === 'company');
62
62
  .cpub-hub-hero { position: relative; overflow: hidden; }
63
63
 
64
64
  .cpub-hub-banner {
65
- height: 180px;
65
+ /* Match the 4:1 upload crop so the banner shows exactly as framed (WYSIWYG). */
66
+ aspect-ratio: 4 / 1;
67
+ max-height: 320px;
66
68
  background: linear-gradient(135deg, var(--accent) 0%, var(--teal) 50%, var(--accent-border) 100%);
67
69
  position: relative;
68
70
  overflow: hidden;
@@ -177,7 +179,7 @@ const isCompanyHub = computed(() => hubType.value === 'company');
177
179
  }
178
180
 
179
181
  @media (max-width: 640px) {
180
- .cpub-hub-banner { height: 120px; }
182
+ .cpub-hub-banner { max-height: 160px; }
181
183
  .cpub-hub-meta-inner { flex-direction: column; padding: 0 16px; }
182
184
  .cpub-hub-icon { margin-top: -24px; width: 56px; height: 56px; font-size: 22px; }
183
185
  .cpub-hub-name { font-size: 1.25rem; }
@@ -23,6 +23,9 @@ export interface ClientAuthSession {
23
23
  interface AuthResponse {
24
24
  user: ClientAuthUser | null;
25
25
  session: ClientAuthSession | null;
26
+ /** Effective RBAC permission grants (advisory — server enforces). */
27
+ permissions?: string[];
28
+ roleKeys?: string[];
26
29
  }
27
30
 
28
31
  // `$fetch<T>(url, options)` instantiates Nuxt's NitroFetchRequest generic
@@ -49,6 +52,10 @@ async function authGet(url: string): Promise<AuthResponse | null> {
49
52
  export function useAuth() {
50
53
  const user = useState<ClientAuthUser | null>('auth-user', () => null);
51
54
  const session = useState<ClientAuthSession | null>('auth-session', () => null);
55
+ // Effective RBAC permission grants for the current user (advisory, UX-only —
56
+ // the server is always the enforcement boundary). Populated by refreshSession.
57
+ const permissions = useState<string[]>('auth-permissions', () => []);
58
+ const roleKeys = useState<string[]>('auth-role-keys', () => []);
52
59
 
53
60
  const isAuthenticated = computed(() => !!user.value);
54
61
  const isAdmin = computed(() => user.value?.role === 'admin');
@@ -69,6 +76,8 @@ export function useAuth() {
69
76
  await authPost('/api/auth/sign-out', {});
70
77
  user.value = null;
71
78
  session.value = null;
79
+ permissions.value = [];
80
+ roleKeys.value = [];
72
81
  await navigateTo('/');
73
82
  }
74
83
 
@@ -84,6 +93,8 @@ export function useAuth() {
84
93
  // (a logged-out user gets `{ user: null }`), so mirror it exactly.
85
94
  user.value = data?.user ?? null;
86
95
  session.value = data?.session ?? null;
96
+ permissions.value = data?.permissions ?? [];
97
+ roleKeys.value = data?.roleKeys ?? [];
87
98
  } catch {
88
99
  // A *thrown* error means we couldn't reach /api/me (network blip, 5xx, a
89
100
  // slow/overloaded server timing out). That is NOT evidence the session is
@@ -96,6 +107,8 @@ export function useAuth() {
96
107
  return {
97
108
  user: readonly(user),
98
109
  session: readonly(session),
110
+ permissions: readonly(permissions),
111
+ roleKeys: readonly(roleKeys),
99
112
  isAuthenticated,
100
113
  isAdmin,
101
114
  signIn,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Client-side permission check (UX only — the server enforces via
3
+ * `requirePermission`). Mirrors `hasPermissionPure` (@commonpub/auth): admin
4
+ * floor → `*` → exact match → `<prefix>.*` segment wildcard.
5
+ *
6
+ * const canModerate = useCan('content.moderate')
7
+ * <button v-if="canModerate">…</button>
8
+ *
9
+ * Never gate real access on this alone — always have a server guard behind it.
10
+ */
11
+ export function useCan(key: string): ComputedRef<boolean> {
12
+ const { isAdmin, permissions } = useAuth();
13
+ return computed(() => {
14
+ // Admin floor — admins pass everything (their resolved set is empty server-side).
15
+ if (isAdmin.value) return true;
16
+ const granted = permissions.value;
17
+ if (!granted.length) return false;
18
+ if (granted.includes('*')) return true;
19
+ if (granted.includes(key)) return true;
20
+ const prefix = key.includes('.') ? key.slice(0, key.indexOf('.')) : null;
21
+ return prefix ? granted.includes(`${prefix}.*`) : false;
22
+ });
23
+ }
package/layouts/admin.vue CHANGED
@@ -63,6 +63,9 @@ const { desktopCollapsed, mobileOpen, toggleDesktop, toggleMobile, closeMobile }
63
63
  <NuxtLink to="/admin/users" class="admin-nav-link" :title="desktopCollapsed ? 'Users' : undefined" @click="closeMobile">
64
64
  <i class="fa-solid fa-users"></i><span class="admin-nav-label">Users</span>
65
65
  </NuxtLink>
66
+ <NuxtLink to="/admin/roles" class="admin-nav-link" :title="desktopCollapsed ? 'Roles' : undefined" @click="closeMobile">
67
+ <i class="fa-solid fa-user-shield"></i><span class="admin-nav-label">Roles</span>
68
+ </NuxtLink>
66
69
  <NuxtLink to="/admin/content" class="admin-nav-link" :title="desktopCollapsed ? 'Content' : undefined" @click="closeMobile">
67
70
  <i class="fa-solid fa-newspaper"></i><span class="admin-nav-label">Content</span>
68
71
  </NuxtLink>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -55,16 +55,16 @@
55
55
  "vue-router": "^4.3.0",
56
56
  "zod": "^4.3.6",
57
57
  "@commonpub/auth": "0.8.0",
58
- "@commonpub/docs": "0.6.3",
59
58
  "@commonpub/config": "0.22.1",
60
- "@commonpub/protocol": "0.13.0",
59
+ "@commonpub/editor": "0.7.12",
61
60
  "@commonpub/learning": "0.5.2",
61
+ "@commonpub/protocol": "0.13.0",
62
+ "@commonpub/server": "2.89.0",
62
63
  "@commonpub/theme-studio": "0.6.1",
63
- "@commonpub/schema": "0.44.0",
64
- "@commonpub/server": "2.88.0",
64
+ "@commonpub/docs": "0.6.3",
65
65
  "@commonpub/ui": "0.13.1",
66
66
  "@commonpub/explainer": "0.7.15",
67
- "@commonpub/editor": "0.7.12"
67
+ "@commonpub/schema": "0.45.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@testing-library/jest-dom": "^6.9.1",
@@ -0,0 +1,286 @@
1
+ <script setup lang="ts">
2
+ import type { RoleWithPermissions } from '@commonpub/server';
3
+
4
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
5
+ useSeoMeta({ title: () => `Roles, ${useSiteName()}` });
6
+
7
+ const toast = useToast();
8
+ const { extract: extractError } = useApiError();
9
+ const { rbac: rbacEnabled, features } = useFeatures();
10
+
11
+ const { data: roles, refresh } = await useFetch<RoleWithPermissions[]>('/api/admin/roles');
12
+ const { data: catalog } = await useFetch<string[]>('/api/admin/permissions');
13
+
14
+ // --- Master RBAC switch (configure-then-activate) ---
15
+ // Roles can be staged while RBAC is off; flipping the flag activates them
16
+ // instantly (and is a reversible kill-switch). Writes the DB feature override
17
+ // via /api/admin/features (needs `settings.manage`).
18
+ const togglingRbac = ref(false);
19
+
20
+ async function setRbac(enabled: boolean): Promise<void> {
21
+ if (
22
+ enabled &&
23
+ !confirm(
24
+ 'Enable RBAC now? Role permissions take effect immediately: any user with the staff role becomes a moderator and custom roles activate. Admins keep full access. You can disable it again at any time.',
25
+ )
26
+ ) {
27
+ return;
28
+ }
29
+ togglingRbac.value = true;
30
+ try {
31
+ await ($fetch as Function)('/api/admin/features', { method: 'PUT', body: { overrides: { rbac: enabled } } });
32
+ // Update the shared reactive flag state so the banner reflects it at once.
33
+ features.value = { ...features.value, rbac: enabled };
34
+ toast.success(enabled ? 'RBAC enabled, role permissions are now live' : 'RBAC disabled, back to admin-only');
35
+ } catch (err) {
36
+ toast.error(extractError(err));
37
+ } finally {
38
+ togglingRbac.value = false;
39
+ }
40
+ }
41
+
42
+ // Group the flat permission catalog by first segment for a tidy editor.
43
+ const grouped = computed<Record<string, string[]>>(() => {
44
+ const out: Record<string, string[]> = {};
45
+ for (const key of catalog.value ?? []) {
46
+ const group = key === '*' ? 'global' : key.includes('.') ? key.slice(0, key.indexOf('.')) : key;
47
+ (out[group] ??= []).push(key);
48
+ }
49
+ return out;
50
+ });
51
+
52
+ // --- Edit existing role permissions ---
53
+ const editingId = ref<string | null>(null);
54
+ const editPerms = ref<Set<string>>(new Set());
55
+ const editName = ref('');
56
+ const savingEdit = ref(false);
57
+
58
+ function startEdit(role: RoleWithPermissions): void {
59
+ editingId.value = role.id;
60
+ editName.value = role.name;
61
+ editPerms.value = new Set(role.permissions);
62
+ }
63
+ function cancelEdit(): void {
64
+ editingId.value = null;
65
+ editPerms.value = new Set();
66
+ }
67
+ function toggleEditPerm(key: string): void {
68
+ if (editPerms.value.has(key)) editPerms.value.delete(key);
69
+ else editPerms.value.add(key);
70
+ editPerms.value = new Set(editPerms.value);
71
+ }
72
+ async function saveEdit(role: RoleWithPermissions): Promise<void> {
73
+ savingEdit.value = true;
74
+ try {
75
+ await ($fetch as Function)(`/api/admin/roles/${role.id}`, {
76
+ method: 'PUT',
77
+ body: { name: editName.value, permissions: [...editPerms.value] },
78
+ });
79
+ toast.success('Role updated');
80
+ cancelEdit();
81
+ await refresh();
82
+ } catch (err) {
83
+ toast.error(extractError(err));
84
+ } finally {
85
+ savingEdit.value = false;
86
+ }
87
+ }
88
+
89
+ async function removeRole(role: RoleWithPermissions): Promise<void> {
90
+ if (!confirm(`Delete the "${role.name}" role? Users lose its permissions.`)) return;
91
+ try {
92
+ await ($fetch as Function)(`/api/admin/roles/${role.id}`, { method: 'DELETE' });
93
+ toast.success('Role deleted');
94
+ await refresh();
95
+ } catch (err) {
96
+ toast.error(extractError(err));
97
+ }
98
+ }
99
+
100
+ // --- Create a new custom role ---
101
+ const showCreate = ref(false);
102
+ const newKey = ref('');
103
+ const newName = ref('');
104
+ const newDesc = ref('');
105
+ const newPerms = ref<Set<string>>(new Set());
106
+ const creating = ref(false);
107
+
108
+ function toggleNewPerm(key: string): void {
109
+ if (newPerms.value.has(key)) newPerms.value.delete(key);
110
+ else newPerms.value.add(key);
111
+ newPerms.value = new Set(newPerms.value);
112
+ }
113
+ async function createRole(): Promise<void> {
114
+ if (!newKey.value || !newName.value) { toast.error('Key and name are required'); return; }
115
+ creating.value = true;
116
+ try {
117
+ await ($fetch as Function)('/api/admin/roles', {
118
+ method: 'POST',
119
+ body: { key: newKey.value, name: newName.value, description: newDesc.value || null, permissions: [...newPerms.value] },
120
+ });
121
+ toast.success('Role created');
122
+ showCreate.value = false;
123
+ newKey.value = ''; newName.value = ''; newDesc.value = ''; newPerms.value = new Set();
124
+ await refresh();
125
+ } catch (err) {
126
+ toast.error(extractError(err));
127
+ } finally {
128
+ creating.value = false;
129
+ }
130
+ }
131
+ </script>
132
+
133
+ <template>
134
+ <div class="cpub-roles">
135
+ <div class="cpub-roles-head">
136
+ <h1 class="cpub-admin-title">Roles &amp; Permissions</h1>
137
+ <button class="cpub-btn cpub-btn-sm" @click="showCreate = !showCreate">
138
+ <i class="fa-solid fa-plus"></i> New role
139
+ </button>
140
+ </div>
141
+
142
+ <!-- Master RBAC switch — off: stage roles then activate; on: live + kill-switch. -->
143
+ <div v-if="!rbacEnabled" class="cpub-rbac-banner cpub-rbac-banner--off">
144
+ <div class="cpub-rbac-banner-text">
145
+ <strong>RBAC is off.</strong> These role permissions have no effect yet, the instance runs
146
+ admin-only. Stage your roles and assignments here, then turn RBAC on to activate them all at once.
147
+ </div>
148
+ <button class="cpub-btn cpub-btn-sm" :disabled="togglingRbac" @click="setRbac(true)">
149
+ <i class="fa-solid fa-toggle-on"></i> {{ togglingRbac ? 'Enabling...' : 'Enable RBAC' }}
150
+ </button>
151
+ </div>
152
+ <div v-else class="cpub-rbac-banner cpub-rbac-banner--on">
153
+ <div class="cpub-rbac-banner-text">
154
+ <strong>RBAC is enabled.</strong> Role permissions are live, staff is a moderator and custom
155
+ roles are active. Admins always keep full access.
156
+ </div>
157
+ <button class="cpub-btn cpub-btn-sm cpub-btn-ghost" :disabled="togglingRbac" @click="setRbac(false)">
158
+ <i class="fa-solid fa-toggle-off"></i> {{ togglingRbac ? 'Disabling...' : 'Disable' }}
159
+ </button>
160
+ </div>
161
+
162
+ <!-- Create form -->
163
+ <section v-if="showCreate" class="cpub-role-card cpub-role-create">
164
+ <h2 class="cpub-role-card-title">New custom role</h2>
165
+ <div class="cpub-role-fields">
166
+ <label class="cpub-field">
167
+ <span class="cpub-field-label">Key</span>
168
+ <input v-model="newKey" class="cpub-input" placeholder="e.g. moderator" />
169
+ </label>
170
+ <label class="cpub-field">
171
+ <span class="cpub-field-label">Name</span>
172
+ <input v-model="newName" class="cpub-input" placeholder="e.g. Moderator" />
173
+ </label>
174
+ </div>
175
+ <label class="cpub-field">
176
+ <span class="cpub-field-label">Description</span>
177
+ <input v-model="newDesc" class="cpub-input" placeholder="What this role is for" />
178
+ </label>
179
+ <div class="cpub-perm-groups">
180
+ <fieldset v-for="(keys, group) in grouped" :key="group" class="cpub-perm-group">
181
+ <legend class="cpub-perm-legend">{{ group }}</legend>
182
+ <label v-for="k in keys" :key="k" class="cpub-perm-check">
183
+ <input type="checkbox" :checked="newPerms.has(k)" @change="toggleNewPerm(k)" />
184
+ <span>{{ k }}</span>
185
+ </label>
186
+ </fieldset>
187
+ </div>
188
+ <div class="cpub-role-actions">
189
+ <button class="cpub-btn cpub-btn-sm" :disabled="creating" @click="createRole">
190
+ {{ creating ? 'Creating...' : 'Create role' }}
191
+ </button>
192
+ <button class="cpub-btn cpub-btn-sm cpub-btn-ghost" @click="showCreate = false">Cancel</button>
193
+ </div>
194
+ </section>
195
+
196
+ <!-- Role list -->
197
+ <div class="cpub-role-list">
198
+ <div v-for="role in roles ?? []" :key="role.id" class="cpub-role-card">
199
+ <div class="cpub-role-row">
200
+ <div class="cpub-role-meta">
201
+ <span class="cpub-role-name">{{ role.name }}</span>
202
+ <span class="cpub-role-key">{{ role.key }}</span>
203
+ <span v-if="role.isSystem" class="cpub-role-badge">system</span>
204
+ </div>
205
+ <div class="cpub-role-stats">
206
+ <span>{{ role.memberCount }} {{ role.memberCount === 1 ? 'user' : 'users' }}</span>
207
+ <button class="cpub-btn cpub-btn-xs" @click="editingId === role.id ? cancelEdit() : startEdit(role)">
208
+ {{ editingId === role.id ? 'Close' : 'Edit' }}
209
+ </button>
210
+ <button v-if="!role.isSystem" class="cpub-btn cpub-btn-xs cpub-btn-danger" @click="removeRole(role)">
211
+ Delete
212
+ </button>
213
+ </div>
214
+ </div>
215
+ <p v-if="role.description" class="cpub-role-desc">{{ role.description }}</p>
216
+ <div v-if="editingId !== role.id" class="cpub-role-perms">
217
+ <span v-if="role.permissions.includes('*')" class="cpub-perm-tag cpub-perm-tag-all">* full access</span>
218
+ <span v-for="p in role.permissions.filter((x) => x !== '*')" :key="p" class="cpub-perm-tag">{{ p }}</span>
219
+ <span v-if="!role.permissions.length" class="cpub-role-none">No permissions (entitlement tier only)</span>
220
+ </div>
221
+
222
+ <!-- Inline editor -->
223
+ <div v-else class="cpub-role-edit">
224
+ <label class="cpub-field">
225
+ <span class="cpub-field-label">Name</span>
226
+ <input v-model="editName" class="cpub-input" />
227
+ </label>
228
+ <div class="cpub-perm-groups">
229
+ <fieldset v-for="(keys, group) in grouped" :key="group" class="cpub-perm-group">
230
+ <legend class="cpub-perm-legend">{{ group }}</legend>
231
+ <label v-for="k in keys" :key="k" class="cpub-perm-check">
232
+ <input type="checkbox" :checked="editPerms.has(k)" @change="toggleEditPerm(k)" />
233
+ <span>{{ k }}</span>
234
+ </label>
235
+ </fieldset>
236
+ </div>
237
+ <div class="cpub-role-actions">
238
+ <button class="cpub-btn cpub-btn-sm" :disabled="savingEdit" @click="saveEdit(role)">
239
+ {{ savingEdit ? 'Saving...' : 'Save' }}
240
+ </button>
241
+ <button class="cpub-btn cpub-btn-sm cpub-btn-ghost" @click="cancelEdit">Cancel</button>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </template>
248
+
249
+ <style scoped>
250
+ .cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
251
+ .cpub-roles-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-5); gap: var(--space-3); }
252
+ .cpub-rbac-banner { display: flex; align-items: center; gap: var(--space-4); padding: var(--space-3) var(--space-4); border: var(--border-width-default) solid var(--border); margin-bottom: var(--space-5); }
253
+ .cpub-rbac-banner-text { font-size: var(--text-sm); color: var(--text-dim); line-height: 1.6; flex: 1; min-width: 0; }
254
+ .cpub-rbac-banner-text strong { color: var(--text); }
255
+ .cpub-rbac-banner--off { background: var(--surface2); }
256
+ .cpub-rbac-banner--on { background: var(--green-bg, var(--surface2)); border-color: var(--green-border, var(--accent)); }
257
+ .cpub-rbac-banner .cpub-btn { flex-shrink: 0; }
258
+ .cpub-role-list { display: flex; flex-direction: column; gap: var(--space-3); }
259
+ .cpub-role-card { padding: var(--space-4); background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); }
260
+ .cpub-role-create { margin-bottom: var(--space-5); }
261
+ .cpub-role-card-title { font-size: var(--text-md); font-weight: var(--font-weight-bold); margin-bottom: var(--space-3); }
262
+ .cpub-role-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
263
+ .cpub-role-meta { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
264
+ .cpub-role-name { font-weight: var(--font-weight-bold); }
265
+ .cpub-role-key { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-faint); }
266
+ .cpub-role-badge { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: var(--tracking-wide); padding: 1px 5px; border: var(--border-width-default) solid var(--border); color: var(--text-dim); }
267
+ .cpub-role-stats { display: flex; align-items: center; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-dim); font-family: var(--font-mono); }
268
+ .cpub-role-desc { font-size: var(--text-sm); color: var(--text-dim); margin: var(--space-2) 0 0; }
269
+ .cpub-role-perms { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-3); }
270
+ .cpub-perm-tag { font-family: var(--font-mono); font-size: 10px; padding: 2px 6px; background: var(--surface2); border: var(--border-width-default) solid var(--border); color: var(--text-dim); }
271
+ .cpub-perm-tag-all { color: var(--accent); border-color: var(--accent); }
272
+ .cpub-role-none { font-size: var(--text-xs); color: var(--text-faint); font-style: italic; }
273
+ .cpub-role-edit, .cpub-role-fields { display: flex; flex-direction: column; gap: var(--space-3); margin-top: var(--space-3); }
274
+ .cpub-role-fields { flex-direction: row; }
275
+ .cpub-field { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; }
276
+ .cpub-field-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--text-dim); }
277
+ .cpub-input { font-size: var(--text-sm); padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; }
278
+ .cpub-input:focus { border-color: var(--accent); }
279
+ .cpub-perm-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-3); margin-top: var(--space-2); }
280
+ .cpub-perm-group { border: var(--border-width-default) solid var(--border); padding: var(--space-2) var(--space-3); }
281
+ .cpub-perm-legend { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--text-dim); padding: 0 var(--space-1); }
282
+ .cpub-perm-check { display: flex; align-items: center; gap: var(--space-2); font-family: var(--font-mono); font-size: 11px; padding: 2px 0; cursor: pointer; }
283
+ .cpub-role-actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); }
284
+ .cpub-btn-xs { font-size: 10px; padding: 3px 8px; }
285
+ .cpub-btn-ghost { background: none; }
286
+ </style>
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import type { RoleWithPermissions } from '@commonpub/server';
3
+
2
4
  definePageMeta({ layout: 'admin', middleware: 'auth' });
3
5
  useSeoMeta({ title: `Users, Admin, ${useSiteName()}` });
4
6
 
@@ -9,6 +11,11 @@ const { data: users, refresh } = await useFetch('/api/admin/users', {
9
11
  query: computed(() => ({ search: search.value || undefined })),
10
12
  });
11
13
 
14
+ // Custom (non-system) roles — for per-user assignment. Requires `roles.manage`;
15
+ // useFetch won't crash the page if the viewer lacks it (data stays null).
16
+ const { data: allRoles } = await useFetch<RoleWithPermissions[]>('/api/admin/roles');
17
+ const customRoles = computed<RoleWithPermissions[]>(() => (allRoles.value ?? []).filter((r) => !r.isSystem));
18
+
12
19
  interface AdminUser {
13
20
  id: string;
14
21
  username: string;
@@ -39,6 +46,49 @@ async function changeRole(userId: string, role: string): Promise<void> {
39
46
  }
40
47
  }
41
48
 
49
+ // --- Custom-role assignment (expand a row to edit a user's custom roles) ---
50
+ const expandedUserId = ref<string | null>(null);
51
+ const editingRoleIds = ref<Set<string>>(new Set());
52
+ const savingRoles = ref(false);
53
+
54
+ async function toggleRolesEditor(userId: string): Promise<void> {
55
+ if (expandedUserId.value === userId) {
56
+ expandedUserId.value = null;
57
+ return;
58
+ }
59
+ expandedUserId.value = userId;
60
+ editingRoleIds.value = new Set();
61
+ try {
62
+ const { roleIds } = await $fetch<{ roleIds: string[] }>(`/api/admin/users/${userId}/roles`);
63
+ editingRoleIds.value = new Set(roleIds);
64
+ } catch {
65
+ toast.error('Could not load this user’s roles');
66
+ }
67
+ }
68
+
69
+ function toggleRoleId(roleId: string): void {
70
+ const next = new Set(editingRoleIds.value);
71
+ if (next.has(roleId)) next.delete(roleId);
72
+ else next.add(roleId);
73
+ editingRoleIds.value = next;
74
+ }
75
+
76
+ async function saveRoles(userId: string): Promise<void> {
77
+ savingRoles.value = true;
78
+ try {
79
+ // Only custom role ids are sent; the system/primary role is the dropdown above.
80
+ const customIds = new Set(customRoles.value.map((r) => r.id));
81
+ const roleIds = [...editingRoleIds.value].filter((id) => customIds.has(id));
82
+ await $fetch(`/api/admin/users/${userId}/roles`, { method: 'PUT', body: { roleIds } });
83
+ toast.success('Custom roles updated');
84
+ expandedUserId.value = null;
85
+ } catch {
86
+ toast.error('Failed to update custom roles');
87
+ } finally {
88
+ savingRoles.value = false;
89
+ }
90
+ }
91
+
42
92
  async function toggleStatus(userId: string, currentStatus: string): Promise<void> {
43
93
  const newStatus = currentStatus === 'active' ? 'suspended' : 'active';
44
94
  try {
@@ -78,13 +128,15 @@ async function deleteUser(userId: string, username: string): Promise<void> {
78
128
  <th>Username</th>
79
129
  <th>Email</th>
80
130
  <th>Role</th>
131
+ <th v-if="customRoles.length">Custom roles</th>
81
132
  <th>Status</th>
82
133
  <th>Joined</th>
83
134
  <th>Actions</th>
84
135
  </tr>
85
136
  </thead>
86
137
  <tbody>
87
- <tr v-for="u in userList" :key="u.id">
138
+ <template v-for="u in userList" :key="u.id">
139
+ <tr>
88
140
  <td>
89
141
  <NuxtLink :to="`/u/${u.username}`" class="admin-link">@{{ u.username }}</NuxtLink>
90
142
  </td>
@@ -98,6 +150,11 @@ async function deleteUser(userId: string, username: string): Promise<void> {
98
150
  <option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
99
151
  </select>
100
152
  </td>
153
+ <td v-if="customRoles.length">
154
+ <button class="admin-roles-btn" :aria-expanded="expandedUserId === u.id" @click="toggleRolesEditor(u.id)">
155
+ <i class="fa-solid fa-user-shield"></i> {{ expandedUserId === u.id ? 'Close' : 'Assign' }}
156
+ </button>
157
+ </td>
101
158
  <td>
102
159
  <button
103
160
  class="admin-status-btn"
@@ -114,6 +171,21 @@ async function deleteUser(userId: string, username: string): Promise<void> {
114
171
  </button>
115
172
  </td>
116
173
  </tr>
174
+ <tr v-if="expandedUserId === u.id && customRoles.length" class="admin-roles-row">
175
+ <td :colspan="7">
176
+ <div class="admin-roles-editor">
177
+ <span class="admin-roles-label">Custom roles for @{{ u.username }}:</span>
178
+ <label v-for="r in customRoles" :key="r.id" class="admin-roles-check">
179
+ <input type="checkbox" :checked="editingRoleIds.has(r.id)" @change="toggleRoleId(r.id)" />
180
+ <span>{{ r.name }}</span>
181
+ </label>
182
+ <button class="admin-roles-save" :disabled="savingRoles" @click="saveRoles(u.id)">
183
+ {{ savingRoles ? 'Saving...' : 'Save' }}
184
+ </button>
185
+ </div>
186
+ </td>
187
+ </tr>
188
+ </template>
117
189
  </tbody>
118
190
  </table>
119
191
  </div>
@@ -141,5 +213,13 @@ async function deleteUser(userId: string, username: string): Promise<void> {
141
213
  .admin-status-btn:hover { opacity: 0.8; }
142
214
  .admin-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
143
215
  .admin-delete-btn:hover { color: var(--red); }
216
+ .admin-roles-btn { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border: var(--border-width-default) solid var(--border2); background: var(--surface); color: var(--text-dim); cursor: pointer; }
217
+ .admin-roles-btn:hover { border-color: var(--accent); color: var(--text); }
218
+ .admin-roles-row td { background: var(--surface2); }
219
+ .admin-roles-editor { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 4px 0; }
220
+ .admin-roles-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
221
+ .admin-roles-check { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; }
222
+ .admin-roles-save { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); background: var(--accent); color: var(--accent-contrast, #fff); cursor: pointer; margin-left: auto; }
223
+ .admin-roles-save:disabled { opacity: 0.6; cursor: default; }
144
224
  .admin-empty { color: var(--text-faint); text-align: center; padding: 32px 0; }
145
225
  </style>
@@ -17,6 +17,11 @@ const { data: contest, refresh, status: contestStatus } = useLazyFetch(`/api/con
17
17
  // broken link. Treat idle/pending as "loading", not "not found".
18
18
  const contestLoading = computed(() => contestStatus.value === 'idle' || contestStatus.value === 'pending');
19
19
  const isOwner = computed(() => isAdmin.value || !!(user.value?.id && contest.value?.createdById === user.value.id));
20
+ // Can the viewer edit this contest? Owner, a per-contest `editor`, or a
21
+ // `contest.manage` holder (server-computed `viewerCanManage`). Drives page access
22
+ // + the editing surface. Owner-only surfaces (delete, managing collaborators)
23
+ // stay gated on `isOwner`.
24
+ const canManage = computed(() => isOwner.value || !!contest.value?.viewerCanManage);
20
25
  useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'}, ${useSiteName()}` });
21
26
 
22
27
  const saving = ref(false);
@@ -327,7 +332,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
327
332
  </script>
328
333
 
329
334
  <template>
330
- <div v-if="contest && !isOwner" class="cpub-not-found">
335
+ <div v-if="contest && !canManage" class="cpub-not-found">
331
336
  <p>You don't have permission to edit this contest.</p>
332
337
  <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
333
338
  </div>
@@ -555,11 +560,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
555
560
  </label>
556
561
  </div>
557
562
  </div>
558
- <div class="cpub-subhead">
559
- <h3 class="cpub-form-subtitle">Reviewers</h3>
563
+ <div v-if="isOwner" class="cpub-subhead">
564
+ <h3 class="cpub-form-subtitle">Collaborators</h3>
560
565
  </div>
561
- <p class="cpub-form-hint">Reviewers can view this contest (even while it's private or in draft) without being a judge or an admin, view access scoped to this contest only. They can't edit or score entries.</p>
562
- <ContestStakeholderManager :contest-slug="slug" />
566
+ <p v-if="isOwner" class="cpub-form-hint">Grant per-contest access scoped to this contest only, with no system-wide access. Reviewers can view it (even while private or in draft) but can't edit or score. Editors can fully edit this contest.</p>
567
+ <ContestStakeholderManager v-if="isOwner" :contest-slug="slug" />
563
568
  </section>
564
569
 
565
570
  <!-- Judge panel (single source of truth: contest_judges table) -->
@@ -620,7 +625,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
620
625
  </div>
621
626
  </section>
622
627
 
623
- <section class="cpub-form-section cpub-danger-zone">
628
+ <section v-if="isOwner" class="cpub-form-section cpub-danger-zone">
624
629
  <h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
625
630
  <div class="cpub-danger-row">
626
631
  <div>
@@ -23,6 +23,9 @@ const c = computed(() => contest.value);
23
23
  const entries = computed(() => apiEntriesData.value?.items ?? []);
24
24
  const judges = computed<ContestJudgeItem[]>(() => judgesData.value ?? []);
25
25
  const isOwner = computed(() => isAdmin.value || !!(user.value?.id && c.value?.createdById === user.value.id));
26
+ // Can edit this contest (owner / per-contest editor / contest.manage holder).
27
+ // Drives the Edit affordance; judge/collaborator management stays owner-only.
28
+ const canManage = computed(() => isOwner.value || !!c.value?.viewerCanManage);
26
29
 
27
30
  // Judge state derives entirely from the contest_judges table (single source of
28
31
  // truth) — not the legacy `judges` jsonb column.
@@ -384,7 +387,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
384
387
  </div>
385
388
  </div>
386
389
 
387
- <ContestSidebar :contest="c" :is-owner="isOwner" :can-judge="canJudge" @copy-link="copyLink" />
390
+ <ContestSidebar :contest="c" :is-owner="isOwner" :can-manage="canManage" :can-judge="canJudge" @copy-link="copyLink" />
388
391
  </div>
389
392
  </div>
390
393
  </div>
@@ -0,0 +1,14 @@
1
+ import { PERMISSIONS } from '@commonpub/schema';
2
+
3
+ // The grantable permission catalog — drives the role editor's checkbox list.
4
+ // `*` and `admin.access` are the admin bypass: they're reserved to the `admin`
5
+ // system role and stripped from every other role server-side (see
6
+ // rbac/admin.ts sanitizeGrants), so we don't offer them in the editor (a
7
+ // checkbox for them would silently never stick). Gated on `roles.manage`.
8
+ const ADMIN_BYPASS_GRANTS = new Set(['*', 'admin.access']);
9
+
10
+ export default defineEventHandler((event): string[] => {
11
+ requireFeature('admin');
12
+ requirePermission(event, 'roles.manage');
13
+ return PERMISSIONS.filter((p) => !ADMIN_BYPASS_GRANTS.has(p));
14
+ });
@@ -0,0 +1,25 @@
1
+ import { deleteRole } from '@commonpub/server';
2
+
3
+ // Delete a custom role (system roles are protected). Cascades its grants +
4
+ // user assignments via the FK.
5
+ export default defineEventHandler(async (event): Promise<{ deleted: true }> => {
6
+ requireFeature('admin');
7
+ requirePermission(event, 'roles.manage');
8
+ const db = useDB();
9
+ const actor = requireAuth(event);
10
+ const { id } = parseParams(event, { id: 'uuid' });
11
+
12
+ try {
13
+ await deleteRole(db, id, actor.id);
14
+ } catch (err) {
15
+ if (err instanceof Error && err.message === 'ROLE_NOT_FOUND') {
16
+ throw createError({ statusCode: 404, statusMessage: 'Role not found' });
17
+ }
18
+ if (err instanceof Error && err.message === 'ROLE_IS_SYSTEM') {
19
+ throw createError({ statusCode: 400, statusMessage: 'System roles cannot be deleted' });
20
+ }
21
+ throw err;
22
+ }
23
+ invalidateAllPermissions();
24
+ return { deleted: true };
25
+ });
@@ -0,0 +1,24 @@
1
+ import { updateRole } from '@commonpub/server';
2
+ import { updateRoleSchema } from '@commonpub/schema';
3
+
4
+ // Edit a role's name/description/permissions (system roles included — e.g. tune
5
+ // the staff moderator set). The admin role always keeps its `*` bypass.
6
+ export default defineEventHandler(async (event): Promise<{ ok: true }> => {
7
+ requireFeature('admin');
8
+ requirePermission(event, 'roles.manage');
9
+ const db = useDB();
10
+ const actor = requireAuth(event);
11
+ const { id } = parseParams(event, { id: 'uuid' });
12
+ const input = await parseBody(event, updateRoleSchema);
13
+
14
+ try {
15
+ await updateRole(db, id, input, actor.id);
16
+ } catch (err) {
17
+ if (err instanceof Error && err.message === 'ROLE_NOT_FOUND') {
18
+ throw createError({ statusCode: 404, statusMessage: 'Role not found' });
19
+ }
20
+ throw err;
21
+ }
22
+ invalidateAllPermissions();
23
+ return { ok: true };
24
+ });
@@ -0,0 +1,10 @@
1
+ import { listRolesWithPermissions } from '@commonpub/server';
2
+ import type { RoleWithPermissions } from '@commonpub/server';
3
+
4
+ // List all roles with their permission keys + member counts. Gated on
5
+ // `roles.manage` (RBAC self-administration).
6
+ export default defineEventHandler(async (event): Promise<RoleWithPermissions[]> => {
7
+ requireFeature('admin');
8
+ requirePermission(event, 'roles.manage');
9
+ return listRolesWithPermissions(useDB());
10
+ });
@@ -0,0 +1,27 @@
1
+ import { createRole } from '@commonpub/server';
2
+ import { createRoleSchema } from '@commonpub/schema';
3
+
4
+ // Create a custom role. Gated on `roles.manage`. New grants take effect on the
5
+ // next request (≤ cache TTL); invalidate the whole cache to be immediate.
6
+ export default defineEventHandler(async (event): Promise<{ id: string }> => {
7
+ requireFeature('admin');
8
+ requirePermission(event, 'roles.manage');
9
+ const db = useDB();
10
+ const actor = requireAuth(event);
11
+ const input = await parseBody(event, createRoleSchema);
12
+
13
+ let result;
14
+ try {
15
+ result = await createRole(db, input, actor.id);
16
+ } catch (err) {
17
+ if (err instanceof Error && err.message === 'ROLE_KEY_TAKEN') {
18
+ throw createError({ statusCode: 409, statusMessage: 'A role with that key already exists' });
19
+ }
20
+ if (err instanceof Error && err.message === 'ROLE_KEY_RESERVED') {
21
+ throw createError({ statusCode: 400, statusMessage: 'That key is reserved for a system role' });
22
+ }
23
+ throw err;
24
+ }
25
+ invalidateAllPermissions();
26
+ return result;
27
+ });
@@ -8,5 +8,24 @@ export default defineEventHandler(async (event): Promise<void> => {
8
8
  const { id } = parseParams(event, { id: 'uuid' });
9
9
  const input = await parseBody(event, adminUpdateRoleSchema);
10
10
 
11
- return updateUserRole(db, id, input.role, admin.id);
11
+ // Minting an admin is admin-only: `users.manage` lets a custom role manage
12
+ // non-admin users, but must not be a backdoor to creating admins (which would
13
+ // turn `users.manage` into root). Promotion to the admin system role requires
14
+ // the admin floor itself.
15
+ if (input.role === 'admin') requirePermission(event, 'admin.access');
16
+
17
+ try {
18
+ await updateUserRole(db, id, input.role, admin.id);
19
+ } catch (err) {
20
+ if (err instanceof Error && err.message === 'LAST_ADMIN') {
21
+ throw createError({ statusCode: 400, statusMessage: 'Cannot demote the only admin account' });
22
+ }
23
+ if (err instanceof Error && err.message === 'User not found') {
24
+ throw createError({ statusCode: 404, statusMessage: 'User not found' });
25
+ }
26
+ throw err;
27
+ }
28
+ // The role change alters effective permissions — drop the cached set so the
29
+ // next request resolves fresh (cache lives in the layer; commit happened above).
30
+ invalidatePermissions(id);
12
31
  });
@@ -0,0 +1,10 @@
1
+ import { getUserRoleIds } from '@commonpub/server';
2
+
3
+ // The role IDs a user currently holds (system + custom) — for the admin UI's
4
+ // role assignment checkboxes.
5
+ export default defineEventHandler(async (event): Promise<{ roleIds: string[] }> => {
6
+ requireFeature('admin');
7
+ requirePermission(event, 'roles.manage');
8
+ const { id } = parseParams(event, { id: 'uuid' });
9
+ return { roleIds: await getUserRoleIds(useDB(), id) };
10
+ });
@@ -0,0 +1,17 @@
1
+ import { setUserCustomRoles } from '@commonpub/server';
2
+ import { setUserRolesSchema } from '@commonpub/schema';
3
+
4
+ // Replace a user's CUSTOM (non-system) role assignments. The system/primary role
5
+ // is managed separately via role.put.ts; system role IDs here are ignored.
6
+ export default defineEventHandler(async (event): Promise<{ ok: true }> => {
7
+ requireFeature('admin');
8
+ requirePermission(event, 'roles.manage');
9
+ const db = useDB();
10
+ const actor = requireAuth(event);
11
+ const { id } = parseParams(event, { id: 'uuid' });
12
+ const input = await parseBody(event, setUserRolesSchema);
13
+
14
+ await setUserCustomRoles(db, id, input.roleIds, actor.id);
15
+ invalidatePermissions(id);
16
+ return { ok: true };
17
+ });
@@ -1,8 +1,9 @@
1
- import { getContestBySlug, advanceContestStage } from '@commonpub/server';
1
+ import { getContestBySlug, advanceContestStage, isContestEditor } from '@commonpub/server';
2
2
  import { contestAdvanceSchema } from '@commonpub/schema';
3
3
 
4
4
  // Phase B2 — apply an advancement cut at a review stage (cull the cohort to top-N
5
- // or a manual pick, snapshot scores, advance to the next stage). Owner-gated.
5
+ // or a manual pick, snapshot scores, advance to the next stage). Authorized for
6
+ // the owner, a per-contest `editor`, or a `contest.manage` holder.
6
7
  export default defineEventHandler(async (event): Promise<{ advanced: boolean; advancedCount: number; eliminatedCount: number }> => {
7
8
  requireFeature('contests');
8
9
  const db = useDB();
@@ -13,10 +14,14 @@ export default defineEventHandler(async (event): Promise<{ advanced: boolean; ad
13
14
  const contest = await getContestBySlug(db, slug);
14
15
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
16
 
16
- const result = await advanceContestStage(db, contest.id, user.id, input);
17
+ const canManage =
18
+ ownerOrPermission(event, contest.createdById, 'contest.manage') ||
19
+ (await isContestEditor(db, contest.id, user.id));
20
+
21
+ const result = await advanceContestStage(db, contest.id, user.id, input, canManage);
17
22
  if (!result.advanced) {
18
- const owner = /owner/i.test(result.error ?? '');
19
- throw createError({ statusCode: owner ? 403 : 400, statusMessage: result.error || 'Advancement failed' });
23
+ const denied = /authoriz|owner/i.test(result.error ?? '');
24
+ throw createError({ statusCode: denied ? 403 : 400, statusMessage: result.error || 'Advancement failed' });
20
25
  }
21
26
  return result;
22
27
  });
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, canViewContest } from '@commonpub/server';
1
+ import { getContestBySlug, canViewContest, isContestEditor } from '@commonpub/server';
2
2
  import type { ContestDetail } from '@commonpub/server';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<ContestDetail> => {
@@ -13,5 +13,13 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
13
13
  if (!(await canViewContest(db, contest, user))) {
14
14
  throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
15
  }
16
- return contest;
16
+ // Per-request manage flag for the client (owner / editor / contest.manage).
17
+ // Server stays the enforcement boundary; this only drives UI affordances.
18
+ // Returned as a fresh object (not a mutation of the fetched row) so the
19
+ // per-viewer flag can never leak across requests if getContestBySlug is cached.
20
+ const viewerCanManage = user
21
+ ? ownerOrPermission(event, contest.createdById, 'contest.manage') ||
22
+ (await isContestEditor(db, contest.id, user.id))
23
+ : false;
24
+ return { ...contest, viewerCanManage };
17
25
  });
@@ -1,4 +1,4 @@
1
- import { updateContest } from '@commonpub/server';
1
+ import { updateContest, getContestBySlug, isContestEditor } from '@commonpub/server';
2
2
  import type { ContestDetail } from '@commonpub/server';
3
3
  import { updateContestSchema } from '@commonpub/schema';
4
4
 
@@ -9,9 +9,18 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
9
9
  const { slug } = parseParams(event, { slug: 'string' });
10
10
  const input = await parseBody(event, updateContestSchema);
11
11
 
12
+ // Owner, a per-contest `editor` stakeholder, or a `contest.manage` holder may
13
+ // edit. The owner check inside updateContest covers the owner; pass canManage
14
+ // for the permission/editor paths (editor is also re-checked server-side).
15
+ const contest = await getContestBySlug(db, slug);
16
+ const canManage = contest
17
+ ? ownerOrPermission(event, contest.createdById, 'contest.manage') ||
18
+ (await isContestEditor(db, contest.id, user.id))
19
+ : false;
20
+
12
21
  let result;
13
22
  try {
14
- result = await updateContest(db, slug, user.id, input);
23
+ result = await updateContest(db, slug, user.id, input, canManage);
15
24
  } catch (err) {
16
25
  if (err instanceof Error && err.message === 'SLUG_TAKEN') {
17
26
  throw createError({ statusCode: 409, statusMessage: 'That URL slug is already in use by another contest.' });
@@ -1,11 +1,19 @@
1
1
  import { getContestBySlug, addContestStakeholder } from '@commonpub/server';
2
+ import { stakeholderRoleSchema } from '@commonpub/schema';
2
3
  import { z } from 'zod';
3
4
 
4
- const addStakeholderSchema = z.object({ userId: z.string().uuid() });
5
+ const addStakeholderSchema = z.object({
6
+ userId: z.string().uuid(),
7
+ // 'reviewer' (view-only, default) or 'editor' (full edit rights to THIS
8
+ // contest only). Only owner / contest.manage can add or promote, so an editor
9
+ // cannot mint more editors.
10
+ role: stakeholderRoleSchema.optional(),
11
+ });
5
12
 
6
13
  /**
7
14
  * POST /api/contests/:slug/stakeholders
8
- * Grant a user view-only review access (contest owner or admin only).
15
+ * Grant a user per-contest access (reviewer = view-only, editor = full edit of
16
+ * this contest). Contest owner or a `contest.manage` holder only.
9
17
  */
10
18
  export default defineEventHandler(async (event) => {
11
19
  requireFeature('contests');
@@ -22,6 +30,7 @@ export default defineEventHandler(async (event) => {
22
30
 
23
31
  const body = await parseBody(event, addStakeholderSchema);
24
32
  const result = await addContestStakeholder(db, contest.id, body.userId, {
33
+ role: body.role,
25
34
  contestSlug: slug,
26
35
  contestTitle: contest.title,
27
36
  invitedBy: user.id,
@@ -29,5 +38,5 @@ export default defineEventHandler(async (event) => {
29
38
  if (!result.added) {
30
39
  throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add stakeholder' });
31
40
  }
32
- return { added: true };
41
+ return { added: true, updated: result.updated ?? false };
33
42
  });
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, transitionContestStatus } from '@commonpub/server';
1
+ import { getContestBySlug, transitionContestStatus, isContestEditor } from '@commonpub/server';
2
2
  import type { ContestStatus } from '@commonpub/server';
3
3
  import { contestTransitionSchema } from '@commonpub/schema';
4
4
 
@@ -14,10 +14,15 @@ export default defineEventHandler(async (event): Promise<{ transitioned: boolean
14
14
  throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
15
  }
16
16
 
17
- const result = await transitionContestStatus(db, contest.id, user.id, input.status);
17
+ const canManage =
18
+ ownerOrPermission(event, contest.createdById, 'contest.manage') ||
19
+ (await isContestEditor(db, contest.id, user.id));
20
+
21
+ const result = await transitionContestStatus(db, contest.id, user.id, input.status, canManage);
18
22
 
19
23
  if (!result.transitioned) {
20
- throw createError({ statusCode: 400, statusMessage: result.error || 'Transition failed' });
24
+ const denied = /authoriz|owner/i.test(result.error ?? '');
25
+ throw createError({ statusCode: denied ? 403 : 400, statusMessage: result.error || 'Transition failed' });
21
26
  }
22
27
 
23
28
  return { transitioned: true, newStatus: input.status };
@@ -6,8 +6,15 @@
6
6
  */
7
7
  export default defineEventHandler((event) => {
8
8
  const { user, session } = event.context.auth ?? {};
9
+ // Effective permissions resolved by the auth middleware (RBAC). The admin
10
+ // floor lives in users.role, so the set is empty for admins — useCan() applies
11
+ // the floor client-side. Permissions/roleKeys are advisory (UX only); the
12
+ // server is always the enforcement boundary.
13
+ const resolved = event.context.cpubPermissions;
9
14
  return {
10
15
  user: user ?? null,
11
16
  session: session ?? null,
17
+ permissions: resolved ? [...resolved.permissions] : [],
18
+ roleKeys: resolved?.roleKeys ?? [],
12
19
  };
13
20
  });