feat: add admin panel (#31)

* feat: add admin panel

* feature: update limit
This commit is contained in:
Dream Hunter
2023-10-09 23:03:15 +08:00
committed by GitHub
parent 0f74bde850
commit 5cfc78d70d
15 changed files with 343 additions and 10 deletions

View File

@@ -109,6 +109,8 @@ node_compat = true
PREFIX = "tmp" # 要处理的邮箱名称前缀
# 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"]
# admin 控制台密码
# ADMIN_PASSWORDS = ["123", "456"]
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔

View File

@@ -44,6 +44,8 @@ pnpm install
# PREFIX = "tmp" - the email create will be like tmp<xxxxx>@DOMAIN
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
# PASSWORDS = ["123", "456"]
# For admin panel
# ADMIN_PASSWORDS = ["123", "456"]
# DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] you domain name
# JWT_SECRET = "xxx"
# BLACK_LIST = ""

View File

@@ -13,7 +13,8 @@
"naive-ui": "^2.34.3",
"vue": "^3.3.4",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "9"
"vue-i18n": "9",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",

View File

@@ -20,6 +20,9 @@ dependencies:
vue-i18n:
specifier: '9'
version: 9.3.0(vue@3.3.4)
vue-router:
specifier: ^4.2.5
version: 4.2.5(vue@3.3.4)
devDependencies:
'@vitejs/plugin-vue':
@@ -735,6 +738,15 @@ packages:
vue: 3.3.4
dev: false
/vue-router@4.2.5(vue@3.3.4):
resolution: {integrity: sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==}
peerDependencies:
vue: ^3.2.0
dependencies:
'@vue/devtools-api': 6.5.0
vue: 3.3.4
dev: false
/vue@3.3.4:
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
dependencies:

View File

@@ -43,10 +43,7 @@ onMounted(async () => {
<n-gi span="1"></n-gi>
<n-gi span="6">
<div class="main">
<n-space vertical>
<Header />
<Content />
</n-space>
<router-view></router-view>
</div>
</n-gi>
<n-gi span="1"></n-gi>

View File

@@ -1,7 +1,7 @@
import { useGlobalState } from '../store'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const { loading, auth, jwt, openSettings, showAuth } = useGlobalState();
const { loading, auth, jwt, openSettings, showAuth, adminAuth, showAdminAuth } = useGlobalState();
const apiFetch = async (path, options = {}) => {
loading.value = true;
@@ -10,6 +10,7 @@ const apiFetch = async (path, options = {}) => {
method: options.method || 'GET',
headers: {
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
'Content-Type': 'application/json',
},
@@ -18,6 +19,10 @@ const apiFetch = async (path, options = {}) => {
showAuth.value = true;
throw new Error("Unauthorized");
}
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
throw new Error("Unauthorized");
}
if (!response.ok) {
throw new Error(`${response.status} ${await response.text()}` || "error");
}
@@ -58,9 +63,39 @@ const getSettings = async () => {
return res["address"];
}
const adminShowPassword = async (id) => {
try {
const { password } = await apiFetch(`/admin/show_password/${id}`);
return password;
} catch (error) {
throw error;
}
}
const adminDeleteAddress = async (id) => {
try {
await apiFetch(`/admin/delete_address/${id}`, {
method: 'DELETE'
});
} catch (error) {
throw error;
}
}
const adminGetAddress = async () => {
try {
return await apiFetch("/admin/addresss");
} catch (error) {
throw error;
}
}
export const api = {
fetch: apiFetch,
getSettings: getSettings,
getOpenSettings: getOpenSettings,
adminShowPassword: adminShowPassword,
adminDeleteAddress: adminDeleteAddress,
adminGetAddress: adminGetAddress,
}

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createI18n } from 'vue-i18n'
import router from './router'
const i18n = createI18n({
legacy: false, // you must set `false`, to use Composition API
@@ -15,4 +16,5 @@ const i18n = createI18n({
})
const app = createApp(App)
app.use(i18n)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import Admin from '../views/Admin.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: Index
},
{
path: '/admin',
component: Admin
}
]
})
export default router

View File

@@ -13,7 +13,9 @@ export const useGlobalState = createGlobalState(
}]
})
const showAuth = ref(false);
const showAdminAuth = ref(false);
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zhCN');
const themeSwitch = useStorage('themeSwitch', false);
@@ -24,7 +26,9 @@ export const useGlobalState = createGlobalState(
auth,
jwt,
localeCache,
themeSwitch
themeSwitch,
adminAuth,
showAdminAuth,
}
},
)

View File

