@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.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +11 -3
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +54 -20
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useAuth.ts +13 -0
- package/composables/useCan.ts +23 -0
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +43 -18
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +8 -8
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/roles.vue +286 -0
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +81 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -764
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +97 -8
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/admin/permissions.get.ts +14 -0
- package/server/api/admin/roles/[id]/index.delete.ts +25 -0
- package/server/api/admin/roles/[id]/index.put.ts +24 -0
- package/server/api/admin/roles/index.get.ts +10 -0
- package/server/api/admin/roles/index.post.ts +27 -0
- package/server/api/admin/users/[id]/role.put.ts +20 -1
- package/server/api/admin/users/[id]/roles.get.ts +10 -0
- package/server/api/admin/users/[id]/roles.put.ts +17 -0
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/advance.post.ts +10 -5
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/index.get.ts +10 -2
- package/server/api/contests/[slug]/index.put.ts +11 -2
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
- package/server/api/contests/[slug]/transition.post.ts +8 -3
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/me.get.ts +7 -0
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
package/layouts/default.vue
CHANGED
|
@@ -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"
|
|
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"
|
|
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"
|
|
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"
|
|
184
|
-
<NuxtLink to="/dashboard" class="cpub-dropdown-item"
|
|
185
|
-
<NuxtLink to="/settings" class="cpub-dropdown-item"
|
|
186
|
-
<button class="cpub-dropdown-item"
|
|
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"
|
|
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.
|
|
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/
|
|
59
|
-
"@commonpub/
|
|
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/
|
|
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/
|
|
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",
|
package/pages/[type]/index.vue
CHANGED
|
@@ -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>
|
package/pages/admin/api-keys.vue
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/pages/admin/features.vue
CHANGED
|
@@ -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' },
|
|
@@ -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 & 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>
|
package/pages/admin/settings.vue
CHANGED
|
@@ -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
|
-
|
|
44
|
+
toast.error('Failed to save setting');
|
|
44
45
|
} finally {
|
|
45
46
|
saving.value = false;
|
|
46
47
|
}
|