mirror of
https://github.com/beilunyang/moemail.git
synced 2025-12-24 11:30:51 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7ed0b031 | ||
|
|
ea7fd5490c | ||
|
|
b1d898e298 | ||
|
|
f86d944c25 | ||
|
|
59671091b6 | ||
|
|
19d805de57 | ||
|
|
2566a8a105 | ||
|
|
821a32aa4b |
16
README.md
16
README.md
@@ -15,6 +15,7 @@
|
||||
<a href="#部署">部署</a> •
|
||||
<a href="#邮箱域名配置">邮箱域名配置</a> •
|
||||
<a href="#权限系统">权限系统</a> •
|
||||
<a href="#系统设置">系统设置</a> •
|
||||
<a href="#Webhook 集成">Webhook 集成</a> •
|
||||
<a href="#OpenAPI">OpenAPI</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
@@ -273,6 +274,7 @@ pnpm deploy:cleanup
|
||||
### 注意事项
|
||||
- 确保域名的 DNS 托管在 Cloudflare
|
||||
- Email Worker 必须已经部署成功
|
||||
- 如果Catch-All 状态不可用,请在点击`路由规则`旁边的`目标地址`进去绑定一个邮箱
|
||||
|
||||
## 权限系统
|
||||
|
||||
@@ -327,8 +329,18 @@ pnpm deploy:cleanup
|
||||
- **Webhook 管理**:配置邮件通知的 Webhook
|
||||
- **API Key 管理**:创建和管理 API 访问密钥
|
||||
- **用户管理**:升降用户角色
|
||||
- **系统配置**:管理系统全局设置
|
||||
- **系统设置**:管理系统全局设置
|
||||
|
||||
## 系统设置
|
||||
|
||||
系统设置存储在 Cloudflare KV 中,包括以下内容:
|
||||
|
||||
- `DEFAULT_ROLE`: 新注册用户默认角色,可选值为 `CIVILIAN`、`KNIGHT`、`DUKE`
|
||||
- `EMAIL_DOMAINS`: 支持的邮箱域名,多个域名用逗号分隔
|
||||
- `ADMIN_CONTACT`: 管理员联系方式
|
||||
- `MAX_EMAILS`: 每个用户可创建的最大邮箱数量
|
||||
|
||||
**皇帝**角色可以在个人中心页面设置
|
||||
|
||||
## Webhook 集成
|
||||
|
||||
@@ -491,7 +503,7 @@ const data = await res.json();
|
||||
本项目采用 [MIT](LICENSE) 许可证
|
||||
|
||||
## 交流群
|
||||
<img src="https://pic.otaku.ren/20250210/AQADOMUxG7BRUFV-.jpg" style="width: 400px;"/>
|
||||
<img src="https://pic.otaku.ren/20250221/AQAD8b8xG9vVwFV-.jpg" style="width: 400px;"/>
|
||||
<br />
|
||||
如二维码失效,请添加我的个人微信(hansenones),并备注 "MoeMail" 加入微信交流群
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
const env = getRequestContext().env
|
||||
const adminContact = await env.SITE_CONFIG.get("ADMIN_CONTACT")
|
||||
|
||||
return Response.json({
|
||||
adminContact: adminContact || ""
|
||||
})
|
||||
}
|
||||
@@ -1,28 +1,32 @@
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
const env = getRequestContext().env
|
||||
const [defaultRole, emailDomains, adminContact] = await Promise.all([
|
||||
const [defaultRole, emailDomains, adminContact, maxEmails] = await Promise.all([
|
||||
env.SITE_CONFIG.get("DEFAULT_ROLE"),
|
||||
env.SITE_CONFIG.get("EMAIL_DOMAINS"),
|
||||
env.SITE_CONFIG.get("ADMIN_CONTACT")
|
||||
env.SITE_CONFIG.get("ADMIN_CONTACT"),
|
||||
env.SITE_CONFIG.get("MAX_EMAILS")
|
||||
])
|
||||
|
||||
return Response.json({
|
||||
defaultRole: defaultRole || ROLES.CIVILIAN,
|
||||
emailDomains: emailDomains || "",
|
||||
adminContact: adminContact || ""
|
||||
emailDomains: emailDomains || "moemail.app",
|
||||
adminContact: adminContact || "",
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { defaultRole, emailDomains, adminContact } = await request.json() as {
|
||||
const { defaultRole, emailDomains, adminContact, maxEmails } = await request.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string,
|
||||
adminContact: string
|
||||
adminContact: string,
|
||||
maxEmails: string
|
||||
}
|
||||
|
||||
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
||||
@@ -33,7 +37,8 @@ export async function POST(request: Request) {
|
||||
await Promise.all([
|
||||
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
|
||||
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains),
|
||||
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact)
|
||||
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact),
|
||||
env.SITE_CONFIG.put("MAX_EMAILS", maxEmails)
|
||||
])
|
||||
|
||||
return Response.json({ success: true })
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
|
||||
return NextResponse.json({ domains: domainString ? domainString.split(',') : ["moemail.app"] })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch domains:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取域名列表失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,29 +7,37 @@ import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
import { getUserRole } from "@/lib/auth"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const db = createDb()
|
||||
const env = getRequestContext().env
|
||||
|
||||
const userId = await getUserId()
|
||||
const userRole = await getUserRole(userId!)
|
||||
|
||||
try {
|
||||
const activeEmailsCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, userId!),
|
||||
gt(emails.expiresAt, new Date())
|
||||
if (userRole !== ROLES.EMPEROR) {
|
||||
const maxEmails = await env.SITE_CONFIG.get("MAX_EMAILS") || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
const activeEmailsCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, userId!),
|
||||
gt(emails.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (Number(activeEmailsCount[0].count) >= EMAIL_CONFIG.MAX_ACTIVE_EMAILS) {
|
||||
return NextResponse.json(
|
||||
{ error: `已达到最大邮箱数量限制 (${EMAIL_CONFIG.MAX_ACTIVE_EMAILS})` },
|
||||
{ status: 403 }
|
||||
)
|
||||
|
||||
if (Number(activeEmailsCount[0].count) >= Number(maxEmails)) {
|
||||
return NextResponse.json(
|
||||
{ error: `已达到最大邮箱数量限制 (${maxEmails})` },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { name, expiryTime, domain } = await request.json<{
|
||||
@@ -45,7 +53,7 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
const domainString = await env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
||||
const domains = domainString ? domainString.split(',') : ["moemail.app"]
|
||||
|
||||
if (!domains || !domains.includes(domain)) {
|
||||
|
||||
@@ -12,16 +12,17 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
interface CreateDialogProps {
|
||||
onEmailCreated: () => void
|
||||
}
|
||||
|
||||
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
const { config } = useConfig()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emailName, setEmailName] = useState("")
|
||||
const [domains, setDomains] = useState<string[]>([])
|
||||
const [currentDomain, setCurrentDomain] = useState("")
|
||||
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
|
||||
const { toast } = useToast()
|
||||
@@ -83,16 +84,11 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDomains = async () => {
|
||||
const response = await fetch("/api/emails/domains");
|
||||
const data = (await response.json()) as { domains: string[] };
|
||||
setDomains(data.domains || []);
|
||||
setCurrentDomain(data.domains[0] || "");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDomains()
|
||||
}, [])
|
||||
if ((config?.emailDomainsArray?.length ?? 0) > 0) {
|
||||
setCurrentDomain(config?.emailDomainsArray[0] ?? "")
|
||||
}
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -114,13 +110,13 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
placeholder="输入邮箱名"
|
||||
className="flex-1"
|
||||
/>
|
||||
{domains.length > 1 && (
|
||||
{(config?.emailDomainsArray?.length ?? 0) > 1 && (
|
||||
<Select value={currentDomain} onValueChange={setCurrentDomain}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains.map(d => (
|
||||
{config?.emailDomainsArray?.map(d => (
|
||||
<SelectItem key={d} value={d}>@{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { ROLES } from "@/lib/permissions"
|
||||
import { useUserRole } from "@/hooks/use-user-role"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
interface Email {
|
||||
id: string
|
||||
@@ -40,6 +43,8 @@ interface EmailResponse {
|
||||
|
||||
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
const { data: session } = useSession()
|
||||
const { config } = useConfig()
|
||||
const { role } = useUserRole()
|
||||
const [emails, setEmails] = useState<Email[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -109,7 +114,6 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (session) fetchEmails()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session])
|
||||
|
||||
const handleDelete = async (email: Email) => {
|
||||
@@ -167,7 +171,11 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱
|
||||
{role === ROLES.EMPEROR ? (
|
||||
`${total}/∞ 个邮箱`
|
||||
) : (
|
||||
`${total}/${config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<CreateDialog onEmailCreated={handleRefresh} />
|
||||
|
||||
39
app/components/float-menu.tsx
Normal file
39
app/components/float-menu.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Github } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function FloatMenu() {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20"
|
||||
onClick={() => window.open("https://github.com/beilunyang/moemail", "_blank")}
|
||||
>
|
||||
<Github
|
||||
className="w-4 h-4 transition-all duration-300 text-primary group-hover:scale-110"
|
||||
/>
|
||||
<span className="sr-only">获取网站源代码</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<p>获取网站源代码</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAdminContact } from "@/hooks/use-admin-contact"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
export function NoPermissionDialog() {
|
||||
const router = useRouter()
|
||||
const { adminContact } = useAdminContact()
|
||||
const { config } = useConfig()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
|
||||
@@ -15,8 +15,8 @@ export function NoPermissionDialog() {
|
||||
<h1 className="text-xl md:text-2xl font-bold">权限不足</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">你没有权限访问此页面,请联系网站管理员</p>
|
||||
{
|
||||
adminContact && (
|
||||
<p className="text-sm md:text-base text-muted-foreground">管理员联系方式:{adminContact}</p>
|
||||
config?.adminContact && (
|
||||
<p className="text-sm md:text-base text-muted-foreground">管理员联系方式:{config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { useAdminContact } from "@/hooks/use-admin-contact"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
type ApiKey = {
|
||||
id: string
|
||||
@@ -68,7 +68,7 @@ export function ApiKeyPanel() {
|
||||
}
|
||||
}, [canManageApiKey])
|
||||
|
||||
const { adminContact } = useAdminContact()
|
||||
const { config } = useConfig()
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return
|
||||
@@ -248,8 +248,8 @@ export function ApiKeyPanel() {
|
||||
<p>需要公爵或更高权限才能管理 API Key</p>
|
||||
<p className="mt-2">请联系网站管理员升级您的角色</p>
|
||||
{
|
||||
adminContact && (
|
||||
<p className="mt-2">管理员联系方式:{adminContact}</p>
|
||||
config?.adminContact && (
|
||||
<p className="mt-2">管理员联系方式:{config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export function ConfigPanel() {
|
||||
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||
const [adminContact, setAdminContact] = useState<string>("")
|
||||
const [maxEmails, setMaxEmails] = useState<string>(EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -31,10 +33,14 @@ export function ConfigPanel() {
|
||||
if (res.ok) {
|
||||
const data = await res.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string
|
||||
emailDomains: string,
|
||||
adminContact: string,
|
||||
maxEmails: string
|
||||
}
|
||||
setDefaultRole(data.defaultRole)
|
||||
setEmailDomains(data.emailDomains)
|
||||
setAdminContact(data.adminContact)
|
||||
setMaxEmails(data.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +53,8 @@ export function ConfigPanel() {
|
||||
body: JSON.stringify({
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact
|
||||
adminContact,
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -112,6 +119,20 @@ export function ConfigPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">最大邮箱数量:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={maxEmails}
|
||||
onChange={(e) => setMaxEmails(e.target.value)}
|
||||
placeholder={`默认为 ${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function useAdminContact() {
|
||||
const [adminContact, setAdminContact] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchAdminContact = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin-contact")
|
||||
if (!res.ok) throw new Error("获取管理员联系方式失败")
|
||||
const data = await res.json() as { adminContact: string }
|
||||
setAdminContact(data.adminContact)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "获取管理员联系方式失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminContact()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
adminContact,
|
||||
loading,
|
||||
refreshAdminContact: fetchAdminContact
|
||||
}
|
||||
}
|
||||
62
app/hooks/use-config.ts
Normal file
62
app/hooks/use-config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface Config {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>
|
||||
emailDomains: string
|
||||
emailDomainsArray: string[]
|
||||
adminContact: string
|
||||
maxEmails: number
|
||||
}
|
||||
|
||||
interface ConfigStore {
|
||||
config: Config | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
fetch: () => Promise<void>
|
||||
}
|
||||
|
||||
const useConfigStore = create<ConfigStore>((set) => ({
|
||||
config: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetch: async () => {
|
||||
try {
|
||||
set({ loading: true, error: null })
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) throw new Error("获取配置失败")
|
||||
const data = await res.json() as Config
|
||||
set({
|
||||
config: {
|
||||
defaultRole: data.defaultRole || ROLES.CIVILIAN,
|
||||
emailDomains: data.emailDomains,
|
||||
emailDomainsArray: data.emailDomains.split(','),
|
||||
adminContact: data.adminContact || "",
|
||||
maxEmails: Number(data.maxEmails) || EMAIL_CONFIG.MAX_ACTIVE_EMAILS
|
||||
},
|
||||
loading: false
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "获取配置失败",
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
export function useConfig() {
|
||||
const store = useConfigStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.config && !store.loading) {
|
||||
store.fetch()
|
||||
}
|
||||
}, [store.config, store.loading])
|
||||
|
||||
return store
|
||||
}
|
||||
21
app/hooks/use-user-role.ts
Normal file
21
app/hooks/use-user-role.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { Role } from "@/lib/permissions"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useUserRole() {
|
||||
const { data: session } = useSession()
|
||||
const [role, setRole] = useState<Role | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.roles?.[0]?.name) {
|
||||
setRole(session.user.roles[0].name as Role)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
return {
|
||||
role,
|
||||
loading: !session
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Metadata, Viewport } from "next"
|
||||
import { zpix } from "./fonts"
|
||||
import "./globals.css"
|
||||
import { Providers } from "./providers"
|
||||
import { FloatMenu } from "@/components/float-menu"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MoeMail - 萌萌哒临时邮箱服务",
|
||||
@@ -98,6 +99,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
</Providers>
|
||||
<Toaster />
|
||||
<FloatMenu />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -52,6 +52,15 @@ export async function assignRoleToUser(db: Db, userId: string, roleId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserRole(userId: string) {
|
||||
const db = createDb()
|
||||
const userRoleRecords = await db.query.userRoles.findMany({
|
||||
where: eq(userRoles.userId, userId),
|
||||
with: { role: true },
|
||||
})
|
||||
return userRoleRecords[0].role.name
|
||||
}
|
||||
|
||||
export async function checkPermission(permission: Permission) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return false
|
||||
|
||||
@@ -32,6 +32,10 @@ export async function middleware(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
if (pathname === '/api/config' && request.method === 'GET') {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
for (const [route, permission] of Object.entries(API_PERMISSIONS)) {
|
||||
if (pathname.startsWith(route)) {
|
||||
const hasAccess = await checkPermission(permission)
|
||||
@@ -56,6 +60,5 @@ export const config = {
|
||||
'/api/roles/:path*',
|
||||
'/api/config/:path*',
|
||||
'/api/api-keys/:path*',
|
||||
'/api/admin-contact',
|
||||
]
|
||||
}
|
||||
@@ -48,7 +48,8 @@
|
||||
"react-dom": "19.0.0",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241127.0",
|
||||
|
||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.24.1
|
||||
zustand:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3(@types/react@18.3.14)(react@19.0.0)
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20241127.0
|
||||
@@ -1267,79 +1270,67 @@ packages:
|
||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.0.4':
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.33.5':
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.33.5':
|
||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.33.5':
|
||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.33.5':
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
@@ -1428,56 +1419,48 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.1.1':
|
||||
resolution: {integrity: sha512-I5Q6M3T9jzTUM2JlwTBy/VBSX+YCDvPLnSaJX5wE5GEPeaJkipMkvTA9+IiFK5PG5ljXTqVFVUj5BSHiYLCpoQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@13.5.7':
|
||||
resolution: {integrity: sha512-A06vkj+8X+tLRzSja5REm/nqVOCzR+x5Wkw325Q/BQRyRXWGCoNbQ6A+BR5M86TodigrRfI3lUZEKZKe3QJ9Bg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.1.1':
|
||||
resolution: {integrity: sha512-4cPMSYmyXlOAk8U04ouEACEGnOwYM9uJOXZnm9GBXIKRbNEvBOH9OePhHiDWqOws6iaHvGayaKr+76LmM41yJA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@13.5.7':
|
||||
resolution: {integrity: sha512-UdHm7AlxIbdRdMsK32cH0EOX4OmzAZ4Xm+UVlS0YdvwLkI3pb7AoBEoVMG5H0Wj6Wpz6GNkrFguHTRLymTy6kw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.1.1':
|
||||
resolution: {integrity: sha512-KgIiKDdV35KwL9TrTxPFGsPb3J5RuDpw828z3MwMQbWaOmpp/T4MeWQCwo+J2aOxsyAcfsNE334kaWXCb6YTTA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@13.5.7':
|
||||
resolution: {integrity: sha512-c50Y8xBKU16ZGj038H6C13iedRglxvdQHD/1BOtes56gwUrIRDX2Nkzn3mYtpz3Wzax0gfAF9C0Nqljt93IxvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.1.1':
|
||||
resolution: {integrity: sha512-aHP/29x8loFhB3WuW2YaWaYFJN389t6/SBsug19aNwH+PRLzDEQfCvtuP6NxRCido9OAoExd+ZuYJKF9my1Kpg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@13.5.7':
|
||||
resolution: {integrity: sha512-NcUx8cmkA+JEp34WNYcKW6kW2c0JBhzJXIbw+9vKkt9m/zVJ+KfizlqmoKf04uZBtzFN6aqE2Fyv2MOd021WIA==}
|
||||
@@ -5613,6 +5596,24 @@ packages:
|
||||
zod@3.24.1:
|
||||
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
|
||||
|
||||
zustand@5.0.3:
|
||||
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=18.0.0'
|
||||
use-sync-external-store: '>=1.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@@ -11302,3 +11303,8 @@ snapshots:
|
||||
stacktracey: 2.1.8
|
||||
|
||||
zod@3.24.1: {}
|
||||
|
||||
zustand@5.0.3(@types/react@18.3.14)(react@19.0.0):
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.14
|
||||
react: 19.0.0
|
||||
|
||||
Reference in New Issue
Block a user