Skip to content

Commit 7b50107

Browse files
committed
email: update admin
1 parent cd6d35a commit 7b50107

7 files changed

Lines changed: 247 additions & 46 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Push the static site (e.g., to GitHub Pages). The landing page and `/admin.html`
7474
- **Supabase storage** — submissions persist in `public.applications`; schema (including `country` column) is auto-created on first call.
7575
- **Mailgun owner notifications** — the function posts to Mailgun so the owner mailbox receives every new lead.
7676
- **No auto-reply to requester** — submitters are not emailed automatically; follow-up is manual from the owner side.
77-
- **Admin dashboard**`/admin.html` lists submissions. Access requires the password that you stored in the function secret (`x-admin-pass` header). If the function is offline, the dashboard shows the locally cached leads.
77+
- **Admin dashboard**`/admin.html` lists submissions and supports deleting requests. Access requires the password that you stored in the function secret (`x-admin-pass` header). If the function is offline, the dashboard shows the locally cached leads.
7878
- **Offline fallback** — when the API is unreachable (or not configured) leads are saved in `localStorage`, so you can later recover them from `/admin`.
7979

8080
## Supabase function behavior
@@ -83,6 +83,7 @@ The deployed function handles:
8383

8484
- `POST /database-access` — validate payload, insert into `applications`, send owner notification to `MAIL_NOTIFY_TO` (if configured), respond with the created ID.
8585
- `GET /database-access` — require `x-admin-pass` header, validate request `Origin` against `ALLOWED_ORIGINS`, apply per-client failed-login throttling (delay + temporary block), return ordered submissions.
86+
- `DELETE /database-access` — require `x-admin-pass`, accept `{ "id": <number> }`, delete one request by ID.
8687
- `OPTIONS` — CORS preflight for allowlisted origins (`content-type` + `x-admin-pass` headers).
8788
- **Schema requirement** — create the table once in Supabase (SQL editor):
8889
```sql

admin.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ <h1 data-i18n="admin.heading"></h1>
2727
<th data-i18n="admin.table.headers.context"></th>
2828
<th data-i18n="admin.table.headers.country"></th>
2929
<th data-i18n="admin.table.headers.created"></th>
30+
<th data-i18n="admin.table.headers.actions"></th>
3031
</tr>
3132
</thead>
3233
<tbody id="applications-body">
3334
<tr>
34-
<td colspan="6" data-i18n="admin.table.empty"></td>
35+
<td colspan="7" data-i18n="admin.table.empty"></td>
3536
</tr>
3637
</tbody>
3738
</table>

assets/css/style.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,21 @@ html:not([data-theme="dark"]) .theme-icon-sun {
10721072
background: color-mix(in srgb, var(--surface-muted) 40%, var(--surface));
10731073
}
10741074

1075+
.admin-action-cell {
1076+
width: 1%;
1077+
white-space: nowrap;
1078+
}
1079+
1080+
.admin-delete-button {
1081+
color: #a93333;
1082+
}
1083+
1084+
.admin-delete-button:hover {
1085+
color: #fff;
1086+
border-color: #b43b3b;
1087+
background: #b43b3b;
1088+
}
1089+
10751090
.modal {
10761091
position: fixed;
10771092
inset: 0;

assets/i18n/en.json

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,13 @@
329329
"company": "Company",
330330
"context": "Context",
331331
"country": "Country",
332-
"created": "Created"
332+
"created": "Created",
333+
"actions": "Actions"
333334
},
335+
"actions": {
336+
"delete": "Delete"
337+
},
338+
"confirmDelete": "Delete this request",
334339
"empty": "Requests are not loaded yet."
335340
},
336341
"modal": {
@@ -354,13 +359,17 @@
354359
"showingCached": "API unavailable. Showing cached requests.",
355360
"validating": "Validating...",
356361
"accessGranted": "Access granted.",
357-
"apiNotConfiguredHint": "API endpoint is not configured. Update assets/js/config.js."
362+
"apiNotConfiguredHint": "API endpoint is not configured. Update assets/js/config.js.",
363+
"deleting": "Deleting request...",
364+
"deleted": "Request deleted."
358365
},
359366
"errors": {
360367
"passwordRequired": "Password is required.",
361368
"apiNotConfigured": "API endpoint is not configured.",
362369
"loadFailed": "Unable to load requests.",
363-
"backendUnreachable": "Backend is unreachable."
370+
"backendUnreachable": "Backend is unreachable.",
371+
"deleteFailed": "Unable to delete request.",
372+
"invalidId": "Invalid request ID."
364373
}
365374
}
366375
}

assets/i18n/ru.json

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,13 @@
329329
"company": "Компания",
330330
"context": "Контекст",
331331
"country": "Страна",
332-
"created": "Создано"
332+
"created": "Создано",
333+
"actions": "Действия"
333334
},
335+
"actions": {
336+
"delete": "Удалить"
337+
},
338+
"confirmDelete": "Удалить заявку",
334339
"empty": "Заявки ещё не загружены."
335340
},
336341
"modal": {
@@ -354,13 +359,17 @@
354359
"showingCached": "API недоступно. Показываем кэшированные заявки.",
355360
"validating": "Проверяем...",
356361
"accessGranted": "Доступ предоставлен.",
357-
"apiNotConfiguredHint": "API-эндпоинт не настроен. Обновите assets/js/config.js."
362+
"apiNotConfiguredHint": "API-эндпоинт не настроен. Обновите assets/js/config.js.",
363+
"deleting": "Удаляем заявку...",
364+
"deleted": "Заявка удалена."
358365
},
359366
"errors": {
360367
"passwordRequired": "Требуется пароль.",
361368
"apiNotConfigured": "API-эндпоинт не настроен.",
362369
"loadFailed": "Не удалось загрузить заявки.",
363-
"backendUnreachable": "Бэкенд недоступен."
370+
"backendUnreachable": "Бэкенд недоступен.",
371+
"deleteFailed": "Не удалось удалить заявку.",
372+
"invalidId": "Некорректный ID заявки."
364373
}
365374
}
366375
}

assets/js/admin.js

Lines changed: 139 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ document.addEventListener('DOMContentLoaded', () => {
99
const tableWrapper = document.querySelector('.table-wrapper');
1010
const refreshButton = document.getElementById('refresh-button');
1111
const storage = window.MB3RStorage;
12+
const TABLE_COLUMN_COUNT = 7;
1213
const getI18n = () => window.MB3RI18n;
1314
const t = (key) => (getI18n()?.t ? getI18n().t(key) : key);
1415
const whenI18nReady = () => getI18n()?.ready || Promise.resolve();
@@ -35,6 +36,9 @@ document.addEventListener('DOMContentLoaded', () => {
3536
}
3637
return null;
3738
};
39+
const isDeletableId = (value) => Number.isInteger(Number(value)) && Number(value) > 0;
40+
const parseDeleteId = (value) => (isDeletableId(value) ? Number(value) : null);
41+
3842
let currentPassword = '';
3943
let hasRemoteSuccess = false;
4044

@@ -88,7 +92,7 @@ document.addEventListener('DOMContentLoaded', () => {
8892
tableBody.innerHTML = '';
8993
const row = document.createElement('tr');
9094
const cell = document.createElement('td');
91-
cell.colSpan = 6;
95+
cell.colSpan = TABLE_COLUMN_COUNT;
9296
cell.textContent = message;
9397
row.appendChild(cell);
9498
tableBody.appendChild(row);
@@ -146,10 +150,48 @@ document.addEventListener('DOMContentLoaded', () => {
146150
tr.appendChild(td);
147151
});
148152

153+
const actionTd = document.createElement('td');
154+
actionTd.className = 'admin-action-cell';
155+
156+
if (isDeletableId(row.id)) {
157+
const deleteButton = document.createElement('button');
158+
deleteButton.type = 'button';
159+
deleteButton.className = 'button button-ghost button-small admin-delete-button';
160+
deleteButton.dataset.deleteId = String(row.id);
161+
deleteButton.textContent = t('admin.table.actions.delete');
162+
deleteButton.setAttribute(
163+
'aria-label',
164+
`${t('admin.table.actions.delete')} #${row.id}`
165+
);
166+
actionTd.appendChild(deleteButton);
167+
} else {
168+
actionTd.textContent = t('common.placeholder');
169+
}
170+
171+
tr.appendChild(actionTd);
149172
tableBody.appendChild(tr);
150173
});
151174
};
152175

