Merge pull request #42 from cloud-neutral-toolkit/codex/homepage-live-stats
Codex/homepage live stats
This commit is contained in:
commit
4f16ed15b6
@ -80,6 +80,12 @@ yarn build
|
||||
|
||||
> For detailed steps on configuring GitHub and Google OIDC authentication, please refer to the [OIDC Authentication Guide](./docs/integrations/oidc-auth.md).
|
||||
|
||||
## 统计配置 (Homepage Stats Configuration)
|
||||
|
||||
首页“注册用户数 / 访问量”所需 Cloudflare 变量说明,请参阅 [Cloudflare Web Analytics 集成配置](./docs/integrations/cloudflare-web-analytics.md)。
|
||||
|
||||
> For Cloudflare variables used by homepage stats, see the [Cloudflare Web Analytics integration guide](./docs/integrations/cloudflare-web-analytics.md).
|
||||
|
||||
## 开发指南 (Development Guidelines)
|
||||
|
||||
有关详细的编码标准、架构规则和 Agent 特定说明,请参阅 [AGENTS.md](./AGENTS.md)。
|
||||
|
||||
@ -46,6 +46,7 @@ This directory follows a standard open-source documentation layout and mirrors t
|
||||
- Integrations
|
||||
- `integrations/databases.md`
|
||||
- `integrations/cloud.md`
|
||||
- `integrations/cloudflare-web-analytics.md`
|
||||
- `integrations/ai-providers.md`
|
||||
- Advanced
|
||||
- `advanced/performance.md`
|
||||
|
||||
80
docs/integrations/cloudflare-web-analytics.md
Normal file
80
docs/integrations/cloudflare-web-analytics.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Cloudflare Web Analytics 集成配置
|
||||
|
||||
本页说明首页统计接口 `/api/marketing/home-stats` 依赖的 3 个 Cloudflare 环境变量如何获取,以及应配置到哪里。
|
||||
|
||||
## 需要的环境变量
|
||||
|
||||
```bash
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
|
||||
```
|
||||
|
||||
## 变量获取方式
|
||||
|
||||
### 1) `CLOUDFLARE_API_TOKEN`
|
||||
|
||||
用途:服务端调用 Cloudflare GraphQL API 读取访问量。
|
||||
|
||||
获取路径:
|
||||
|
||||
1. 打开 Cloudflare 控制台,右上角头像 -> **My Profile**
|
||||
2. 进入 **API Tokens**
|
||||
3. 点击 **Create Token**
|
||||
4. 建议创建仅只读 token,至少包含:**Account Analytics:Read**(作用域限定到目标 Account)
|
||||
5. 复制生成后的 token(只显示一次)
|
||||
|
||||
### 2) `CLOUDFLARE_ACCOUNT_ID`
|
||||
|
||||
用途:GraphQL 查询时定位账号。
|
||||
|
||||
获取方式(任选其一):
|
||||
|
||||
- 在 Cloudflare 控制台 URL 中,账号路径段通常就是 account id。
|
||||
- 在账号总览页面(Overview)侧边栏/页面信息中复制 Account ID。
|
||||
|
||||
### 3) `CLOUDFLARE_WEB_ANALYTICS_SITE_TAG`
|
||||
|
||||
用途:定位具体 Web Analytics 站点。
|
||||
|
||||
获取方式(任选其一):
|
||||
|
||||
- 你当前这类链接中可直接看到:
|
||||
`.../web-analytics/overview?siteTag~in=<SITE_TAG>&excludeBots=Yes`
|
||||
其中 `<SITE_TAG>` 就是变量值。
|
||||
- 在 Cloudflare Web Analytics 的站点设置/安装脚本中,`siteTag`(或 beacon token)即对应值。
|
||||
|
||||
## 配置写入位置
|
||||
|
||||
### 本地开发
|
||||
|
||||
写入 `console.svc.plus/.env.local`:
|
||||
|
||||
```bash
|
||||
CLOUDFLARE_API_TOKEN=...
|
||||
CLOUDFLARE_ACCOUNT_ID=...
|
||||
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
|
||||
```
|
||||
|
||||
### 线上部署
|
||||
|
||||
把同名变量写入 `console.svc.plus` 的部署环境(例如 Vercel/Cloud Run 的环境变量配置)。
|
||||
|
||||
> 注意:这些变量属于服务端密钥,不要暴露到 `NEXT_PUBLIC_*`。
|
||||
|
||||
## 联调验证
|
||||
|
||||
部署后访问:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://console.svc.plus/api/marketing/home-stats
|
||||
```
|
||||
|
||||
期望返回中 `visits.daily/weekly/monthly` 为数字(非 `null`)。
|
||||
|
||||
如果是 `null`,优先检查:
|
||||
|
||||
1. token 权限是否包含 Analytics Read
|
||||
2. Account ID 是否与 siteTag 属于同一账号
|
||||
3. 环境变量是否已在当前运行实例生效(重启/重新部署后再测)
|
||||
|
||||
@ -9,6 +9,8 @@ const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
type AccountUser = {
|
||||
id?: string
|
||||
uuid?: string
|
||||
proxyUuid?: string
|
||||
proxyUuidExpiresAt?: string
|
||||
name?: string
|
||||
username?: string
|
||||
email: string
|
||||
@ -24,6 +26,7 @@ type AccountUser = {
|
||||
role?: string
|
||||
groups?: string[]
|
||||
permissions?: string[]
|
||||
readOnly?: boolean
|
||||
tenantId?: string
|
||||
tenants?: Array<{
|
||||
id?: string
|
||||
@ -37,6 +40,23 @@ type SessionResponse = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
function normalizeRole(role: unknown): string {
|
||||
if (typeof role !== 'string') {
|
||||
return 'user'
|
||||
}
|
||||
const normalized = role.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return 'user'
|
||||
}
|
||||
if (normalized === 'root' || normalized === 'super_admin') {
|
||||
return 'admin'
|
||||
}
|
||||
if (normalized === 'readonly' || normalized === 'read_only') {
|
||||
return 'user'
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
async function fetchSession(token: string) {
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
|
||||
@ -86,10 +106,8 @@ export async function GET(request: NextRequest) {
|
||||
: false
|
||||
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled
|
||||
|
||||
const normalizedRole =
|
||||
typeof rawUser.role === 'string' && rawUser.role.trim().length > 0
|
||||
? rawUser.role.trim().toLowerCase()
|
||||
: 'user'
|
||||
const normalizedRole = normalizeRole(rawUser.role)
|
||||
const rawRole = typeof rawUser.role === 'string' ? rawUser.role.trim().toLowerCase() : ''
|
||||
const normalizedGroups = Array.isArray(rawUser.groups)
|
||||
? rawUser.groups
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
@ -100,6 +118,21 @@ export async function GET(request: NextRequest) {
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
: []
|
||||
const normalizedReadOnly =
|
||||
Boolean(rawUser.readOnly) ||
|
||||
normalizedGroups.some((group) => group.toLowerCase() === 'readonly role') ||
|
||||
rawRole === 'readonly' ||
|
||||
rawRole === 'read_only' ||
|
||||
String(rawUser.email ?? '').trim().toLowerCase() === 'demo@svc.plus'
|
||||
const normalizedProxyUuid =
|
||||
typeof rawUser.proxyUuid === 'string' && rawUser.proxyUuid.trim().length > 0
|
||||
? rawUser.proxyUuid.trim()
|
||||
: undefined
|
||||
const normalizedProxyUuidExpiresAt =
|
||||
typeof rawUser.proxyUuidExpiresAt === 'string' && rawUser.proxyUuidExpiresAt.trim().length > 0
|
||||
? rawUser.proxyUuidExpiresAt.trim()
|
||||
: undefined
|
||||
|
||||
const normalizedTenantId =
|
||||
typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0
|
||||
? rawUser.tenantId.trim()
|
||||
@ -158,6 +191,9 @@ export async function GET(request: NextRequest) {
|
||||
role: normalizedRole,
|
||||
groups: normalizedGroups,
|
||||
permissions: normalizedPermissions,
|
||||
readOnly: normalizedReadOnly,
|
||||
proxyUuid: normalizedProxyUuid,
|
||||
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
|
||||
tenantId: normalizedTenantId,
|
||||
tenants: normalizedTenants,
|
||||
},
|
||||
|
||||
@ -13,6 +13,8 @@ export type TenantMembership = {
|
||||
type User = {
|
||||
id: string
|
||||
uuid: string
|
||||
proxyUuid?: string
|
||||
proxyUuidExpiresAt?: string
|
||||
email: string
|
||||
name?: string
|
||||
username: string
|
||||
@ -25,6 +27,7 @@ type User = {
|
||||
isUser: boolean
|
||||
isOperator: boolean
|
||||
isAdmin: boolean
|
||||
isReadOnly: boolean
|
||||
tenantId?: string
|
||||
tenants?: TenantMembership[]
|
||||
mfa?: {
|
||||
@ -50,6 +53,10 @@ type UserStore = {
|
||||
}
|
||||
|
||||
const KNOWN_ROLE_MAP: Record<string, UserRole> = {
|
||||
root: 'admin',
|
||||
super_admin: 'admin',
|
||||
readonly: 'user',
|
||||
read_only: 'user',
|
||||
admin: 'admin',
|
||||
administrator: 'admin',
|
||||
operator: 'operator',
|
||||
@ -97,6 +104,9 @@ async function fetchSessionUser(): Promise<User | null> {
|
||||
role?: string
|
||||
groups?: string[]
|
||||
permissions?: string[]
|
||||
proxyUuid?: string
|
||||
proxyUuidExpiresAt?: string
|
||||
readOnly?: boolean
|
||||
tenantId?: string
|
||||
tenants?: TenantMembership[]
|
||||
mfa?: {
|
||||
@ -141,6 +151,7 @@ async function fetchSessionUser(): Promise<User | null> {
|
||||
}
|
||||
|
||||
const normalizedRole = normalizeRole(role)
|
||||
const rawRole = typeof role === 'string' ? role.trim().toLowerCase() : ''
|
||||
const normalizedGroups = Array.isArray(groups)
|
||||
? groups
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
@ -151,6 +162,22 @@ async function fetchSessionUser(): Promise<User | null> {
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
: []
|
||||
const normalizedEmail = typeof email === 'string' ? email.trim().toLowerCase() : ''
|
||||
const inferredReadOnly =
|
||||
rawRole === 'readonly' ||
|
||||
rawRole === 'read_only' ||
|
||||
normalizedEmail === 'demo@svc.plus' ||
|
||||
normalizedGroups.some((value) => value.toLowerCase() === 'readonly role')
|
||||
const normalizedReadOnly = Boolean(sessionUser.readOnly ?? inferredReadOnly)
|
||||
const normalizedProxyUuid =
|
||||
typeof sessionUser.proxyUuid === 'string' && sessionUser.proxyUuid.trim().length > 0
|
||||
? sessionUser.proxyUuid.trim()
|
||||
: undefined
|
||||
const normalizedProxyUuidExpiresAt =
|
||||
typeof sessionUser.proxyUuidExpiresAt === 'string' && sessionUser.proxyUuidExpiresAt.trim().length > 0
|
||||
? sessionUser.proxyUuidExpiresAt.trim()
|
||||
: undefined
|
||||
|
||||
const normalizedTenantId =
|
||||
typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0
|
||||
? sessionUser.tenantId.trim()
|
||||
@ -189,6 +216,8 @@ async function fetchSessionUser(): Promise<User | null> {
|
||||
return {
|
||||
id: identifier,
|
||||
uuid: identifier,
|
||||
proxyUuid: normalizedProxyUuid,
|
||||
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
|
||||
email,
|
||||
name: normalizedName,
|
||||
username: normalizedUsername ?? email,
|
||||
@ -202,6 +231,7 @@ async function fetchSessionUser(): Promise<User | null> {
|
||||
isUser: normalizedRole === 'user',
|
||||
isOperator: normalizedRole === 'operator',
|
||||
isAdmin: normalizedRole === 'admin',
|
||||
isReadOnly: normalizedReadOnly,
|
||||
tenantId: normalizedTenantId,
|
||||
tenants: normalizedTenants,
|
||||
}
|
||||
|
||||
@ -126,6 +126,7 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps
|
||||
const setupRequested = searchParams.get('setupMfa') === '1'
|
||||
const hasPendingMfa = Boolean(status?.totpPending && !status?.totpEnabled)
|
||||
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
|
||||
const isReadOnlyAccount = Boolean(user?.isReadOnly)
|
||||
|
||||
const resolveErrorMessage = useCallback(
|
||||
(code?: string | null) => {
|
||||
@ -146,6 +147,7 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps
|
||||
'mfa_challenge_creation_failed': copy.errors.provisioningFailed,
|
||||
'mfa_status_failed': copy.errors.network,
|
||||
'account_service_unreachable': copy.errors.network,
|
||||
'read_only_account': copy.errors.disableFailed,
|
||||
'mfa_disable_failed': copy.errors.disableFailed,
|
||||
'mfa_not_enabled': copy.errors.disableFailed,
|
||||
'mfa_code_required': copy.errors.missingCode,
|
||||
@ -450,6 +452,19 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps
|
||||
)
|
||||
}
|
||||
|
||||
if (isReadOnlyAccount) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-[var(--color-text)]">{copy.title}</h2>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-subtle)]">
|
||||
{language === 'zh'
|
||||
? 'Demo 体验账号已关闭 MFA,且账号为只读模式。你可以浏览控制台与使用二维码,但不能执行修改操作。'
|
||||
: 'MFA is disabled for the Demo account and the account is read-only. You can browse and use the QR code, but changes are blocked.'}
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const statusLabel = displayStatus?.totpEnabled
|
||||
? copy.state.enabled
|
||||
: displayStatus?.totpPending || hasPendingMfa
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Copy } from 'lucide-react'
|
||||
@ -45,15 +45,27 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
const mfaCopy = translations[language].userCenter.mfa
|
||||
const user = useUserStore((state) => state.user)
|
||||
const isLoading = useUserStore((state) => state.isLoading)
|
||||
const refresh = useUserStore((state) => state.refresh)
|
||||
const logout = useUserStore((state) => state.logout)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const displayName = useMemo(() => resolveDisplayName(user), [user])
|
||||
const uuid = user?.uuid ?? user?.id ?? '—'
|
||||
const vlessUuid = user?.uuid ?? user?.id ?? null
|
||||
const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—'
|
||||
const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null
|
||||
const username = user?.username ?? '—'
|
||||
const email = user?.email ?? '—'
|
||||
const docsUrl = mfaCopy.actions.docsUrl
|
||||
const isDemoReadOnly = Boolean(user?.isReadOnly && user?.email?.toLowerCase() === 'demo@svc.plus')
|
||||
const demoUuidExpiresAtText = useMemo(() => {
|
||||
if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(user.proxyUuidExpiresAt)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
return date.toLocaleString()
|
||||
}, [isDemoReadOnly, user?.proxyUuidExpiresAt])
|
||||
|
||||
const mfaStatusLabel = useMemo(() => {
|
||||
if (user?.mfaEnabled) {
|
||||
@ -68,7 +80,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const identifier = user?.uuid ?? user?.id
|
||||
const identifier = user?.proxyUuid ?? user?.uuid ?? user?.id
|
||||
if (!identifier) {
|
||||
return
|
||||
}
|
||||
@ -92,7 +104,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
} catch (error) {
|
||||
console.warn('Failed to copy UUID', error)
|
||||
}
|
||||
}, [user?.id, user?.uuid])
|
||||
}, [user?.id, user?.proxyUuid, user?.uuid])
|
||||
|
||||
const handleGoToSetup = useCallback(() => {
|
||||
router.push('/panel/account?setupMfa=1')
|
||||
@ -104,10 +116,34 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
router.refresh()
|
||||
}, [logout, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) {
|
||||
return
|
||||
}
|
||||
const expiresAt = new Date(user.proxyUuidExpiresAt).getTime()
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
return
|
||||
}
|
||||
const delay = Math.max(1000, expiresAt - Date.now() + 1500)
|
||||
const timer = window.setTimeout(() => {
|
||||
void refresh()
|
||||
}, delay)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [isDemoReadOnly, refresh, user?.proxyUuidExpiresAt])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-[var(--color-text)] transition-colors">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--color-text-subtle)] opacity-90">{copy.uuidNote}</p>
|
||||
{isDemoReadOnly ? (
|
||||
<p className="mt-2 rounded-[var(--radius-md)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-2 text-xs text-[var(--color-warning-foreground)]">
|
||||
{language === 'zh'
|
||||
? `Demo 体验账号为只读模式:可浏览控制台、可使用 VLESS 二维码,但不能修改任何配置。UUID 每 1 小时自动刷新${demoUuidExpiresAtText ? `(下次刷新约 ${demoUuidExpiresAtText})` : ''}。`
|
||||
: `Demo account runs in read-only mode: browse safely and use the VLESS QR code, but no configuration changes are allowed. UUID rotates every hour${demoUuidExpiresAtText ? ` (next refresh around ${demoUuidExpiresAtText})` : ''}.`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!hideMfaMainPrompt && requiresSetup ? (
|
||||
|
||||
@ -58,6 +58,10 @@ type AccountSessionResponse = {
|
||||
}
|
||||
|
||||
const KNOWN_ROLE_MAP: Record<string, AccountUserRole> = {
|
||||
root: 'admin',
|
||||
super_admin: 'admin',
|
||||
readonly: 'user',
|
||||
read_only: 'user',
|
||||
admin: 'admin',
|
||||
administrator: 'admin',
|
||||
operator: 'operator',
|
||||
@ -246,4 +250,3 @@ export async function getAccountSession(request?: NextRequest): Promise<AccountS
|
||||
return { token, user: null }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user