@commonpub/layer 0.81.0 → 0.83.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.
Files changed (212) hide show
  1. package/components/AppToast.vue +1 -1
  2. package/components/ContentAvatar.vue +98 -0
  3. package/components/CpubCriteriaBar.vue +88 -0
  4. package/components/CpubDateTimeField.vue +73 -0
  5. package/components/CpubMarkdown.vue +3 -1
  6. package/components/FormatToggle.vue +2 -2
  7. package/components/ImageUpload.vue +5 -8
  8. package/components/MirrorDetailModal.vue +3 -1
  9. package/components/MirrorRequestApproveModal.vue +3 -1
  10. package/components/ProductEditModal.vue +184 -0
  11. package/components/RemoteFollowDialog.vue +2 -2
  12. package/components/SearchSidebar.vue +14 -21
  13. package/components/ShareToHubModal.vue +3 -1
  14. package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
  15. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
  16. package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
  17. package/components/blocks/BlockCompareColumnsView.vue +92 -0
  18. package/components/blocks/BlockContentRenderer.vue +17 -0
  19. package/components/blocks/BlockCriteriaBarView.vue +25 -0
  20. package/components/blocks/BlockGalleryView.vue +5 -0
  21. package/components/blocks/BlockHtmlView.vue +26 -0
  22. package/components/blocks/BlockImageView.vue +4 -0
  23. package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
  24. package/components/blocks/BlockRoadmapView.vue +84 -0
  25. package/components/blocks/BlockSponsorsView.vue +89 -0
  26. package/components/blocks/BlockTableView.vue +49 -0
  27. package/components/blocks/BlockTabsView.vue +121 -0
  28. package/components/contest/ContestBodyCanvas.vue +155 -0
  29. package/components/contest/ContestCriteriaEditor.vue +79 -0
  30. package/components/contest/ContestEditor.vue +948 -0
  31. package/components/contest/ContestEntries.vue +1 -1
  32. package/components/contest/ContestEntryPrivateData.vue +126 -0
  33. package/components/contest/ContestHero.vue +114 -186
  34. package/components/contest/ContestJudgeManager.vue +6 -4
  35. package/components/contest/ContestJudgingCriteria.vue +5 -21
  36. package/components/contest/ContestPrizes.vue +8 -1
  37. package/components/contest/ContestProposalForm.vue +88 -0
  38. package/components/contest/ContestRules.vue +8 -1
  39. package/components/contest/ContestSidebar.vue +11 -3
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +54 -20
  43. package/components/contest/ContestSubmissionField.vue +141 -0
  44. package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
  45. package/components/contest/blocks/ContestTabPanel.vue +27 -0
  46. package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
  47. package/components/contest/blocks/HtmlBlock.vue +61 -0
  48. package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
  49. package/components/contest/blocks/RoadmapBlock.vue +127 -0
  50. package/components/contest/blocks/SponsorsBlock.vue +127 -0
  51. package/components/contest/blocks/TableBlock.vue +101 -0
  52. package/components/contest/blocks/TabsBlock.vue +168 -0
  53. package/components/editors/ArticleEditor.vue +9 -16
  54. package/components/editors/ExplainerEditor.vue +8 -5
  55. package/components/editors/ProjectEditor.vue +13 -10
  56. package/components/homepage/CustomHtmlSection.vue +11 -2
  57. package/components/hub/HubProducts.vue +4 -2
  58. package/components/nav/NavDropdown.vue +1 -5
  59. package/components/nav/NavLink.vue +2 -0
  60. package/components/views/ArticleView.vue +3 -56
  61. package/components/views/ExplainerView.vue +4 -0
  62. package/components/views/ProjectView.vue +83 -245
  63. package/composables/useAuth.ts +13 -0
  64. package/composables/useCan.ts +23 -0
  65. package/composables/useContestEditor.ts +388 -0
  66. package/composables/useDocsPageTree.ts +154 -0
  67. package/composables/useDocsSiteSettings.ts +107 -0
  68. package/composables/useEditorAutosave.ts +131 -0
  69. package/composables/useEngagement.ts +13 -6
  70. package/composables/useFeatures.ts +9 -1
  71. package/composables/useFileUpload.ts +60 -0
  72. package/composables/useProfileContent.ts +84 -0
  73. package/composables/useSanitize.ts +38 -4
  74. package/composables/useScrollSpy.ts +87 -0
  75. package/layouts/admin.vue +43 -18
  76. package/layouts/default.vue +18 -9
  77. package/nuxt.config.ts +13 -0
  78. package/package.json +8 -8
  79. package/pages/[type]/index.vue +6 -1
  80. package/pages/admin/api-keys.vue +13 -3
  81. package/pages/admin/features.vue +2 -0
  82. package/pages/admin/federation.vue +1 -1
  83. package/pages/admin/layouts/[id].vue +30 -2
  84. package/pages/admin/roles.vue +286 -0
  85. package/pages/admin/settings.vue +2 -1
  86. package/pages/admin/users.vue +81 -1
  87. package/pages/admin/video-categories.vue +203 -0
  88. package/pages/cert/[code].vue +6 -2
  89. package/pages/contests/[slug]/edit.vue +4 -764
  90. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  91. package/pages/contests/[slug]/index.vue +97 -8
  92. package/pages/contests/[slug]/judge.vue +49 -26
  93. package/pages/contests/create.vue +5 -466
  94. package/pages/contests/index.vue +7 -2
  95. package/pages/cookies.vue +1 -1
  96. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  97. package/pages/docs/[siteSlug]/edit.vue +93 -231
  98. package/pages/events/[slug]/edit.vue +20 -20
  99. package/pages/events/create.vue +18 -18
  100. package/pages/events/index.vue +7 -2
  101. package/pages/hubs/[slug]/index.vue +34 -9
  102. package/pages/hubs/[slug]/invites.vue +312 -0
  103. package/pages/hubs/[slug]/members.vue +128 -0
  104. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  105. package/pages/hubs/index.vue +6 -1
  106. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  107. package/pages/learn/index.vue +8 -1
  108. package/pages/messages/index.vue +1 -1
  109. package/pages/mirror/[id].vue +1 -1
  110. package/pages/products/[slug].vue +55 -2
  111. package/pages/products/index.vue +6 -1
  112. package/pages/settings/account.vue +8 -8
  113. package/pages/settings/profile.vue +23 -14
  114. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  115. package/pages/u/[username]/followers.vue +11 -3
  116. package/pages/u/[username]/following.vue +10 -8
  117. package/pages/u/[username]/index.vue +73 -7
  118. package/pages/videos/index.vue +13 -10
  119. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  120. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  121. package/server/api/admin/api-keys/index.get.ts +1 -0
  122. package/server/api/admin/api-keys/index.post.ts +1 -0
  123. package/server/api/admin/federation/refederate.post.ts +18 -1
  124. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  125. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  126. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  127. package/server/api/admin/layouts/[id].delete.ts +1 -4
  128. package/server/api/admin/layouts/[id].get.ts +1 -4
  129. package/server/api/admin/layouts/[id].put.ts +1 -4
  130. package/server/api/admin/permissions.get.ts +14 -0
  131. package/server/api/admin/roles/[id]/index.delete.ts +25 -0
  132. package/server/api/admin/roles/[id]/index.put.ts +24 -0
  133. package/server/api/admin/roles/index.get.ts +10 -0
  134. package/server/api/admin/roles/index.post.ts +27 -0
  135. package/server/api/admin/users/[id]/role.put.ts +20 -1
  136. package/server/api/admin/users/[id]/roles.get.ts +10 -0
  137. package/server/api/admin/users/[id]/roles.put.ts +17 -0
  138. package/server/api/auth/federated/login.post.ts +12 -5
  139. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  140. package/server/api/content/[id]/build.get.ts +11 -0
  141. package/server/api/content/[id]/report.post.ts +2 -0
  142. package/server/api/content/[id]/versions.get.ts +15 -0
  143. package/server/api/contests/[slug]/advance.post.ts +10 -5
  144. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  145. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  146. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  147. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  148. package/server/api/contests/[slug]/export.get.ts +43 -0
  149. package/server/api/contests/[slug]/index.get.ts +10 -2
  150. package/server/api/contests/[slug]/index.put.ts +11 -2
  151. package/server/api/contests/[slug]/judge.post.ts +8 -2
  152. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  153. package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
  154. package/server/api/contests/[slug]/transition.post.ts +8 -3
  155. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  156. package/server/api/contests/index.post.ts +1 -1
  157. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  158. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  159. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  160. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  161. package/server/api/events/[slug]/attendees.get.ts +10 -0
  162. package/server/api/events/[slug].get.ts +9 -0
  163. package/server/api/events/index.get.ts +8 -1
  164. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  165. package/server/api/federation/content/[id]/build.get.ts +10 -0
  166. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  167. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  168. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  169. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  170. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  171. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  172. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  173. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  174. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  175. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  176. package/server/api/me.get.ts +7 -0
  177. package/server/api/products/[id].delete.ts +22 -2
  178. package/server/api/registry/ping.post.ts +17 -3
  179. package/server/api/search/index.get.ts +5 -3
  180. package/server/api/social/bookmark.get.ts +1 -0
  181. package/server/api/social/bookmark.post.ts +1 -0
  182. package/server/api/social/bookmarks.get.ts +1 -0
  183. package/server/api/social/comments/[id].delete.ts +1 -0
  184. package/server/api/social/comments.get.ts +1 -0
  185. package/server/api/social/comments.post.ts +1 -0
  186. package/server/api/social/like.get.ts +1 -0
  187. package/server/api/social/like.post.ts +1 -0
  188. package/server/api/users/[username]/content.get.ts +15 -3
  189. package/server/api/users/[username]/follow.delete.ts +1 -0
  190. package/server/api/users/[username]/follow.post.ts +1 -0
  191. package/server/api/users/[username]/followers.get.ts +2 -1
  192. package/server/api/users/[username]/following.get.ts +2 -1
  193. package/server/middleware/content-ap.ts +8 -3
  194. package/server/middleware/csrf.ts +93 -0
  195. package/server/plugins/federation-hub-sync.ts +48 -17
  196. package/server/plugins/notification-email.ts +22 -3
  197. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  198. package/server/routes/inbox.ts +14 -1
  199. package/server/routes/users/[username]/inbox.ts +13 -1
  200. package/server/utils/inbox.ts +7 -2
  201. package/server/utils/validate.ts +22 -0
  202. package/theme/base.css +5 -0
  203. package/theme/prose.css +20 -0
  204. package/theme/stoa-dark.css +4 -0
  205. package/types/contestBlocks.ts +122 -0
  206. package/utils/contestBlocks.ts +107 -0
  207. package/utils/contestBody.ts +25 -0
  208. package/utils/contestStages.ts +62 -0
  209. package/utils/contestSubmission.ts +97 -0
  210. package/utils/datetime.ts +45 -0
  211. package/utils/projectBlocks.ts +162 -0
  212. package/components/editors/BlogEditor.vue +0 -648