176+
const handleAuthError = (error) => {
177+
const isIncorrectPassword = /incorrect password/i.test(String(error.message || ''));
178+
const isRateLimited = error.status === 429;
179+
currentPassword = '';
180+
lockTable();
181+
setAuthStatus(
182+
isRateLimited ? t('admin.status.tooManyAttempts') : error.message,
183+
'error'
184+
);
185+
if (isRateLimited) {
186+
setOverlayMessage(t('admin.status.tooManyAttempts'), 'admin.status.tooManyAttempts');
187+
} else if (isIncorrectPassword) {
188+
setOverlayMessage(t('admin.status.incorrectPassword'), 'admin.status.incorrectPassword');
189+
} else {
190+
setOverlayMessage(error.message || t('admin.errors.loadFailed'));
191+
}
192+
openModal();
193+
};
194+
153195
const fetchApplications = async (password) => {
154196
await whenI18nReady();
155197

@@ -176,12 +218,6 @@ document.addEventListener('DOMContentLoaded', () => {
176218
const data = await response.json().catch(() => ({}));
177219

178220
if (!response.ok) {
179-
if (response.status === 401 || response.status === 403) {
180-
const authError = new Error(data.message || t('admin.errors.loadFailed'));
181-
authError.status = response.status;
182-
throw authError;
183-
}
184-
185221
const error = new Error(data.message || t('admin.errors.loadFailed'));
186222
error.status = response.status;
187223
throw error;
@@ -199,6 +235,52 @@ document.addEventListener('DOMContentLoaded', () => {
199235
}
200236
};
201237

238+
const deleteApplication = async (id, password) => {
239+
await whenI18nReady();
240+
241+
if (!password) {
242+
const error = new Error(t('admin.errors.passwordRequired'));
243+
error.status = 401;
244+
throw error;
245+
}
246+
247+
const endpoint = resolveEndpoint('/applications');
248+
if (!endpoint) {
249+
const configError = new Error(t('admin.errors.apiNotConfigured'));
250+
configError.offline = true;
251+
throw configError;
252+
}
253+
254+
try {
255+
const response = await fetch(endpoint, {
256+
method: 'DELETE',
257+
headers: {
258+
'Content-Type': 'application/json',
259+
'x-admin-pass': password
260+
},
261+
body: JSON.stringify({ id }),
262+
cache: 'no-store'
263+
});
264+
265+
const data = await response.json().catch(() => ({}));
266+
if (!response.ok) {
267+
const error = new Error(data.message || t('admin.errors.deleteFailed'));
268+
error.status = response.status;
269+
throw error;
270+
}
271+
272+
return data;
273+
} catch (error) {
274+
if (!error.status || isOfflineError(error.status)) {
275+
const offlineError = new Error(error.message || t('admin.errors.backendUnreachable'));
276+
offlineError.offline = true;
277+
offlineError.status = error.status;
278+
throw offlineError;
279+
}
280+
throw error;
281+
}
282+
};
283+
202284
const loadApplications = async ({ password = currentPassword, silent = false } = {}) => {
203285
await whenI18nReady();
204286

@@ -222,22 +304,7 @@ document.addEventListener('DOMContentLoaded', () => {
222304
return;
223305
} catch (error) {
224306
if (error.status === 401 || error.status === 403 || error.status === 429) {
225-
const isIncorrectPassword = /incorrect password/i.test(String(error.message || ''));
226-
const isRateLimited = error.status === 429;
227-
currentPassword = '';
228-
lockTable();
229-
setAuthStatus(
230-
isRateLimited ? t('admin.status.tooManyAttempts') : error.message,
231-
'error'
232-
);
233-
if (isRateLimited) {
234-
setOverlayMessage(t('admin.status.tooManyAttempts'), 'admin.status.tooManyAttempts');
235-
} else if (isIncorrectPassword) {
236-
setOverlayMessage(t('admin.status.incorrectPassword'), 'admin.status.incorrectPassword');
237-
} else {
238-
setOverlayMessage(error.message || t('admin.errors.loadFailed'));
239-
}
240-
openModal();
307+
handleAuthError(error);
241308
throw error;
242309
}
243310

@@ -252,10 +319,7 @@ document.addEventListener('DOMContentLoaded', () => {
252319
}
253320

254321
if (!hasRemoteSuccess) {
255-
setAuthStatus(
256-
t('admin.status.apiUnavailable'),
257-
'error'
258-
);
322+
setAuthStatus(t('admin.status.apiUnavailable'), 'error');
259323
currentPassword = '';
260324
lockTable();
261325
setOverlayMessage(t('admin.status.apiUnavailableLater'), 'admin.status.apiUnavailableLater');
@@ -296,6 +360,54 @@ document.addEventListener('DOMContentLoaded', () => {
296360
}
297361
});
298362

363+
tableBody?.addEventListener('click', async (event) => {
364+
const deleteButton = event.target.closest('[data-delete-id]');
365+
if (!deleteButton) {
366+
return;
367+
}
368+
369+
await whenI18nReady();
370+
371+
if (!currentPassword) {
372+
openModal();
373+
return;
374+
}
375+
376+
const applicationId = parseDeleteId(deleteButton.dataset.deleteId);
377+
if (!applicationId) {
378+
setAuthStatus(t('admin.errors.invalidId'), 'error');
379+
return;
380+
}
381+
382+
const confirmed = window.confirm(`${t('admin.table.confirmDelete')} #${applicationId}`);
383+
if (!confirmed) {
384+
return;
385+
}
386+
387+
deleteButton.disabled = true;
388+
setAuthStatus(t('admin.status.deleting'), '');
389+
390+
try {
391+
await deleteApplication(applicationId, currentPassword);
392+
setAuthStatus(t('admin.status.deleted'), 'success');
393+
await loadApplications({ silent: true });
394+
} catch (error) {
395+
if (error.status === 401 || error.status === 403 || error.status === 429) {
396+
handleAuthError(error);
397+
return;
398+
}
399+
400+
if (error.offline) {
401+
setAuthStatus(t('admin.status.apiUnavailableLater'), 'error');
402+
return;
403+
}
404+
405+
setAuthStatus(error.message || t('admin.errors.deleteFailed'), 'error');
406+
} finally {
407+
deleteButton.disabled = false;
408+
}
409+
});
410+
299411
refreshButton?.addEventListener('click', () => {
300412
if (!currentPassword) {
301413
openModal();

0 commit comments

Comments
 (0)