@@ -0,0 +1,196 @@
<script setup>
import { NSpace, NLayoutHeader, NInput, c } from 'naive-ui'
import { NButton, NSelect, NModal } from 'naive-ui'
import { NDataTable, NPopconfirm } from 'naive-ui'
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useGlobalState } from '../store'
import { api } from '../api'
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
const router = useRouter()
const message = useMessage()
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const authFunc = async () => {
try {
location.reload()
} catch (error) {
message.error(error.message || "error");
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
title: 'Temp Email Admin',
auth: 'Admin Auth',
home: 'Home',
authTip: 'Please enter the correct auth code',
name: 'Name',
created_at: 'Created At',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
refresh: 'Refresh',
},
zh: {
title: '临时邮件 Admin',
auth: 'Admin 授权',
home: '首页',
authTip: '请输入正确的授权码',
name: '名称',
created_at: '创建时间',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
refresh: '刷新',
}
}
});
const data = ref([])
const showPassword = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
}
}
const deleteEmail = async (id) => {
try {
await api.adminDeleteAddress(id)
message.success("success");
await fetchData()
} catch (error) {
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
data.value = await api.adminGetAddress()
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: 'Action',
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
onClick: () => showPassword(row.id)
},
{ default: () => t('showPass') }
),
h(NPopconfirm,
{
onPositiveClick: () => deleteEmail(row.id)
},
{
trigger: () => h(NButton, { type: "error" }, () => t('delete')),
default: () => t('deleteTip')
}
)
])
}
}
]
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true
} else {
await fetchData()
}
})
</script>
<template>
<n-space vertical>
<n-layout-header>
<div>
<h2>{{ t('title') }}</h2>
</div>
<div>
<n-button tertiary @click="() => router.push('/')" type="primary">
{{ t('home') }}
</n-button>
<n-button tertiary @click="fetchData" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('auth') }}</div>
</template>
<p>{{ t('authTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{
minRows: 3
}" />
<template #action>
<n-button @click="authFunc" size="small" tertiary round type="primary">
{{ t('auth') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
</n-card>
<template #action>
</template>
</n-modal>
</n-layout-header>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</n-space>
</template>
<style scoped>
.n-layout-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -13,10 +13,9 @@ const { toClipboard } = useClipboard()
const message = useMessage()
const address = ref("")
const { jwt, loading, openSettings } = useGlobalState()
const { jwt, openSettings } = useGlobalState()
const autoRefresh = ref(false)
const data = ref([])
const API_BASE = import.meta.env.VITE_API_BASE || "";
const timer = ref(null)
const showPassword = ref(false)
const showNewEmail = ref(false)

View File

@@ -0,0 +1,12 @@
<script setup>
import { NSpace } from 'naive-ui'
import Header from './Header.vue';
import Content from './Content.vue';
</script>
<template>
<n-space vertical>
<Header />
<Content />
</n-space>
</template>

View File

@@ -9,7 +9,7 @@ api.get('/api/mails', async (c) => {
return c.json({ "error": "No address" }, 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, subject, message FROM mails where address = ? order by id desc limit 10`
`SELECT id, source, subject, message FROM mails where address = ? order by id desc limit 100`
).bind(address).all();
return c.json(results);
})
@@ -66,4 +66,42 @@ api.get('/api/new_address', async (c) => {
})
})
api.get('/admin/addresss', async (c) => {
const { results } = await c.env.DB.prepare(
`SELECT * FROM address order by id desc`
).all();
return c.json(results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}));
})
api.delete('/admin/delete_address/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ?`
).bind(id).run();
if (!success) {
return c.text("Failed to delete address", 500)
}
return c.json({
success: success
})
})
api.get('/admin/show_password/:id', async (c) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ?`
).bind(id).first("name");
// compute address
const emailAddress = c.env.PREFIX + name
const jwt = await Jwt.sign({
address: emailAddress
}, c.env.JWT_SECRET)
return c.json({
password: jwt
})
})
export { api }

View File

@@ -22,6 +22,18 @@ app.use('/api/*', async (c, next) => {
return jwt({ secret: c.env.JWT_SECRET })(c, next);
});
app.use('/admin/*', async (c, next) => {
// check header x-admin-auth
if (c.env.ADMIN_PASSWORDS && c.env.ADMIN_PASSWORDS.length > 0) {
const adminAuth = c.req.headers.get("x-admin-auth");
if (adminAuth && c.env.ADMIN_PASSWORDS.includes(adminAuth)) {
await next();
return;
}
}
return c.text("Need Admin Password", 401)
});
app.route('/', api)

View File

@@ -7,6 +7,8 @@ node_compat = true
PREFIX = "tmp"
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
# PASSWORDS = ["123", "456"]
# For admin panel
# ADMIN_PASSWORDS = ["123", "456"]
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
JWT_SECRET = "xxx"
BLACK_LIST = ""