@@ -71,6 +71,15 @@ function handleGlobalKeydown(e: KeyboardEvent): void {
71
71
  }
72
72
  }
73
73
 
74
+ const avatarBtnRef = ref<HTMLButtonElement | null>(null);
75
+
76
+ // Disclosure menu: Esc closes and returns focus to the trigger.
77
+ function closeUserMenu(): void {
78
+ if (!userMenuOpen.value) return;
79
+ userMenuOpen.value = false;
80
+ avatarBtnRef.value?.focus();
81
+ }
82
+
74
83
  // Close menus on click outside
75
84
  function handleClickOutside(e: MouseEvent): void {
76
85
  const target = e.target as HTMLElement;
@@ -162,32 +171,32 @@ const userUsername = computed(() => user.value?.username ?? '');
162
171
  <i class="fa-solid fa-plus"></i> <span class="cpub-new-text">New</span>
163
172
  </NuxtLink>
164
173
  <div class="cpub-user-menu-wrapper">
165
- <button class="cpub-avatar-btn" aria-label="User menu" :aria-expanded="userMenuOpen" @click.stop="userMenuOpen = !userMenuOpen">
174
+ <button ref="avatarBtnRef" class="cpub-avatar-btn" aria-label="User menu" :aria-expanded="userMenuOpen" @click.stop="userMenuOpen = !userMenuOpen" @keydown.esc="closeUserMenu">
166
175
  <span class="cpub-user-avatar">
167
176
  <img v-if="userImage" :src="userImage" :alt="user?.name || user?.username" class="cpub-user-avatar-img" />
168
177
  <span v-else>{{ userInitial }}</span>
169
178
  </span>
170
179
  </button>
171
- <div v-if="userMenuOpen" class="cpub-user-dropdown" role="menu">
180
+ <div v-if="userMenuOpen" class="cpub-user-dropdown" @keydown.esc="closeUserMenu">
172
181
  <!-- Mobile-only: messages/notifications relocated here from
173
182
  the top bar (hidden on desktop, which keeps the icons). -->
174
- <NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
183
+ <NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile" @click="userMenuOpen = false">
175
184
  <i class="fa-solid fa-envelope"></i> Messages
176
185
  <span v-if="unreadMessages > 0" class="cpub-dropdown-count">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
177
186
  </NuxtLink>
178
- <NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
187
+ <NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile" @click="userMenuOpen = false">
179
188
  <i class="fa-solid fa-bell"></i> Notifications
180
189
  <span v-if="unreadCount > 0" class="cpub-dropdown-count">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
181
190
  </NuxtLink>
182
191
  <div class="cpub-dropdown-divider cpub-dropdown-item--mobile" />
183
- <NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-user"></i> Profile</NuxtLink>
184
- <NuxtLink to="/dashboard" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
185
- <NuxtLink to="/settings" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
186
- <button class="cpub-dropdown-item" role="menuitem" @click="setDarkMode(!isDark)">
192
+ <NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-user"></i> Profile</NuxtLink>
193
+ <NuxtLink to="/dashboard" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
194
+ <NuxtLink to="/settings" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
195
+ <button class="cpub-dropdown-item" @click="setDarkMode(!isDark)">
187
196
  <i :class="isDark ? 'fa-solid fa-sun' : 'fa-solid fa-moon'"></i> {{ isDark ? 'Light mode' : 'Dark mode' }}
188
197
  </button>
189
198
  <div class="cpub-dropdown-divider" />
190
- <button class="cpub-dropdown-item" role="menuitem" @click="handleSignOut"><i class="fa-solid fa-right-from-bracket"></i> Sign out</button>
199
+ <button class="cpub-dropdown-item" @click="handleSignOut"><i class="fa-solid fa-right-from-bracket"></i> Sign out</button>
191
200
  </div>
192
201
  </div>
193
202
  </template>
package/nuxt.config.ts CHANGED
@@ -100,6 +100,8 @@ export default defineNuxtConfig({
100
100
  video: true,
101
101
  contests: false,
102
102
  contestStageSubmissions: true,
103
+ contestProposals: false,
104
+ contestPii: false,
103
105
  events: false,
104
106
  learning: true,
105
107
  explainers: true,
@@ -117,6 +119,17 @@ export default defineNuxtConfig({
117
119
  actAsRegistry: false,
118
120
  announceToRegistry: true,
119
121
  publicApiMetricsFederation: false,
122
+ // Nested identity sub-flags must be declared here too, or
123
+ // NUXT_PUBLIC_FEATURES_IDENTITY_* env overrides silently drop (same
124
+ // rule as the booleans above). Mirrors @commonpub/config's
125
+ // IdentityFeatures; all default false.
126
+ identity: {
127
+ linkRemoteAccounts: false,
128
+ signInWithRemote: false,
129
+ actingAs: false,
130
+ remoteInteract: false,
131
+ remotePublish: false,
132
+ },
120
133
  },
121
134
  contentTypes: 'project,blog,explainer',
122
135
  contestCreation: 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.81.0",
3
+ "version": "0.83.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/explainer": "0.7.15",
59
- "@commonpub/config": "0.22.1",
58
+ "@commonpub/config": "0.23.0",
59
+ "@commonpub/editor": "0.8.0",
60
+ "@commonpub/explainer": "0.8.0",
60
61
  "@commonpub/docs": "0.6.3",
61
- "@commonpub/editor": "0.7.12",
62
- "@commonpub/protocol": "0.13.0",
63
- "@commonpub/ui": "0.13.1",
62
+ "@commonpub/protocol": "0.14.0",
64
63
  "@commonpub/learning": "0.5.2",
64
+ "@commonpub/schema": "0.46.0",
65
+ "@commonpub/server": "2.90.0",
65
66
  "@commonpub/theme-studio": "0.6.1",
66
- "@commonpub/schema": "0.44.0",
67
- "@commonpub/server": "2.88.0"
67
+ "@commonpub/ui": "0.13.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@testing-library/jest-dom": "^6.9.1",
@@ -23,7 +23,7 @@ useSeoMeta({
23
23
  const sortBy = ref('recent');
24
24
  const sortOptions = ['recent', 'popular'] as const;
25
25
 
26
- const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
26
+ const { data, pending, error, refresh } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
27
27
  query: computed(() => ({
28
28
  status: 'published',
29
29
  type: contentType.value,
@@ -48,6 +48,11 @@ const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentLis
48
48
  </div>
49
49
 
50
50
  <p v-if="pending" class="cpub-listing-empty"><i class="fa-solid fa-circle-notch fa-spin"></i> Loading...</p>
51
+ <div v-else-if="error" class="cpub-fetch-error">
52
+ <div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
53
+ <div class="cpub-fetch-error-msg">Failed to load {{ contentType }}s.</div>
54
+ <button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
55
+ </div>
51
56
  <div v-else-if="data?.items?.length" class="cpub-listing-grid">
52
57
  <ContentCard v-for="item in data.items" :key="item.id" :item="item" />
53
58
  </div>
@@ -6,6 +6,11 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
6
6
 
7
7
  useSeoMeta({ title: `API Keys, Admin, ${useSiteName()}` });
8
8
 
9
+ // Keys only authenticate when the Public API feature is on (public-api-auth 404s
10
+ // every /api/public/* route otherwise). Surface that so admins don't mint dead keys.
11
+ const { publicApi } = useFeatures();
12
+ const toast = useToast();
13
+
9
14
  interface KeyListResponse {
10
15
  items: AdminApiKeyView[];
11
16
  total: number;
@@ -122,8 +127,9 @@ async function revoke(id: string, name: string): Promise<void> {
122
127
  try {
123
128
  await $fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE' });
124
129
  await refresh();
130
+ toast.success(`Revoked "${name}"`);
125
131
  } catch {
126
- // toast would go here
132
+ toast.error('Failed to revoke key');
127
133
  }
128
134
  }
129
135
 
@@ -185,12 +191,16 @@ function fmtErrorRate(rate: number): string {
185
191
  <input type="checkbox" v-model="includeRevoked" @change="refresh()" />
186
192
  Show revoked
187
193
  </label>
188
- <button class="cpub-btn cpub-btn-primary" @click="showCreate = true">
194
+ <button v-if="publicApi" class="cpub-btn cpub-btn-primary" @click="showCreate = true">
189
195
  <i class="fa-solid fa-plus"></i> New key
190
196
  </button>
191
197
  </div>
192
198
  </header>
193
199
 
200
+ <p v-if="!publicApi" class="cpub-admin-sub" role="status">
201
+ The Public API feature is disabled, so keys created here will not authenticate. Turn it on in Features to use API keys.
202
+ </p>
203
+
194
204
  <!-- One-time token reveal -->
195
205
  <div v-if="createdKey" class="cpub-key-reveal" role="alert">
196
206
  <div class="cpub-key-reveal-head">
@@ -298,7 +308,7 @@ function fmtErrorRate(rate: number): string {
298
308
 
299
309
  <!-- List -->
300
310
  <div v-if="pending" class="cpub-loading">Loading keys...</div>
301
- <p v-else-if="listError" class="cpub-form-error">Failed to load keys.</p>
311
+ <p v-else-if="publicApi && listError" class="cpub-form-error">Failed to load keys.</p>
302
312
  <p v-else-if="!data?.items?.length" class="cpub-empty">
303
313
  No API keys yet. Create one to start consuming <code>/api/public/v1/*</code>.
304
314
  </p>
@@ -25,6 +25,8 @@ const flagMeta: Record<string, { label: string; description: string; icon: strin
25
25
  video: { label: 'Video', description: 'Video content and categories', icon: 'fa-solid fa-video' },
26
26
  contests: { label: 'Contests', description: 'Contest system with judging', icon: 'fa-solid fa-trophy' },
27
27
  contestStageSubmissions: { label: 'Contest Stage Submissions', description: 'Per-stage submission forms for multi-round contests', icon: 'fa-solid fa-file-pen' },
28
+ contestProposals: { label: 'Contest Proposals', description: 'Form-first proposal entries with a draft placeholder project', icon: 'fa-solid fa-clipboard-list' },
29
+ contestPii: { label: 'Contest PII Fields', description: 'Offer personal-data fields (email, address) in submission forms', icon: 'fa-solid fa-user-shield' },
28
30
  learning: { label: 'Learning', description: 'Learning paths and courses', icon: 'fa-solid fa-graduation-cap' },
29
31
  explainers: { label: 'Explainers', description: 'Interactive explainer modules', icon: 'fa-solid fa-lightbulb' },
30
32
  editorial: { label: 'Editorial', description: 'Staff picks and content categories', icon: 'fa-solid fa-pen-fancy' },
@@ -57,7 +57,7 @@ async function removeTrusted(domain: string): Promise<void> {
57
57
  });
58
58
  await refreshTrusted();
59
59
  } catch {
60
- alert('Failed to remove trusted instance');
60
+ toast.error('Failed to remove trusted instance');
61
61
  }
62
62
  }
63
63
 
@@ -17,11 +17,13 @@
17
17
  import type { LayoutRecord } from '@commonpub/server';
18
18
  import { PublishStepError } from '../../../composables/useLayoutEditor';
19
19
  import { useLayoutAnnouncer, narrateUndo, narrateRedo, narrateUndoEmpty, narrateRedoEmpty, narrateRowAdded, narrateRowRemoved } from '../../../composables/useLayoutAnnouncer';
20
- import { useLayoutHistory, addRowCommand, removeRowCommand } from '../../../composables/useLayoutHistory';
20
+ import { useLayoutHistory, addRowCommand, removeRowCommand, insertSectionCommand } from '../../../composables/useLayoutHistory';
21
21
  import { useLayoutHotkeys } from '../../../composables/useLayoutHotkeys';
22
22
  import { DnDProvider } from '@vue-dnd-kit/core';
23
23
  import { useSectionRegistry } from '../../../sections/registry';
24
24
  import { useLayoutResize } from '../../../composables/useLayoutResize';
25
+ import { createSectionFromSpec, paletteDragPayload } from '../../../composables/useLayoutDrag';
26
+ import type { SectionDefinition } from '@commonpub/ui';
25
27
 
26
28
  definePageMeta({
27
29
  layout: 'admin',
@@ -163,6 +165,32 @@ function onAddRow(zoneSlug: string): void {
163
165
  }));
164
166
  }
165
167
 
168
+ /**
169
+ * Keyboard-insert from the palette — WCAG 2.1.1 alternative to drag. Mirrors the
170
+ * drop path (createSectionFromSpec + push); appends to the last row of the first
171
+ * populated zone, adding a row if the layout has none. Direct mutation fires the
172
+ * dirty watcher + autosave, exactly like a drop.
173
+ */
174
+ function onPaletteInsert(section: SectionDefinition): void {
175
+ const draft = editor.draft.value;
176
+ if (!draft || draft.zones.length === 0) return;
177
+ const zone = draft.zones.find((z) => z.rows.length > 0) ?? draft.zones[0]!;
178
+ let row = zone.rows[zone.rows.length - 1];
179
+ // Record each mutation to history so Cmd+Z reverses it and the undo stack
180
+ // stays in sync with the draft (mirrors onAddRow + the LayoutRow drop path).
181
+ if (!row) {
182
+ const position = zone.rows.length;
183
+ row = { id: crypto.randomUUID(), order: position, config: null, sections: [] };
184
+ zone.rows.push(row);
185
+ history.record(addRowCommand({ zoneSlug: zone.zone, position, row, label: `add row to ${zone.zone}` }));
186
+ }
187
+ const newSection = createSectionFromSpec(paletteDragPayload(section));
188
+ const at = row.sections.length;
189
+ row.sections.push(newSection);
190
+ history.record(insertSectionCommand({ rowId: row.id, at, section: newSection, label: `insert ${section.type}` }));
191
+ useLayoutAnnouncer().announce(`Inserted ${section.name} section into ${zone.zone}`);
192
+ }
193
+
166
194
  /**
167
195
  * Session 164 polish — Remove row. Confirm before removing rows with
168
196
  * sections (destructive intent: section data goes away, only restorable
@@ -702,7 +730,7 @@ async function onConflictForceSave(): Promise<void> {
702
730
  :on-add-row="onAddRow"
703
731
  :on-remove-row="onRemoveRow"
704
732
  />
705
- <AdminLayoutsPalette v-show="!chrome.paletteHidden.value" />
733
+ <AdminLayoutsPalette v-show="!chrome.paletteHidden.value" @insert="onPaletteInsert" />
706
734
  <AdminLayoutsInspector
707
735
  v-show="!chrome.inspectorHidden.value"
708
736
  :draft="editor.draft.value"
@@ -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>
@@ -4,6 +4,7 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
4
4
  useSeoMeta({ title: `Settings, Admin, ${useSiteName()}` });
5
5
 
6
6
  const { data: settings, pending, refresh } = await useFetch<Record<string, string>>('/api/admin/settings');
7
+ const toast = useToast();
7
8
 
8
9
  const saving = ref(false);
9
10
  const editKey = ref('');
@@ -40,7 +41,7 @@ async function saveSetting(key: string, value: string): Promise<void> {
40
41
  editValue.value = '';
41
42
  await refresh();
42
43
  } catch {
43
- // Error handling via toast if available
44
+ toast.error('Failed to save setting');
44
45
  } finally {
45
46
  saving.value = false;
46
47
  }