feat: add ADMIN_USER_ROLE for user access admin panel (#363)

This commit is contained in:
Dream Hunter
2024-07-27 22:04:18 +08:00
committed by GitHub
parent a0805bc0ce
commit 5faae8796d
21 changed files with 81 additions and 56 deletions
+5
View File
@@ -1,6 +1,11 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 --> <!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG # CHANGE LOG
## main(v0.7.0)
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
## v0.6.1 ## v0.6.1
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355) - pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "0.6.1", "version": "0.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1
View File
@@ -22,6 +22,7 @@ const apiFetch = async (path, options = {}) => {
data: options.body || null, data: options.body || null,
headers: { headers: {
'x-user-token': userJwt.value, 'x-user-token': userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value, 'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value, 'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`, 'Authorization': `Bearer ${jwt.value}`,
+7 -1
View File
@@ -1,4 +1,4 @@
import { ref } from "vue"; import { computed, ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core' import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
export const useGlobalState = createGlobalState( export const useGlobalState = createGlobalState(
@@ -74,9 +74,14 @@ export const useGlobalState = createGlobalState(
user_email: '', user_email: '',
/** @type {number} */ /** @type {number} */
user_id: 0, user_id: 0,
/** @type {boolean} */
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */ /** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null, user_role: null,
}); });
const showAdminPage = computed(() => !!adminAuth.value || userSettings.value.is_admin);
const telegramApp = ref(window.Telegram?.WebApp || {}); const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData); const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
return { return {
@@ -108,6 +113,7 @@ export const useGlobalState = createGlobalState(
useSideMargin, useSideMargin,
telegramApp, telegramApp,
isTelegram, isTelegram,
showAdminPage,
} }
}, },
) )
+4 -3
View File
@@ -21,7 +21,8 @@ import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue'; import Webhook from './admin/Webhook.vue';
const { const {
adminAuth, showAdminAuth, adminTab, loading, globalTabplacement adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage
} = useGlobalState() } = useGlobalState()
const message = useMessage() const message = useMessage()
@@ -81,7 +82,7 @@ const { t } = useI18n({
}); });
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) { if (!showAdminPage.value) {
showAdminAuth.value = true; showAdminAuth.value = true;
return; return;
} }
@@ -100,7 +101,7 @@ onMounted(async () => {
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement"> <n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="account" :tab="t('account')"> <n-tab-pane name="account" :tab="t('account')">
<n-tabs type="bar" animated> <n-tabs type="bar" animated>
<n-tab-pane name="account" :tab="t('account')"> <n-tab-pane name="account" :tab="t('account')">
+8 -3
View File
@@ -17,8 +17,8 @@ import { getRouterPathWithLang } from '../utils'
const message = useMessage() const message = useMessage()
const { const {
toggleDark, isDark, isTelegram, toggleDark, isDark, isTelegram, showAdminPage,
showAuth, adminAuth, auth, loading, openSettings showAuth, auth, loading, openSettings
} = useGlobalState() } = useGlobalState()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -134,7 +134,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }), icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
} }
), ),
show: !!adminAuth.value, show: showAdminPage.value,
key: "admin" key: "admin"
}, },
{ {
@@ -223,6 +223,11 @@ const logoClick = async () => {
onMounted(async () => { onMounted(async () => {
await api.getOpenSettings(message); await api.getOpenSettings(message);
try {
await api.getUserSettings(message);
} catch (error) {
console.error(error);
}
}); });
</script> </script>
+2 -6
View File
@@ -9,8 +9,8 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material' import { MenuFilled } from '@vicons/material'
const { const {
adminAuth, showAdminAuth, loading, showAdminAuth, loading, adminTab,
adminTab, adminMailTabAddress, adminSendBoxTabAddress adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState() } = useGlobalState()
const message = useMessage() const message = useMessage()
@@ -252,10 +252,6 @@ watch([page, pageSize], async () => {
}) })
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData() await fetchData()
}) })
</script> </script>
+1 -11
View File
@@ -6,10 +6,7 @@ import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import MailBox from '../../components/MailBox.vue'; import MailBox from '../../components/MailBox.vue';
const { const { adminMailTabAddress } = useGlobalState()
adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
messages: { messages: {
@@ -48,13 +45,6 @@ const fetchMailData = async (limit, offset) => {
const deleteMail = async (curMailId) => { const deleteMail = async (curMailId) => {
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' }); await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
}; };
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script> </script>
<template> <template>
+1 -13
View File
@@ -1,12 +1,7 @@
<script setup> <script setup>
import { onMounted } from 'vue';
import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import MailBox from '../../components/MailBox.vue'; import MailBox from '../../components/MailBox.vue';
const { adminAuth, showAdminAuth } = useGlobalState()
const fetchMailUnknowData = async (limit, offset) => { const fetchMailUnknowData = async (limit, offset) => {
return await api.fetch( return await api.fetch(
`/admin/mails_unknow` `/admin/mails_unknow`
@@ -18,17 +13,10 @@ const fetchMailUnknowData = async (limit, offset) => {
const deleteMail = async (curMailId) => { const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' }); await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
}; };
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script> </script>
<template> <template>
<div v-if="adminAuth" style="margin-top: 10px;"> <div style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="true" :fetchMailData="fetchMailUnknowData" :deleteMail="deleteMail" /> <MailBox :enableUserDeleteEmail="true" :fetchMailData="fetchMailUnknowData" :deleteMail="deleteMail" />
</div> </div>
</template> </template>
+1 -7
View File
@@ -1,12 +1,10 @@
<script setup> <script setup>
import { ref, h, onMounted, watch } from 'vue'; import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material' import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage() const message = useMessage()
const cleanupModel = ref({ const cleanupModel = ref({
enableMailsAutoCleanup: false, enableMailsAutoCleanup: false,
@@ -80,10 +78,6 @@ const save = async () => {
} }
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData(); await fetchData();
}) })
</script> </script>
-5
View File
@@ -4,10 +4,8 @@ import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa' import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material' import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { adminAuth } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
@@ -60,9 +58,6 @@ const fetchStatistics = async () => {
} }
onMounted(async () => { onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics() await fetchStatistics()
}) })
</script> </script>
+2 -1
View File
@@ -31,7 +31,8 @@ const { t } = useI18n({
onMounted(async () => { onMounted(async () => {
await api.getUserOpenSettings(message); await api.getUserOpenSettings(message);
await api.getUserSettings(message); // make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
}); });
</script> </script>
+1
View File
@@ -91,6 +91,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all your domain name
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name # For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"] # DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification) # USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix # User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [ # USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" }, # { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },
@@ -60,6 +60,8 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"] # DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 新用户默认角色, 仅在启用邮件验证时有效 # 新用户默认角色, 仅在启用邮件验证时有效
# USER_DEFAULT_ROLE = "vip" # USER_DEFAULT_ROLE = "vip"
# admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# 用户角色配置, 如果 domains 为空将使用 default_domains # 用户角色配置, 如果 domains 为空将使用 default_domains
# 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀 # 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀
# USER_ROLES = [ # USER_ROLES = [
@@ -1,7 +1,5 @@
# Admin 用户相关 # Admin 用户相关
默认不允许用户注册,可通过
## 用户管理页面 ## 用户管理页面
![admin-user-management](/feature/admin-user-management.png) ![admin-user-management](/feature/admin-user-management.png)
@@ -1,7 +1,11 @@
# Admin 控制台 # Admin 控制台
部署前端应用之后,访问 `/admin` 路径即可进入管理控制台。 > [!NOTE]
> 需要配置 `ADMIN_PASSWORDS` 或者 `ADMIN_USER_ROLE` 才可以访问 admin 控制台
> admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
需要在后端配置 `admin 控制台密码`, 不配置则不允许访问控制台。 部署前端应用之后,点击 左上角 logo 5 次 或者访问 `/admin` 路径即可进入管理控制台。
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`, 则不允许访问控制台。
![admin](/feature/admin.png) ![admin](/feature/admin.png)
+1 -1
View File
@@ -1,5 +1,5 @@
export const CONSTANTS = { export const CONSTANTS = {
VERSION: 'v0.6.1', VERSION: 'v0.7.0',
// DB settings // DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list', ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
+1
View File
@@ -19,6 +19,7 @@ export type Bindings = {
MAX_ADDRESS_LEN: string | number | undefined MAX_ADDRESS_LEN: string | number | undefined
DEFAULT_DOMAINS: string | string[] | undefined DEFAULT_DOMAINS: string | string[] | undefined
DOMAINS: string | string[] | undefined DOMAINS: string | string[] | undefined
ADMIN_USER_ROLE: string | undefined
USER_DEFAULT_ROLE: string | UserRole | undefined USER_DEFAULT_ROLE: string | UserRole | undefined
USER_ROLES: string | UserRole[] | undefined USER_ROLES: string | UserRole[] | undefined
DOMAIN_LABELS: string | string[] | undefined DOMAIN_LABELS: string | string[] | undefined
+16
View File
@@ -5,6 +5,7 @@ import { UserSettings } from "../models";
import { getJsonSetting, getUserRoles } from "../utils" import { getJsonSetting, getUserRoles } from "../utils"
import { CONSTANTS } from "../constants"; import { CONSTANTS } from "../constants";
import { commonGetUserRole } from "../common"; import { commonGetUserRole } from "../common";
import { Jwt } from "hono/utils/jwt";
export default { export default {
openSettings: async (c: Context<HonoCustomType>) => { openSettings: async (c: Context<HonoCustomType>) => {
@@ -25,8 +26,23 @@ export default {
return c.text("User not found", 400); return c.text("User not found", 400);
} }
const user_role = await commonGetUserRole(c, db_user_id); const user_role = await commonGetUserRole(c, db_user_id);
const is_admin = (
c.env.ADMIN_USER_ROLE
&&
c.env.ADMIN_USER_ROLE === user_role?.role
);
const access_token = is_admin ? await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
user_role: user_role?.role,
iat: Math.floor(Date.now() / 1000),
// 1 hour
exp: Math.floor(Date.now() / 1000) + 3600,
}, c.env.JWT_SECRET, "HS256") : null;
return c.json({ return c.json({
...user, ...user,
is_admin: is_admin,
access_token: access_token,
user_role: user_role user_role: user_role
}); });
}, },
+20
View File
@@ -136,6 +136,26 @@ app.use('/admin/*', async (c, next) => {
return; return;
} }
} }
// check if user is admin
const access_token = c.req.raw.headers.get("x-user-access-token");
if (c.env.ADMIN_USER_ROLE && access_token) {
try {
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, "HS256");
// check expired
if (!payload.exp) return c.text("Invalid Token", 401);
// exp is in seconds
if (payload.exp < Math.floor(Date.now() / 1000)) {
return c.text("Token Expired", 401)
}
if (payload.user_role !== c.env.ADMIN_USER_ROLE) {
return c.text("Need Admin Role", 401)
}
await next();
return;
} catch (e) {
console.error(e);
}
}
return c.text("Need Admin Password", 401) return c.text("Need Admin Password", 401)
}); });
+1
View File
@@ -33,6 +33,7 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name # For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"] # DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification) # USER_DEFAULT_ROLE = "vip" # default role for new users(only when enable mail verification)
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix # User roles configuration, if domains is empty will use default_domains, if prefix is null will use default prefix, if prefix is empty string will not use prefix
# USER_ROLES = [ # USER_ROLES = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" }, # { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },