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

View File

@@ -1,6 +1,11 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main(v0.7.0)
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
## v0.6.1
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.6.1",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -22,6 +22,7 @@ const apiFetch = async (path, options = {}) => {
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,

View File

@@ -1,4 +1,4 @@
import { ref } from "vue";
import { computed, ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
export const useGlobalState = createGlobalState(
@@ -74,9 +74,14 @@ export const useGlobalState = createGlobalState(
user_email: '',
/** @type {number} */
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}} */
user_role: null,
});
const showAdminPage = computed(() => !!adminAuth.value || userSettings.value.is_admin);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
return {
@@ -108,6 +113,7 @@ export const useGlobalState = createGlobalState(
useSideMargin,
telegramApp,
isTelegram,
showAdminPage,
}
},
)

View File

@@ -21,7 +21,8 @@ import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
const {
adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage
} = useGlobalState()
const message = useMessage()
@@ -81,7 +82,7 @@ const { t } = useI18n({
});
onMounted(async () => {
if (!adminAuth.value) {
if (!showAdminPage.value) {
showAdminAuth.value = true;
return;
}
@@ -100,7 +101,7 @@ onMounted(async () => {
</n-button>
</template>
</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-tabs type="bar" animated>
<n-tab-pane name="account" :tab="t('account')">

View File

@@ -17,8 +17,8 @@ import { getRouterPathWithLang } from '../utils'
const message = useMessage()
const {
toggleDark, isDark, isTelegram,
showAuth, adminAuth, auth, loading, openSettings
toggleDark, isDark, isTelegram, showAdminPage,
showAuth, auth, loading, openSettings
} = useGlobalState()
const route = useRoute()
const router = useRouter()
@@ -134,7 +134,7 @@ const menuOptions = computed(() => [
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
show: !!adminAuth.value,
show: showAdminPage.value,
key: "admin"
},
{
@@ -223,6 +223,11 @@ const logoClick = async () => {
onMounted(async () => {
await api.getOpenSettings(message);
try {
await api.getUserSettings(message);
} catch (error) {
console.error(error);
}
});
</script>

View File

@@ -9,8 +9,8 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
showAdminAuth, loading, adminTab,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -252,10 +252,6 @@ watch([page, pageSize], async () => {
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>

View File

@@ -6,10 +6,7 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const {
adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const { adminMailTabAddress } = useGlobalState()
const { t } = useI18n({
messages: {
@@ -48,13 +45,6 @@ const fetchMailData = async (limit, offset) => {
const deleteMail = async (curMailId) => {
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
};
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>

View File

@@ -1,12 +1,7 @@
<script setup>
import { onMounted } from 'vue';
import { useGlobalState } from '../../store'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const { adminAuth, showAdminAuth } = useGlobalState()
const fetchMailUnknowData = async (limit, offset) => {
return await api.fetch(
`/admin/mails_unknow`
@@ -18,17 +13,10 @@ const fetchMailUnknowData = async (limit, offset) => {
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
})
</script>
<template>
<div v-if="adminAuth" style="margin-top: 10px;">
<div style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="true" :fetchMailData="fetchMailUnknowData" :deleteMail="deleteMail" />
</div>
</template>

View File

@@ -1,12 +1,10 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage()
const cleanupModel = ref({
enableMailsAutoCleanup: false,
@@ -80,10 +78,6 @@ const save = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData();
})
</script>

View File

@@ -4,10 +4,8 @@ import { useI18n } from 'vue-i18n'
import { User, UserCheck, MailBulk } from '@vicons/fa'
import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { adminAuth } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
@@ -60,9 +58,6 @@ const fetchStatistics = async () => {
}
onMounted(async () => {
if (!adminAuth.value) {
return;
}
await fetchStatistics()
})
</script>

View File

@@ -31,7 +31,8 @@ const { t } = useI18n({
onMounted(async () => {
await api.getUserOpenSettings(message);
await api.getUserSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>

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
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 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 = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },

View File

@@ -60,6 +60,8 @@ DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 新用户默认角色, 仅在启用邮件验证时有效
# USER_DEFAULT_ROLE = "vip"
# admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制台
# ADMIN_USER_ROLE = "admin" # the role which can access admin panel
# 用户角色配置, 如果 domains 为空将使用 default_domains
# 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀
# USER_ROLES = [

View File

@@ -1,7 +1,5 @@
# Admin 用户相关
默认不允许用户注册,可通过
## 用户管理页面
![admin-user-management](/feature/admin-user-management.png)

View File

@@ -1,7 +1,11 @@
# 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)

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v0.6.1',
VERSION: 'v0.7.0',
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',

View File

@@ -19,6 +19,7 @@ export type Bindings = {
MAX_ADDRESS_LEN: string | number | undefined
DEFAULT_DOMAINS: string | string[] | undefined
DOMAINS: string | string[] | undefined
ADMIN_USER_ROLE: string | undefined
USER_DEFAULT_ROLE: string | UserRole | undefined
USER_ROLES: string | UserRole[] | undefined
DOMAIN_LABELS: string | string[] | undefined

View File

@@ -5,6 +5,7 @@ import { UserSettings } from "../models";
import { getJsonSetting, getUserRoles } from "../utils"
import { CONSTANTS } from "../constants";
import { commonGetUserRole } from "../common";
import { Jwt } from "hono/utils/jwt";
export default {
openSettings: async (c: Context<HonoCustomType>) => {
@@ -25,8 +26,23 @@ export default {
return c.text("User not found", 400);
}
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({
...user,
is_admin: is_admin,
access_token: access_token,
user_role: user_role
});
},

View File

@@ -136,6 +136,26 @@ app.use('/admin/*', async (c, next) => {
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)
});

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
# DOMAIN_LABELS = ["中文.xxx", "xxx.xxx2"]
# 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 = [
# { domains = ["xxx.xxx1" , "xxx.xxx2"], role = "vip", prefix = "vip" },