@beforesemicolon/site-builder 0.35.0 → 0.36.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.
@@ -0,0 +1,244 @@
1
+ import * as authManager from './auth-manager.js'
2
+ import { Flashbars, showFlashbar } from './flashbar.js'
3
+ import { Modal, showModal } from './modal.js'
4
+ import { Preview } from './preview.js'
5
+ import { Controls } from './controls/controls.js'
6
+ import {
7
+ config,
8
+ currentPage,
9
+ currentWidget,
10
+ currentWidgetId,
11
+ hasPendingChanges,
12
+ loadConfig,
13
+ loading,
14
+ loadWidget,
15
+ panel,
16
+ publishChanges,
17
+ publishing,
18
+ selectPage,
19
+ selectWidget,
20
+ setLoading,
21
+ setPanel,
22
+ } from './data.js'
23
+ import { WidgetPreview } from './preview-widget.js'
24
+
25
+ const { MARKUP } = window.BFS
26
+ const { html, when, is, isNot, repeat, pick, or } = MARKUP
27
+
28
+ const logout = async () => {
29
+ const doLogout = async () => {
30
+ await authManager.logout()
31
+ }
32
+
33
+ if (hasPendingChanges()) {
34
+ return showModal({
35
+ title: 'Unsaved Changes',
36
+ content:
37
+ 'You have unsaved changes. Are you sure you want to logout?',
38
+ actionLabel: 'Logout',
39
+ action: doLogout,
40
+ })
41
+ }
42
+
43
+ doLogout()
44
+ }
45
+
46
+ const selectionOptions = () => {
47
+ if (!config()) return []
48
+
49
+ if (panel() === 'page') {
50
+ return config().pages.map((pg) => ({
51
+ label: pg.url.replace('.html', ''),
52
+ value: pg.id,
53
+ selected: is(currentPage, (p) => p.id === pg.id),
54
+ }))
55
+ } else {
56
+ return config().widgets.map((w) => ({
57
+ label: w.name,
58
+ value: w.id,
59
+ selected: is(currentWidgetId, (id) => id === w.id),
60
+ }))
61
+ }
62
+ }
63
+
64
+ const onOptionSelectionChange = (e) => {
65
+ if (panel() === 'page') {
66
+ selectPage(
67
+ config()?.pages.find((pg) => pg.id === e.target.value) ?? null
68
+ )
69
+ } else {
70
+ const w = config()?.widgets.find((w) => w.id === e.target.value) ?? null
71
+
72
+ if (w) {
73
+ loadWidget(w.id)
74
+ }
75
+ }
76
+ }
77
+
78
+ html`
79
+ ${when(
80
+ authManager.isAuthenticated,
81
+ () => html`
82
+ <div id="cms-app">
83
+ ${Flashbars}
84
+ ${when(
85
+ publishing,
86
+ html`
87
+ <div
88
+ class="publish-overlay"
89
+ role="status"
90
+ aria-live="polite"
91
+ ></div>
92
+ `
93
+ )}
94
+ <!-- Top Navigation Bar -->
95
+ <header class="cms-header">
96
+ <div class="header-left">
97
+ <img
98
+ src="https://beforesemicolon.com/assets/new-logo-white-on-black@2x.png"
99
+ alt="Before Semicolon logo"
100
+ class="cms-logo"
101
+ height="40"
102
+ />
103
+ <h1>Content Management System</h1>
104
+ </div>
105
+ <div class="header-right">
106
+ <button
107
+ id="publish-btn"
108
+ class="btn btn-primary"
109
+ disabled="${or(
110
+ publishing,
111
+ isNot(hasPendingChanges, true)
112
+ )}"
113
+ onclick="${publishChanges}"
114
+ >
115
+ ${when(
116
+ publishing,
117
+ 'Publishing...',
118
+ 'Publish Changes'
119
+ )}
120
+ </button>
121
+ <button
122
+ id="logout-btn"
123
+ class="btn btn-secondary"
124
+ disabled="${publishing}"
125
+ onclick="${logout}"
126
+ >
127
+ Logout
128
+ </button>
129
+ <div
130
+ class="avatar"
131
+ title="${pick(authManager.user, 'name')}"
132
+ >
133
+ <img
134
+ src="${pick(authManager.user, 'picture')}"
135
+ alt="${pick(authManager.user, 'name')}"
136
+ width="40"
137
+ height="40"
138
+ />
139
+ </div>
140
+ </div>
141
+ </header>
142
+ <!-- Main Content Area -->
143
+ <main class="cms-main">
144
+ <aside class="cms-sidebar">
145
+ <div id="widget-editor" class="widget-editor">
146
+ ${Controls({ disabled: publishing })}
147
+ </div>
148
+ </aside>
149
+ <!-- Right Panel: Live Preview -->
150
+ <section class="cms-preview">
151
+ <div class="row preview-selection">
152
+ <div class="tabs">
153
+ <button
154
+ type="button"
155
+ class="btn ${when(
156
+ is(panel, 'page'),
157
+ 'active'
158
+ )}"
159
+ onclick="${() => {
160
+ setPanel('page')
161
+ selectWidget('', null)
162
+ }}"
163
+ >
164
+ Pages
165
+ </button>
166
+ <button
167
+ type="button"
168
+ class="btn ${when(
169
+ is(panel, 'widgets'),
170
+ 'active'
171
+ )}"
172
+ onclick="${() => {
173
+ if (!currentWidget()) {
174
+ const w = config().widgets[0]
175
+ loadWidget(w.id)
176
+ }
177
+ setPanel('widgets')
178
+ }}"
179
+ >
180
+ Widgets
181
+ </button>
182
+ </div>
183
+ <select
184
+ class="preview-dropdown"
185
+ onchange="${onOptionSelectionChange}"
186
+ >
187
+ ${repeat(
188
+ selectionOptions,
189
+ (item) =>
190
+ html` <option
191
+ value="${item.value}"
192
+ selected="${item.selected}"
193
+ >
194
+ ${item.label}
195
+ </option>`
196
+ )}
197
+ </select>
198
+ </div>
199
+ <div id="preview-container" class="preview-container">
200
+ ${when(is(panel, 'page'), Preview, WidgetPreview)}
201
+ </div>
202
+ </section>
203
+ </main>
204
+ <!-- Loading Overlay -->
205
+ <div
206
+ id="loading-overlay"
207
+ class="loading-overlay ${when(loading, 'show')}"
208
+ >
209
+ <div class="loading-spinner">
210
+ <div class="spinner"></div>
211
+ <p>Loading...</p>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ ${Modal}
216
+ `,
217
+ () => authManager.LoginView
218
+ )}
219
+ `
220
+ .onMount(() => {
221
+ // Initialize authentication
222
+ authManager
223
+ .initialize()
224
+ .then(() => loadConfig())
225
+ .catch((error) => {
226
+ console.error('Auth initialization failed:', error)
227
+ showFlashbar({
228
+ type: 'error',
229
+ title: 'Failed to auth user',
230
+ message: 'Authentication failed. Please try again.',
231
+ })
232
+ })
233
+ .finally(() => {
234
+ setLoading(false)
235
+ })
236
+
237
+ window.addEventListener('beforeunload', (e) => {
238
+ if (hasPendingChanges()) {
239
+ e.preventDefault()
240
+ e.returnValue = ''
241
+ }
242
+ })
243
+ })
244
+ .replace(document.getElementById('app'))
@@ -0,0 +1,275 @@
1
+ import { showFlashbar } from './flashbar.js'
2
+
3
+ const { html, state } = window.BFS.MARKUP
4
+
5
+ const baseUrl = window.location.origin
6
+ const redirectUri = baseUrl + '/admin'
7
+
8
+ export const AUTH0_CONFIG = {
9
+ domain: 'cms-editor.us.auth0.com',
10
+ clientId: 'KL8nUmcNI8Y5mTLQVtftv9hXslWDwWED',
11
+ redirectUri: redirectUri,
12
+ scope: 'openid profile email',
13
+ }
14
+
15
+ // Module state
16
+ let auth0Client = null
17
+ let authStateCallbacks = []
18
+ let initialized = false
19
+ let cachedUser = null
20
+
21
+ const [_isAuthenticated, setIsAuthenticated] = state(false)
22
+ const [_user, setUser] = state(null)
23
+
24
+ export const isAuthenticated = _isAuthenticated
25
+ export const user = _user
26
+
27
+ /**
28
+ * Notify all registered callbacks of auth state change
29
+ * @private
30
+ */
31
+ function notifyAuthStateChange() {
32
+ const currentUser = user()
33
+ const isAuth = currentUser !== null
34
+
35
+ authStateCallbacks.forEach((callback) => {
36
+ callback(isAuth, currentUser)
37
+ })
38
+ }
39
+
40
+ /**
41
+ * Initialize Auth0 client
42
+ */
43
+ export async function initialize() {
44
+ if (initialized) {
45
+ return
46
+ }
47
+
48
+ try {
49
+ // Get Auth0 config from configuration file
50
+ const domain = AUTH0_CONFIG.domain
51
+ const clientId = AUTH0_CONFIG.clientId
52
+
53
+ if (
54
+ !domain ||
55
+ !clientId ||
56
+ domain === 'YOUR_AUTH0_DOMAIN' ||
57
+ clientId === 'YOUR_AUTH0_CLIENT_ID'
58
+ ) {
59
+ throw new Error(
60
+ 'Auth0 configuration missing. Please update admin/auth-config.js with your Auth0 credentials.'
61
+ )
62
+ }
63
+
64
+ // Create Auth0 client
65
+ auth0Client = await window.auth0.createAuth0Client({
66
+ domain,
67
+ clientId,
68
+ authorizationParams: {
69
+ redirect_uri: AUTH0_CONFIG.redirectUri,
70
+ audience: AUTH0_CONFIG.audience || `https://${domain}/api/v2/`,
71
+ scope: AUTH0_CONFIG.scope,
72
+ },
73
+ cacheLocation: 'localstorage',
74
+ })
75
+
76
+ // Handle redirect callback
77
+ if (
78
+ window.location.search.includes('code=') &&
79
+ window.location.search.includes('state=')
80
+ ) {
81
+ await auth0Client.handleRedirectCallback()
82
+ // Clean up URL
83
+ window.history.replaceState(
84
+ {},
85
+ document.title,
86
+ window.location.pathname
87
+ )
88
+ }
89
+
90
+ // Check if user is authenticated
91
+ const isAuthenticated = await auth0Client.isAuthenticated()
92
+
93
+ if (isAuthenticated) {
94
+ // Cache user data
95
+ cachedUser = await auth0Client.getUser()
96
+
97
+ // NEW: Validate email against Netlify Identity
98
+ try {
99
+ const validation = await validateUserEmail(cachedUser.email)
100
+
101
+ if (!validation.authorized) {
102
+ await handleUnauthorizedAccess(validation.message)
103
+ return
104
+ }
105
+
106
+ setIsAuthenticated(true)
107
+ setUser(cachedUser)
108
+ } catch (error) {
109
+ console.error('Email validation error:', error)
110
+ await handleUnauthorizedAccess(
111
+ 'Unable to validate user access. Please try again later.'
112
+ )
113
+ return
114
+ }
115
+ }
116
+
117
+ initialized = true
118
+
119
+ // Notify after user data is cached
120
+ notifyAuthStateChange()
121
+ } catch (error) {
122
+ console.error('Auth0 initialization failed:', error)
123
+ throw error
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Login with Auth0
129
+ */
130
+ export async function login() {
131
+ if (!auth0Client) {
132
+ console.error('Auth0 client not initialized')
133
+ return
134
+ }
135
+
136
+ try {
137
+ await auth0Client.loginWithRedirect({
138
+ authorizationParams: {
139
+ redirect_uri: AUTH0_CONFIG.redirectUri,
140
+ },
141
+ })
142
+ } catch (error) {
143
+ console.error('Login failed:', error)
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Validate user email against Netlify Identity
149
+ * @param {string} email - User email from Auth0 token
150
+ * @returns {Promise<{authorized: boolean, message?: string}>}
151
+ */
152
+ async function validateUserEmail(email) {
153
+ try {
154
+ const response = await fetch('/api/validate-user', {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/json',
158
+ },
159
+ body: JSON.stringify({ email }),
160
+ })
161
+
162
+ if (!response.ok) {
163
+ if (response.status === 403) {
164
+ const result = await response.json()
165
+ return {
166
+ authorized: false,
167
+ message:
168
+ result.message ||
169
+ 'Your email is not authorized to access this admin panel',
170
+ }
171
+ }
172
+ throw new Error(`Validation request failed: ${response.status}`)
173
+ }
174
+
175
+ return await response.json()
176
+ } catch (error) {
177
+ console.error('Email validation failed:', error)
178
+ // On validation API failure, logout user as a safety measure
179
+ throw new Error(
180
+ 'Unable to validate user access. Please try again later.'
181
+ )
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Handle unauthorized access
187
+ * - Display error message
188
+ * - Logout from Auth0
189
+ * - Clear cached user data
190
+ * @param {string} message - Error message to display
191
+ */
192
+ async function handleUnauthorizedAccess(message) {
193
+ try {
194
+ // Clear cached user data
195
+ cachedUser = null
196
+ setIsAuthenticated(false)
197
+ setUser(null)
198
+
199
+ // Display error message
200
+ showFlashbar({
201
+ type: 'error',
202
+ title: 'Access Denied',
203
+ message: 'You are not authorized to access this admin panel.',
204
+ })
205
+
206
+ // Logout from Auth0
207
+ if (auth0Client) {
208
+ await auth0Client.logout({
209
+ logoutParams: {
210
+ returnTo: window.location.origin,
211
+ },
212
+ })
213
+ }
214
+ } catch (error) {
215
+ console.error('Error handling unauthorized access:', error)
216
+ // Force page reload as fallback
217
+ window.location.reload()
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Logout current user
223
+ */
224
+ export async function logout() {
225
+ if (!auth0Client) {
226
+ console.error('Auth0 client not initialized')
227
+ return
228
+ }
229
+
230
+ try {
231
+ cachedUser = null
232
+ await auth0Client.logout({
233
+ logoutParams: {
234
+ returnTo: window.location.origin + '/admin',
235
+ },
236
+ })
237
+ } catch (error) {
238
+ console.error('Logout failed:', error)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Login View Component
244
+ */
245
+ export const LoginView = html`
246
+ <div id="login-screen" class="login-screen">
247
+ <div class="login-container">
248
+ <div class="login-header">
249
+ <img
250
+ src="https://beforesemicolon.com/assets/new-logo-white-on-black@2x.png"
251
+ alt="Before Semicolon logo"
252
+ class="login-logo"
253
+ height="60"
254
+ />
255
+ <h1>CMS Login</h1>
256
+ <p>Please sign in to access the admin panel</p>
257
+ </div>
258
+ <div class="login-form">
259
+ <button
260
+ type="button"
261
+ class="btn btn-primary btn-block"
262
+ onclick="${login}"
263
+ >
264
+ Sign In
265
+ </button>
266
+ </div>
267
+ </div>
268
+ <div class="login-footer">
269
+ <p>
270
+ &copy; ${new Date().getFullYear()} Before Semicolon. All rights
271
+ reserved.
272
+ </p>
273
+ </div>
274
+ </div>
275
+ `
@@ -0,0 +1,22 @@
1
+ const { html } = window.BFS.MARKUP
2
+
3
+ export const renderCodeEditor = (input, path, disabled, label, onChange) => {
4
+ return html`
5
+ <div class="control-field-block code-editor-control">
6
+ <span class="label">${label}</span>
7
+ <div class="code-editor-wrapper">
8
+ <textarea
9
+ class="code-editor"
10
+ disabled="${disabled}"
11
+ oninput="${(e) => {
12
+ onChange(path, e.target.value, 'code')
13
+ Prism.highlightElement(e.target)
14
+ }}"
15
+ rows="10"
16
+ >
17
+ ${input.value || ''}</textarea
18
+ >
19
+ </div>
20
+ </div>
21
+ `
22
+ }