mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(admin): GitHub OAuth login, admin whitelist and Nuxt WebUI
Add an admin WebUI (Nuxt static SPA in admin/, served via the ASSETS binding) for managing routes and viewing send logs. Access is gated by GitHub OAuth plus an ADMIN_USER_IDS whitelist with cookie sessions (admin-session.ts). Expose routes/logs CRUD API (admin-routes.ts), add a discord-link mapping in token-store.ts, and mount admin routes with an SPA assets fallback in the server.
This commit is contained in:
parent
cfdf37e273
commit
407514f3c9
20 changed files with 3242 additions and 13 deletions
6
admin/app.vue
Normal file
6
admin/app.vue
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<template>
|
||||
<div class="shell">
|
||||
<NuxtPage />
|
||||
<AppToasts />
|
||||
</div>
|
||||
</template>
|
||||
867
admin/assets/css/main.css
Normal file
867
admin/assets/css/main.css
Normal file
|
|
@ -0,0 +1,867 @@
|
|||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f3f4f6;
|
||||
--surface-3: #eceef2;
|
||||
--border: #e6e8ec;
|
||||
--border-strong: #d4d8de;
|
||||
--text: #0f172a;
|
||||
--muted: #5b6472;
|
||||
--faint: #9aa3b2;
|
||||
--accent: #4f46e5;
|
||||
--accent-strong: #4338ca;
|
||||
--accent-dim: #eef2ff;
|
||||
--accent-text: #3730a3;
|
||||
--ok: #16a34a;
|
||||
--ok-dim: #ecfdf3;
|
||||
--bad: #dc2626;
|
||||
--bad-dim: #fef2f2;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--ui: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(15, 23, 42, 0.05) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), transparent 40%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 32px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2.5px;
|
||||
color: var(--faint);
|
||||
margin-top: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: var(--ui);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-2);
|
||||
border-color: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 32px 96px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--faint);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
margin-left: 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.routes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 22px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
animation: rise 0.35s cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.card.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.route-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.route-id {
|
||||
color: var(--faint);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge.lang {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--bad);
|
||||
border-color: var(--bad);
|
||||
background: var(--bad-dim);
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch .track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s, border-color 0.18s;
|
||||
}
|
||||
|
||||
.switch .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
|
||||
.switch input:checked + .track {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.switch input:checked + .track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip .f-type {
|
||||
color: var(--faint);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chip .f-val {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip.exclude {
|
||||
background: var(--bad-dim);
|
||||
}
|
||||
|
||||
.chip.exclude .f-type {
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.target {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.target b {
|
||||
color: var(--faint);
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.target code {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
background: var(--surface-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 72px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 62vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 48px 52px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.login-card code {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.editor {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
width: min(520px, 100vw);
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -16px 0 48px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.editor-head h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--faint);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lbl-note {
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.field input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--ui);
|
||||
font-size: 13.5px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.field input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.field .hint {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.inline span {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
padding: 7px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-family: var(--ui);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-row input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-filter {
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.err {
|
||||
color: var(--bad);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 28px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.toast.bad {
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.22s, transform 0.22s;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 7px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
font-family: var(--ui);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--ok);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.log-entry.fail {
|
||||
border-left-color: var(--bad);
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-route {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.log-event {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
margin-left: auto;
|
||||
color: var(--faint);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.log-meta b {
|
||||
color: var(--faint);
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.log-meta code {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
background: var(--surface-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bad-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--bad);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-log {
|
||||
color: var(--faint);
|
||||
text-align: center;
|
||||
padding: 56px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filter-row {
|
||||
grid-template-columns: 100px 1fr auto;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 20px 16px 80px;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
1426
admin/bun.lock
Normal file
1426
admin/bun.lock
Normal file
File diff suppressed because it is too large
Load diff
13
admin/components/AppToasts.vue
Normal file
13
admin/components/AppToasts.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<div class="toasts">
|
||||
<TransitionGroup name="toast">
|
||||
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind" @click="dismiss(t.id)">
|
||||
{{ t.msg }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { toasts, dismiss } = useToasts();
|
||||
</script>
|
||||
47
admin/components/RouteCard.vue
Normal file
47
admin/components/RouteCard.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<article class="card" :class="{ disabled: !route.enabled }">
|
||||
<div class="card-head">
|
||||
<div class="card-title">
|
||||
<label class="switch" @click.prevent>
|
||||
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
|
||||
<span class="track"></span>
|
||||
</label>
|
||||
<span class="route-name">{{ route.name || "(untitled)" }}</span>
|
||||
<span class="route-id">{{ route.id }}</span>
|
||||
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="icon-btn" title="Edit" @click="$emit('edit', route)">✎</button>
|
||||
<button class="icon-btn danger" title="Delete" @click="$emit('delete', route)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span v-for="(f, i) in route.filters" :key="i" class="chip" :class="{ exclude: f.exclude }">
|
||||
<span class="f-type">{{ f.exclude ? "NOT " : "" }}{{ FILTER_LABELS[f.type] || f.type }}</span>
|
||||
<span class="f-val">{{ fmtMatch(f.match) }}</span>
|
||||
</span>
|
||||
<span v-if="!route.filters.length" class="chip"><span class="f-type">no filters</span></span>
|
||||
</div>
|
||||
<div class="target">
|
||||
<span><b>CHANNEL</b><code>{{ route.target.channelId }}</code></span>
|
||||
<span v-if="route.target.threadId"><b>THREAD</b><code>{{ route.target.threadId }}</code></span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Route } from "~/types";
|
||||
import { FILTER_LABELS, fmtMatch } from "~/types";
|
||||
|
||||
const props = defineProps<{ route: Route }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle", route: Route): void;
|
||||
(e: "edit", route: Route): void;
|
||||
(e: "delete", route: Route): void;
|
||||
}>();
|
||||
|
||||
function onToggle(event: Event): void {
|
||||
const next = { ...props.route, enabled: (event.target as HTMLInputElement).checked };
|
||||
emit("toggle", next);
|
||||
}
|
||||
</script>
|
||||
187
admin/components/RouteEditor.vue
Normal file
187
admin/components/RouteEditor.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="open" class="overlay" @click.self="close"></div>
|
||||
</Transition>
|
||||
<Transition name="slide">
|
||||
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
||||
<div class="editor-head">
|
||||
<h2>{{ isEdit ? "Edit route" : "New route" }}</h2>
|
||||
<button class="icon-btn" title="Close" @click="close">✕</button>
|
||||
</div>
|
||||
<form class="editor-body" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input v-model="form.name" type="text" placeholder="My Route" required />
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>ID</label>
|
||||
<input v-model="form.id" type="text" placeholder="my-route" required />
|
||||
<div class="hint">Unique, use a-z / 0-9 / dashes</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Language</label>
|
||||
<input v-model="form.lang" type="text" placeholder="en" />
|
||||
<div class="hint">en or zh; custom via KV i18n:<lang></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>Route enabled</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Filters <span class="lbl-note">(all must match · AND)</span></label>
|
||||
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
|
||||
<select v-model="f.type">
|
||||
<option v-for="t in FILTER_TYPES" :key="t" :value="t">{{ FILTER_LABELS[t] }}</option>
|
||||
</select>
|
||||
<input v-model="f.matchText" type="text" placeholder="match value" />
|
||||
<label class="inline" title="Invert this filter">
|
||||
<input v-model="f.exclude" type="checkbox" /><span>NOT</span>
|
||||
</label>
|
||||
<button type="button" class="icon-btn danger" title="Remove filter" @click="form.filters.splice(i, 1)">✕</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">+ Add filter</button>
|
||||
<div class="err">{{ filterError }}</div>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>Channel ID</label>
|
||||
<input v-model="form.channelId" type="text" placeholder="Discord channel ID" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Thread ID <span class="lbl-note">(optional)</span></label>
|
||||
<input v-model="form.threadId" type="text" placeholder="Optional thread ID" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="err">{{ formError }}</div>
|
||||
</form>
|
||||
<div class="editor-foot">
|
||||
<button class="btn btn-ghost" type="button" @click="close">Cancel</button>
|
||||
<button class="btn btn-accent" type="button" :disabled="saving" @click="save">Save route</button>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from "vue";
|
||||
import type { Filter, Route } from "~/types";
|
||||
import { FILTER_TYPES, FILTER_LABELS, fmtMatch } from "~/types";
|
||||
|
||||
interface FilterForm extends Filter {
|
||||
matchText: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ open: boolean; route: Route | null; saving: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "save", route: Route): void;
|
||||
}>();
|
||||
|
||||
const isEdit = computed(() => props.route != null);
|
||||
const filterError = ref("");
|
||||
const formError = ref("");
|
||||
|
||||
const form = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
lang: "",
|
||||
enabled: true,
|
||||
channelId: "",
|
||||
threadId: "",
|
||||
filters: [] as FilterForm[],
|
||||
});
|
||||
|
||||
function parseMatch(text: string): string | string[] | null {
|
||||
const parts = text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return null;
|
||||
return parts.length === 1 ? parts[0]! : parts;
|
||||
}
|
||||
|
||||
function blankFilter(): FilterForm {
|
||||
return { type: "event", match: "", exclude: false, matchText: "" };
|
||||
}
|
||||
|
||||
function addFilter(): void {
|
||||
form.filters.push(blankFilter());
|
||||
filterError.value = "";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
const r = props.route;
|
||||
form.id = r?.id ?? "";
|
||||
form.name = r?.name ?? "";
|
||||
form.lang = r?.lang ?? "";
|
||||
form.enabled = r?.enabled ?? true;
|
||||
form.channelId = r?.target.channelId ?? "";
|
||||
form.threadId = r?.target.threadId ?? "";
|
||||
form.filters = (r && r.filters.length
|
||||
? r.filters
|
||||
: [{ type: "event", match: "", exclude: false }]
|
||||
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
|
||||
filterError.value = "";
|
||||
formError.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
function close(): void {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function collect(): Route | null {
|
||||
const filters: Filter[] = [];
|
||||
for (let i = 0; i < form.filters.length; i++) {
|
||||
const f = form.filters[i]!;
|
||||
const match = parseMatch(f.matchText);
|
||||
if (!match) {
|
||||
filterError.value = `Filter ${i + 1} needs a match value`;
|
||||
return null;
|
||||
}
|
||||
filters.push({ type: f.type, match, exclude: f.exclude });
|
||||
}
|
||||
filterError.value = "";
|
||||
if (!filters.length) {
|
||||
filterError.value = "Add at least one filter";
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: form.id.trim(),
|
||||
name: form.name.trim(),
|
||||
enabled: form.enabled,
|
||||
lang: form.lang.trim() || undefined,
|
||||
filters,
|
||||
target: {
|
||||
channelId: form.channelId.trim(),
|
||||
threadId: form.threadId.trim() || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
formError.value = "";
|
||||
const route = collect();
|
||||
if (!route) return;
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(route.id)) {
|
||||
formError.value = "ID must be a-z / 0-9 / dashes";
|
||||
return;
|
||||
}
|
||||
if (!route.name) {
|
||||
formError.value = "Name is required";
|
||||
return;
|
||||
}
|
||||
if (!route.target.channelId) {
|
||||
formError.value = "Channel ID is required";
|
||||
return;
|
||||
}
|
||||
emit("save", route);
|
||||
}
|
||||
</script>
|
||||
42
admin/components/SendLogs.vue
Normal file
42
admin/components/SendLogs.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="log-toolbar">
|
||||
<span class="kpi-label">LAST {{ logs.length }} SENDS</span>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="refresh">⟳ Refresh</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-else-if="!loading && !logs.length" class="empty-log">No send records yet — trigger a webhook to see results.</p>
|
||||
|
||||
<div class="log-list">
|
||||
<article v-for="l in logs" :key="l.ts + l.routeId" class="log-entry" :class="{ fail: !l.ok }">
|
||||
<div class="log-head">
|
||||
<span class="dot" :class="l.ok ? 'ok' : 'bad'"></span>
|
||||
<span class="log-route">{{ l.routeId }}</span>
|
||||
<span class="log-event">{{ l.event }}</span>
|
||||
<span class="log-time">{{ fmtTime(l.ts) }}</span>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span v-if="l.repo"><b>REPO</b><code>{{ l.repo }}</code></span>
|
||||
<span><b>TARGET</b><code>{{ l.target }}</code></span>
|
||||
</div>
|
||||
<div v-if="!l.ok && l.error" class="log-error">{{ l.error }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SendRecord } from "~/types";
|
||||
|
||||
const props = defineProps<{ logs: SendRecord[]; loading: boolean }>();
|
||||
const emit = defineEmits<{ (e: "refresh"): void }>();
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
emit("refresh");
|
||||
}
|
||||
</script>
|
||||
33
admin/composables/useLogs.ts
Normal file
33
admin/composables/useLogs.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { SendRecord } from "~/types";
|
||||
|
||||
export function useSendLogs() {
|
||||
const logs = ref<SendRecord[]>([]);
|
||||
const loading = ref(false);
|
||||
const needLogin = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(limit = 50): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
const res = await fetch(`/admin/api/logs?limit=${limit}`, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { logs?: SendRecord[] };
|
||||
logs.value = data.logs ?? [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { logs, loading, needLogin, error, load };
|
||||
}
|
||||
51
admin/composables/useRoutes.ts
Normal file
51
admin/composables/useRoutes.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { Route } from "~/types";
|
||||
|
||||
export function useRoutesApi() {
|
||||
const routes = ref<Route[]>([]);
|
||||
const loading = ref(false);
|
||||
const needLogin = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
const res = await fetch("/admin/api/routes", {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { routes?: Route[] };
|
||||
routes.value = data.routes ?? [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(next: Route[]): Promise<void> {
|
||||
const res = await fetch("/admin/api/routes", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ routes: next }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
routes.value = next;
|
||||
}
|
||||
|
||||
return { routes, loading, needLogin, error, load, save };
|
||||
}
|
||||
23
admin/composables/useToasts.ts
Normal file
23
admin/composables/useToasts.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export interface Toast {
|
||||
id: number;
|
||||
msg: string;
|
||||
kind: "ok" | "bad";
|
||||
}
|
||||
|
||||
export function useToasts() {
|
||||
const toasts = useState<Toast[]>("app-toasts", () => []);
|
||||
|
||||
function push(msg: string, kind: "ok" | "bad" = "ok"): void {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
toasts.value.push({ id, msg, kind });
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
function dismiss(id: number): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
return { toasts, push, dismiss };
|
||||
}
|
||||
22
admin/nuxt.config.ts
Normal file
22
admin/nuxt.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
compatibilityDate: "2025-01-01",
|
||||
app: {
|
||||
head: {
|
||||
title: "WebHooker · Config Console",
|
||||
link: [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
css: ["~/assets/css/main.css"],
|
||||
devtools: { enabled: false },
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: "/admin/api",
|
||||
},
|
||||
},
|
||||
});
|
||||
15
admin/package.json
Normal file
15
admin/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "webhooker-admin",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^3.21.10",
|
||||
"vue": "^3.5.13"
|
||||
}
|
||||
}
|
||||
7
admin/pages/[...slug].vue
Normal file
7
admin/pages/[...slug].vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/", { redirectCode: 302 });
|
||||
</script>
|
||||
170
admin/pages/index.vue
Normal file
170
admin/pages/index.vue
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<template>
|
||||
<div>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="brand-mark">WH</div>
|
||||
<div>
|
||||
<h1>WebHooker</h1>
|
||||
<span class="tagline">CONFIG CONSOLE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button v-if="!needLogin" class="btn btn-accent" @click="openNew">+ New Route</button>
|
||||
<a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">Sign out</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div v-if="needLogin" class="login">
|
||||
<div class="login-card">
|
||||
<h2>Config Console</h2>
|
||||
<p v-if="forbidden">Access denied — this GitHub account is not in <code>ADMIN_USER_IDS</code>.</p>
|
||||
<p v-else>Sign in with GitHub to manage routes.</p>
|
||||
<a class="btn btn-accent btn-lg" href="/admin/login">Sign in with GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<nav class="tabs">
|
||||
<button class="tab" :class="{ active: view === 'routes' }" @click="switchView('routes')">
|
||||
Routes
|
||||
</button>
|
||||
<button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')">
|
||||
Send Logs
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<template v-if="view === 'routes'">
|
||||
<section class="toolbar">
|
||||
<div>
|
||||
<span class="kpi-label">ROUTES</span>
|
||||
<span class="kpi">{{ routes.length }}</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="dot" :class="loading ? '' : 'ok'"></span>
|
||||
<span>{{ loading ? "loading" : "connected" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<section class="routes">
|
||||
<RouteCard
|
||||
v-for="(r, i) in routes"
|
||||
:key="r.id"
|
||||
:route="r"
|
||||
:style="{ animationDelay: i * 45 + 'ms' }"
|
||||
@toggle="onToggle"
|
||||
@edit="openEdit"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="!loading && !routes.length" class="empty">
|
||||
<p>No routes configured yet.</p>
|
||||
<button class="btn btn-accent" @click="openNew">Create your first route</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="toolbar">
|
||||
<div class="status">
|
||||
<span class="dot" :class="logsLoading ? '' : 'ok'"></span>
|
||||
<span>{{ logsLoading ? "loading" : "connected" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<SendLogs :logs="logs" :loading="logsLoading" @refresh="loadLogs" />
|
||||
</template>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<RouteEditor
|
||||
:open="editorOpen"
|
||||
:route="editing"
|
||||
:saving="saving"
|
||||
@close="editorOpen = false"
|
||||
@save="onSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Route } from "~/types";
|
||||
|
||||
const { routes, loading, needLogin, error, load, save } = useRoutesApi();
|
||||
const { push } = useToasts();
|
||||
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
|
||||
|
||||
const editorOpen = ref(false);
|
||||
const editing = ref<Route | null>(null);
|
||||
const saving = ref(false);
|
||||
const forbidden = ref(false);
|
||||
const view = ref<"routes" | "logs">("routes");
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
forbidden.value = params.get("error") === "forbidden";
|
||||
}
|
||||
load();
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
function switchView(next: "routes" | "logs"): void {
|
||||
view.value = next;
|
||||
if (next === "routes") load();
|
||||
if (next === "logs") loadLogs();
|
||||
}
|
||||
|
||||
function openNew(): void {
|
||||
editing.value = null;
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(route: Route): void {
|
||||
editing.value = route;
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
async function onSave(route: Route): Promise<void> {
|
||||
saving.value = true;
|
||||
try {
|
||||
let next: Route[];
|
||||
if (editing.value) {
|
||||
next = routes.value.map((r) => (r.id === editing.value!.id ? route : r));
|
||||
} else {
|
||||
if (routes.value.some((r) => r.id === route.id)) {
|
||||
push("Route ID already exists", "bad");
|
||||
return;
|
||||
}
|
||||
next = [...routes.value, route];
|
||||
}
|
||||
await save(next);
|
||||
editorOpen.value = false;
|
||||
push("Routes saved");
|
||||
} catch (err) {
|
||||
push(`Save failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(route: Route): Promise<void> {
|
||||
try {
|
||||
await save(routes.value.map((r) => (r.id === route.id ? route : r)));
|
||||
} catch (err) {
|
||||
push(`Save failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(route: Route): Promise<void> {
|
||||
if (!window.confirm(`Delete route "${route.name || route.id}"?`)) return;
|
||||
try {
|
||||
await save(routes.value.filter((r) => r.id !== route.id));
|
||||
push("Route deleted");
|
||||
} catch (err) {
|
||||
push(`Delete failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
43
admin/types.ts
Normal file
43
admin/types.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export interface Filter {
|
||||
type: "event" | "repo" | "actor" | "action" | "branch" | "keyword";
|
||||
match: string | string[];
|
||||
exclude?: boolean;
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
filters: Filter[];
|
||||
target: {
|
||||
channelId: string;
|
||||
threadId?: string;
|
||||
};
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;
|
||||
|
||||
export const FILTER_LABELS: Record<string, string> = {
|
||||
event: "Event",
|
||||
repo: "Repo",
|
||||
actor: "Actor",
|
||||
action: "Action",
|
||||
branch: "Branch",
|
||||
keyword: "Keyword",
|
||||
};
|
||||
|
||||
export function fmtMatch(match: string | string[]): string {
|
||||
if (Array.isArray(match)) return match.join(", ");
|
||||
return String(match ?? "");
|
||||
}
|
||||
|
||||
export interface SendRecord {
|
||||
ts: number;
|
||||
routeId: string;
|
||||
event: string;
|
||||
repo?: string;
|
||||
target: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
132
src/admin-routes.ts
Normal file
132
src/admin-routes.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env, Route } from "./types";
|
||||
import { loadRoutes, saveRoutes } from "./config";
|
||||
import {
|
||||
isAdminUser,
|
||||
getAdminSession,
|
||||
destroyAdminSession,
|
||||
clearAdminCookie,
|
||||
} from "./admin-session";
|
||||
import { getSendLog } from "./send-log";
|
||||
import { log } from "./log";
|
||||
|
||||
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
|
||||
|
||||
function isValidMatch(match: unknown): match is string | string[] {
|
||||
if (typeof match === "string") return match.trim().length > 0;
|
||||
if (Array.isArray(match))
|
||||
return match.length > 0 && match.every((m) => typeof m === "string" && m.trim().length > 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateRoutes(
|
||||
routes: unknown,
|
||||
): { ok: true; routes: Route[] } | { ok: false; error: string } {
|
||||
if (!Array.isArray(routes)) return { ok: false, error: "routes must be an array" };
|
||||
if (routes.length > 200) return { ok: false, error: "too many routes" };
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
const r = routes[i] as Record<string, unknown>;
|
||||
if (!r || typeof r !== "object") return { ok: false, error: `route[${i}] is not an object` };
|
||||
if (typeof r.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(r.id)) {
|
||||
return { ok: false, error: `route[${i}].id is invalid` };
|
||||
}
|
||||
if (seen.has(r.id)) return { ok: false, error: `duplicate route id "${r.id}"` };
|
||||
seen.add(r.id);
|
||||
if (typeof r.name !== "string" || r.name.trim().length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs a name` };
|
||||
}
|
||||
if (typeof r.enabled !== "boolean")
|
||||
return { ok: false, error: `route "${r.id}".enabled must be boolean` };
|
||||
if (r.lang !== undefined && typeof r.lang !== "string") {
|
||||
return { ok: false, error: `route "${r.id}".lang must be a string` };
|
||||
}
|
||||
if (!Array.isArray(r.filters) || r.filters.length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs at least one filter` };
|
||||
}
|
||||
for (let j = 0; j < r.filters.length; j++) {
|
||||
const f = r.filters[j] as Record<string, unknown>;
|
||||
if (!f || typeof f !== "object")
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] invalid` };
|
||||
if (!VALID_FILTER_TYPES.has(f.type as string)) {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] has unknown type` };
|
||||
}
|
||||
if (!isValidMatch(f.match)) {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] needs a match value` };
|
||||
}
|
||||
if (f.exclude !== undefined && typeof f.exclude !== "boolean") {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` };
|
||||
}
|
||||
}
|
||||
const target = r.target as Record<string, unknown> | undefined;
|
||||
if (!target || typeof target !== "object")
|
||||
return { ok: false, error: `route "${r.id}" needs a target` };
|
||||
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
|
||||
return { ok: false, error: `route "${r.id}".target.channelId is required` };
|
||||
if (target.threadId !== undefined && typeof target.threadId !== "string") {
|
||||
return { ok: false, error: `route "${r.id}".target.threadId must be a string` };
|
||||
}
|
||||
}
|
||||
return { ok: true, routes: routes as Route[] };
|
||||
}
|
||||
|
||||
export function createAdminRoutes(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
async function requireAdmin(c: {
|
||||
env: Env;
|
||||
req: { header: (name: string) => string | undefined };
|
||||
}): Promise<{ userId: string; login: string } | null> {
|
||||
const session = await getAdminSession(c.env.KV, c.req.header("cookie"));
|
||||
if (!session) return null;
|
||||
if (!isAdminUser(c.env, session.userId, session.login)) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
app.get("/login", (c) => {
|
||||
return c.redirect("/auth/github?redirect=/");
|
||||
});
|
||||
|
||||
app.get("/logout", async (c) => {
|
||||
await destroyAdminSession(c.env.KV, c.req.header("cookie"));
|
||||
c.header("Set-Cookie", clearAdminCookie());
|
||||
return c.redirect("/");
|
||||
});
|
||||
|
||||
app.get("/api/routes", async (c) => {
|
||||
if (!(await requireAdmin(c))) return c.json({ error: "Unauthorized" }, 401);
|
||||
const routes = await loadRoutes(c.env.KV);
|
||||
return c.json({ routes });
|
||||
});
|
||||
|
||||
app.get("/api/logs", async (c) => {
|
||||
if (!(await requireAdmin(c))) return c.json({ error: "Unauthorized" }, 401);
|
||||
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
|
||||
const logs = await getSendLog(c.env.KV, limit);
|
||||
return c.json({ logs });
|
||||
});
|
||||
|
||||
app.put("/api/routes", async (c) => {
|
||||
if (!(await requireAdmin(c))) return c.json({ error: "Unauthorized" }, 401);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
const result = validateRoutes((body as { routes?: unknown })?.routes);
|
||||
if (!result.ok) return c.json({ error: result.error }, 400);
|
||||
|
||||
try {
|
||||
await saveRoutes(c.env.KV, result.routes);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to save routes");
|
||||
return c.json({ error: "Failed to save routes" }, 500);
|
||||
}
|
||||
log.info({ count: result.routes.length }, "Routes updated via admin UI");
|
||||
return c.json({ ok: true, count: result.routes.length });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
77
src/admin-session.ts
Normal file
77
src/admin-session.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { Env } from "./types";
|
||||
|
||||
const SESSION_COOKIE = "wh_admin_session";
|
||||
const SESSION_TTL = 7 * 24 * 3600;
|
||||
|
||||
export interface AdminSession {
|
||||
userId: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
export function isAdminUser(env: Env, userId: string, login: string): boolean {
|
||||
const ids = (env.ADMIN_USER_IDS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (ids.length === 0) return false;
|
||||
return ids.includes(userId) || ids.some((id) => id.toLowerCase() === login.toLowerCase());
|
||||
}
|
||||
|
||||
function generateSessionId(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function createAdminSession(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
login: string,
|
||||
): Promise<string> {
|
||||
const sessionId = generateSessionId();
|
||||
const session: AdminSession = { userId, login };
|
||||
await kv.put(`session:${sessionId}`, JSON.stringify(session), {
|
||||
expirationTtl: SESSION_TTL,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function adminCookie(sessionId: string): string {
|
||||
return `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL}`;
|
||||
}
|
||||
|
||||
export function clearAdminCookie(): string {
|
||||
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
|
||||
function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getAdminSession(
|
||||
kv: KVNamespace,
|
||||
cookieHeader: string | undefined,
|
||||
): Promise<AdminSession | null> {
|
||||
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (!sessionId) return null;
|
||||
const raw = await kv.get<AdminSession>(`session:${sessionId}`, "json");
|
||||
if (!raw) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function destroyAdminSession(
|
||||
kv: KVNamespace,
|
||||
cookieHeader: string | undefined,
|
||||
): Promise<void> {
|
||||
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (sessionId) await kv.delete(`session:${sessionId}`);
|
||||
}
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import { Hono } from "hono";
|
||||
import { getOAuthURL, handleOAuthCallback } from "./github-oauth";
|
||||
import { removeToken } from "./token-store";
|
||||
import { removeToken, saveDiscordLink } from "./token-store";
|
||||
import { isAdminUser, createAdminSession, adminCookie } from "./admin-session";
|
||||
import type { Env } from "./types";
|
||||
|
||||
interface PendingState {
|
||||
redirectTo: string;
|
||||
expiresAt: number;
|
||||
discordUserId?: string;
|
||||
}
|
||||
|
||||
function linkedPage(login: string): string {
|
||||
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>绑定成功</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:32px 40px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.06)}.ok{color:#16a34a;font-size:40px}h1{font-size:18px;margin:12px 0 4px}p{color:#57606a;font-size:14px;margin:0}</style></head><body><div class="card"><div class="ok">✓</div><h1>GitHub 账号已绑定</h1><p>已连接为 <b>@${login}</b>,现在可以回到 Discord 用 GitHub 评论了。</p></div></body></html>`;
|
||||
}
|
||||
|
||||
function generateRandomHex(length: number): string {
|
||||
|
|
@ -66,6 +72,26 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
|
|||
return c.json({ error: "OAuth failed" }, 400);
|
||||
}
|
||||
|
||||
// Discord account-linking flow: bind the Discord user to this GitHub account.
|
||||
if (pending.discordUserId) {
|
||||
await saveDiscordLink(c.env.KV, pending.discordUserId, result.userId);
|
||||
const isBrowserLink = (c.req.header("accept") ?? "").includes("text/html");
|
||||
if (isBrowserLink) {
|
||||
return c.html(linkedPage(result.login));
|
||||
}
|
||||
return c.json({ ok: true, discordUserId: pending.discordUserId, login: result.login });
|
||||
}
|
||||
|
||||
const isBrowser = (c.req.header("accept") ?? "").includes("text/html");
|
||||
if (isBrowser) {
|
||||
if (!isAdminUser(c.env, result.userId, result.login)) {
|
||||
return c.redirect("/?error=forbidden");
|
||||
}
|
||||
const sessionId = await createAdminSession(c.env.KV, result.userId, result.login);
|
||||
c.header("Set-Cookie", adminCookie(sessionId));
|
||||
return c.redirect(pending.redirectTo);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
userId: result.userId,
|
||||
login: result.login,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { verifySignature, parseEvent } from "./webhook";
|
|||
import { dispatchEvent } from "./discord";
|
||||
import { createOAuthRoutes } from "./oauth-routes";
|
||||
import { createActionRoutes } from "./action-routes";
|
||||
import { createAdminRoutes } from "./admin-routes";
|
||||
import { log } from "./log";
|
||||
|
||||
const MAX_BODY_SIZE = 1024 * 1024;
|
||||
|
|
@ -15,6 +16,7 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
|||
|
||||
app.route("/auth", createOAuthRoutes());
|
||||
app.route("/", createActionRoutes());
|
||||
app.route("/admin", createAdminRoutes());
|
||||
|
||||
app.post("/webhook", async (c) => {
|
||||
const contentLength = Number(c.req.header("content-length") ?? 0);
|
||||
|
|
@ -50,5 +52,12 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
|||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.notFound((c) => {
|
||||
if (c.env.ASSETS) {
|
||||
return c.env.ASSETS.fetch(c.req.raw);
|
||||
}
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ interface StoredToken {
|
|||
refreshToken?: string;
|
||||
}
|
||||
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(token);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function saveToken(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
|
|
@ -20,6 +28,9 @@ export async function saveToken(
|
|||
};
|
||||
const ttl = Math.max(Math.floor(expiresInSeconds * 0.9), 60);
|
||||
await kv.put(`token:${userId}`, JSON.stringify(token), { expirationTtl: ttl });
|
||||
|
||||
const tokenHash = await hashToken(accessToken);
|
||||
await kv.put(`token-reverse:${tokenHash}`, userId, { expirationTtl: ttl });
|
||||
}
|
||||
|
||||
export async function getToken(kv: KVNamespace, userId: string): Promise<string | null> {
|
||||
|
|
@ -40,6 +51,12 @@ export async function getRefreshToken(kv: KVNamespace, userId: string): Promise<
|
|||
}
|
||||
|
||||
export async function removeToken(kv: KVNamespace, userId: string): Promise<void> {
|
||||
const raw = await kv.get(`token:${userId}`, "json");
|
||||
if (raw) {
|
||||
const t = raw as StoredToken;
|
||||
const tokenHash = await hashToken(t.accessToken);
|
||||
await kv.delete(`token-reverse:${tokenHash}`);
|
||||
}
|
||||
await kv.delete(`token:${userId}`);
|
||||
}
|
||||
|
||||
|
|
@ -47,16 +64,32 @@ export async function findUserIdByToken(
|
|||
kv: KVNamespace,
|
||||
accessToken: string,
|
||||
): Promise<string | null> {
|
||||
const list = await kv.list({ prefix: "token:" });
|
||||
for (const key of list.keys) {
|
||||
const raw = await kv.get(key.name, "json");
|
||||
if (!raw) continue;
|
||||
const t = raw as StoredToken;
|
||||
if (Date.now() >= t.expiresAt) {
|
||||
await kv.delete(key.name);
|
||||
continue;
|
||||
const tokenHash = await hashToken(accessToken);
|
||||
return await kv.get(`token-reverse:${tokenHash}`, "text");
|
||||
}
|
||||
if (t.accessToken === accessToken) return t.userId;
|
||||
|
||||
/**
|
||||
* Link a Discord user id to a GitHub user id so that bot commands can act
|
||||
* as that GitHub account. The actual OAuth token lives under `token:{githubUserId}`.
|
||||
*/
|
||||
export async function saveDiscordLink(
|
||||
kv: KVNamespace,
|
||||
discordUserId: string,
|
||||
githubUserId: string,
|
||||
): Promise<void> {
|
||||
await kv.put(`discord-link:${discordUserId}`, githubUserId);
|
||||
}
|
||||
return null;
|
||||
|
||||
export async function getDiscordLink(
|
||||
kv: KVNamespace,
|
||||
discordUserId: string,
|
||||
): Promise<string | null> {
|
||||
return await kv.get(`discord-link:${discordUserId}`, "text");
|
||||
}
|
||||
|
||||
export async function removeDiscordLink(
|
||||
kv: KVNamespace,
|
||||
discordUserId: string,
|
||||
): Promise<void> {
|
||||
await kv.delete(`discord-link:${discordUserId}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue