feat(dashboard-fresh): migrate panel routes to Fresh

- Panel Infrastructure:
  - Add lib/userSession.ts for user session utilities
  - Create islands/panel/Sidebar.tsx with navigation and MFA
  warnings
  - Create islands/panel/Header.tsx with user info and role badges
  - Create islands/panel/PanelLayout.tsx as layout wrapper
  - Add routes/panel/index.tsx as dashboard home page
  - Add routes/panel/account.tsx for account settings
  - Add routes/panel/mail.tsx for mail service (placeholder)
This commit is contained in:
Haitao Pan 2025-11-04 23:30:39 +08:00
parent b542a0ae17
commit dd6bb68d25
9 changed files with 1259 additions and 55 deletions

View File

@ -1,57 +1,36 @@
{
"lock": false,
"nodeModulesDir": "auto",
"tasks": {
// Development
"dev": "deno run -A --watch=static/,routes/ dev.ts",
"dev:full": "deno task css:watch & deno task dev",
"start": "deno run -A main.ts",
// CSS Build (Tailwind)
"start": "deno run -A main.ts --port 3000",
"css:build": "deno run -A npm:tailwindcss@3.4.3 -i ./app/globals.css -o ./static/styles/globals.css --minify",
"css:watch": "deno run -A npm:tailwindcss@3.4.3 -i ./app/globals.css -o ./static/styles/globals.css --watch",
// Build tasks
"prebuild": "deno run -A scripts/build-manifest.ts && deno run -A scripts/export-slugs.ts && deno run -A scripts/scan-md.ts && deno run -A scripts/fetch-dl-index.ts",
"build": "deno run -A scripts/build.ts",
"preview": "deno run -A main.ts",
// Fresh utilities
"preview": "deno run -A main.ts --port 3000",
"update": "deno run -A -r https://fresh.deno.dev/update .",
// Quality checks
"lint": "deno lint",
"fmt": "deno fmt",
"fmt:check": "deno fmt --check",
"check": "deno check **/*.ts **/*.tsx",
// Testing
"test": "deno test --allow-all",
// Clean
"clean": "rm -rf _fresh && rm -rf static/_build"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": ["deno.window", "dom", "dom.iterable"]
},
"imports": {
// Fresh framework
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
// Preact (Fresh uses Preact)
"preact": "https://esm.sh/preact@10.19.6",
"preact/": "https://esm.sh/preact@10.19.6/",
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"preact": "https://esm.sh/preact@10.22.0",
"preact/": "https://esm.sh/preact@10.22.0/",
"preact/hooks": "https://esm.sh/preact@10.19.6/hooks",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.3.1",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
// Path aliases
"@/": "./",
"@cms/": "./cms/",
"@components/": "./components/",
@ -60,50 +39,25 @@
"@types/": "./types/",
"@server/": "./server/",
"@routes/": "./routes/",
// State management - Zustand vanilla for Deno
"zustand": "https://esm.sh/zustand@4.5.0",
"zustand/vanilla": "https://esm.sh/zustand@4.5.0/vanilla",
"zustand/middleware": "https://esm.sh/zustand@4.5.0/middleware",
// Markdown & content - using esm.sh
"gray-matter": "https://esm.sh/gray-matter@4.0.3",
"marked": "https://esm.sh/marked@12.0.0",
"js-yaml": "https://esm.sh/js-yaml@4.1.0",
// UI & Icons
"lucide-preact": "https://esm.sh/lucide-preact@0.319.0",
"clsx": "https://esm.sh/clsx@2.1.0",
// Security
"dompurify": "https://esm.sh/dompurify@3.0.9",
"sanitize-html": "https://esm.sh/sanitize-html@2.12.1",
// QR Code
"qrcode": "https://esm.sh/qrcode@1.5.3",
// Tailwind (keep npm: for build tools)
"tailwindcss": "npm:tailwindcss@3.4.3",
"tailwindcss/": "npm:/tailwindcss@3.4.3/",
"tailwindcss/plugin": "npm:/tailwindcss@3.4.3/plugin.js",
"@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.19",
// Deno standard library
"$std/": "https://deno.land/std@0.224.0/"
},
"exclude": [
"_fresh",
"static/_build"
],
"lint": {
"rules": {
"tags": ["fresh", "recommended"]
},
"exclude": ["_fresh", "static/_build"]
},
"exclude": ["_fresh", "static/_build", "**/_fresh/*"],
"lint": { "rules": { "tags": ["fresh", "recommended"] }, "exclude": ["static/_build"] },
"fmt": {
"useTabs": false,
"lineWidth": 100,
@ -111,6 +65,6 @@
"semiColons": false,
"singleQuote": true,
"proseWrap": "preserve",
"exclude": ["_fresh", "static/_build"]
"exclude": ["static/_build"]
}
}

View File

@ -16,6 +16,9 @@ import * as $api_templates from './routes/api/templates.ts'
import * as $index from './routes/index.tsx'
import * as $login from './routes/login.tsx'
import * as $navbar_demo from './routes/navbar-demo.tsx'
import * as $panel_account from './routes/panel/account.tsx'
import * as $panel_index from './routes/panel/index.tsx'
import * as $panel_mail from './routes/panel/mail.tsx'
import * as $register from './routes/register.tsx'
import * as $AccountDropdown from './islands/AccountDropdown.tsx'
import * as $AskAIButton from './islands/AskAIButton.tsx'
@ -25,7 +28,10 @@ import * as $MobileMenu from './islands/MobileMenu.tsx'
import * as $Navbar from './islands/Navbar.tsx'
import * as $RegisterForm from './islands/RegisterForm.tsx'
import * as $SearchDialog from './islands/SearchDialog.tsx'
import { type Manifest } from '$fresh/server.ts'
import * as $panel_Header from './islands/panel/Header.tsx'
import * as $panel_PanelLayout from './islands/panel/PanelLayout.tsx'
import * as $panel_Sidebar from './islands/panel/Sidebar.tsx'
import type { Manifest } from '$fresh/server.ts'
const manifest = {
routes: {
@ -43,6 +49,9 @@ const manifest = {
'./routes/index.tsx': $index,
'./routes/login.tsx': $login,
'./routes/navbar-demo.tsx': $navbar_demo,
'./routes/panel/account.tsx': $panel_account,
'./routes/panel/index.tsx': $panel_index,
'./routes/panel/mail.tsx': $panel_mail,
'./routes/register.tsx': $register,
},
islands: {
@ -54,6 +63,9 @@ const manifest = {
'./islands/Navbar.tsx': $Navbar,
'./islands/RegisterForm.tsx': $RegisterForm,
'./islands/SearchDialog.tsx': $SearchDialog,
'./islands/panel/Header.tsx': $panel_Header,
'./islands/panel/PanelLayout.tsx': $panel_PanelLayout,
'./islands/panel/Sidebar.tsx': $panel_Sidebar,
},
baseUrl: import.meta.url,
} satisfies Manifest

View File

@ -0,0 +1,98 @@
/**
* Header Island - Fresh + Preact
*
* Top header bar for the user panel
*/
import { Menu } from 'lucide-preact'
import type { ComponentType } from 'preact'
import type { User, UserRole } from '@/lib/userSession.ts'
// Type assertion for lucide icons
const MenuIcon = Menu as unknown as ComponentType<{ class?: string }>
const ROLE_BADGES: Record<UserRole, { label: string; className: string }> = {
guest: {
label: 'Guest',
className: 'bg-slate-100 text-slate-600',
},
user: {
label: 'User',
className: 'bg-blue-100 text-blue-700',
},
operator: {
label: 'Operator',
className: 'bg-emerald-100 text-emerald-700',
},
admin: {
label: 'Admin',
className: 'bg-sky-100 text-sky-700',
},
}
interface HeaderProps {
onMenu: () => void
user: User | null
isLoading?: boolean
}
function resolveAccountInitial(input?: string | null) {
if (!input) {
return '?'
}
const normalized = input.trim()
if (!normalized) {
return '?'
}
return normalized.charAt(0).toUpperCase()
}
export default function Header({ onMenu, user, isLoading = false }: HeaderProps) {
const role: UserRole = user?.role ?? 'guest'
const badge = ROLE_BADGES[role]
const accountLabel = user?.name ?? user?.username ?? user?.email ?? 'Guest user'
const accountInitial = resolveAccountInitial(accountLabel)
const statusBadge = isLoading ? 'Syncing' : badge.label
const badgeClasses = isLoading
? 'bg-slate-100 text-slate-500 opacity-70'
: badge.className
return (
<header class="sticky top-0 z-30 flex items-center justify-between border-b border-slate-200 bg-white/90 px-4 py-3 text-slate-900 shadow-sm backdrop-blur transition-colors md:px-6">
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-sky-300 hover:text-sky-600 md:hidden"
onClick={onMenu}
aria-label="Toggle navigation menu"
>
<MenuIcon class="h-4 w-4" />
Menu
</button>
<div class="flex flex-1 items-center justify-end gap-4 md:justify-between">
<div class="hidden flex-col text-sm text-slate-600 md:flex">
<span class="font-semibold text-slate-900">XControl User Center</span>
<span>Personalized access across every service touchpoint</span>
</div>
<div class="flex items-center gap-3">
<a
href="/"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-sky-300 hover:text-sky-600"
>
</a>
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClasses}`}>{statusBadge}</span>
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-sky-500 to-blue-600 text-sm font-semibold text-white shadow-sm transition-colors">
{isLoading ? <span class="animate-pulse"></span> : accountInitial}
</div>
<div class="hidden flex-col text-right text-xs text-slate-600 sm:flex">
<span class="text-sm font-semibold text-slate-900">{accountLabel}</span>
<span>{user?.email ?? (isLoading ? 'Checking session…' : 'Not signed in')}</span>
</div>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,125 @@
/**
* PanelLayout Island - Fresh + Preact
*
* Layout wrapper for panel pages with sidebar and header
*/
import { useSignal, useComputed } from '@preact/signals'
import { useEffect } from 'preact/hooks'
import Sidebar from '@/islands/panel/Sidebar.tsx'
import Header from '@/islands/panel/Header.tsx'
import type { ComponentChildren } from 'preact'
import type { User } from '@/lib/userSession.ts'
import { logoutUser, fetchSessionUser } from '@/lib/userSession.ts'
interface PanelLayoutProps {
user: User | null
currentPath: string
children: ComponentChildren
}
export default function PanelLayout({ user: initialUser, currentPath, children }: PanelLayoutProps) {
const open = useSignal(false)
const user = useSignal<User | null>(initialUser)
const isLoading = useSignal(false)
const requiresSetup = useComputed(() => Boolean(user.value && (!user.value.mfaEnabled || user.value.mfaPending)))
// Refresh user session periodically
useEffect(() => {
const interval = setInterval(async () => {
try {
const refreshedUser = await fetchSessionUser()
user.value = refreshedUser
} catch (error) {
console.warn('Failed to refresh user session', error)
}
}, 60000) // Refresh every minute
return () => clearInterval(interval)
}, [])
const handleLogout = async () => {
isLoading.value = true
try {
await logoutUser()
globalThis.location.href = '/login'
} catch (error) {
console.error('Logout failed', error)
} finally {
isLoading.value = false
}
}
const mfaMessages = {
lockedMessage: '您的账户需要设置双因素认证MFA后才能访问其他功能。请前往账户设置完成配置。',
setupAction: '前往设置',
docsAction: '查看文档',
logoutAction: '退出登录',
docsUrl: '/docs/security/mfa',
}
return (
<div class="relative flex min-h-screen bg-gradient-to-br from-slate-50 via-slate-100 to-slate-50 text-slate-900">
<Sidebar
className={`fixed inset-y-0 left-0 z-40 transform transition-transform duration-200 ease-in-out md:static md:translate-x-0 ${
open.value ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
}`}
onNavigate={() => (open.value = false)}
user={user.value}
currentPath={currentPath}
/>
{open.value && (
<div
class="fixed inset-0 z-30 bg-slate-900/20 backdrop-blur-sm md:hidden"
onClick={() => (open.value = false)}
/>
)}
<div class="flex min-h-screen flex-1 flex-col">
<Header
onMenu={() => (open.value = !open.value)}
user={user.value}
isLoading={isLoading.value}
/>
<main class="flex flex-1 flex-col space-y-6 bg-transparent px-3 py-5 text-slate-900 transition-colors sm:px-4 md:px-6 lg:px-8">
{requiresSetup.value && currentPath !== '/panel/account' ? (
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 transition-colors">
<p class="text-sm">{mfaMessages.lockedMessage}</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
onClick={() => (globalThis.location.href = '/panel/account?setupMfa=1')}
class="inline-flex items-center justify-center rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-sky-700"
>
{mfaMessages.setupAction}
</button>
<a
href={mfaMessages.docsUrl}
target="_blank"
rel="noreferrer"
class="inline-flex items-center justify-center rounded-md border border-sky-300 px-3 py-1.5 text-sm font-medium text-sky-700 transition-colors hover:border-sky-500 hover:bg-sky-50"
>
{mfaMessages.docsAction}
</a>
<button
type="button"
onClick={handleLogout}
class="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-1.5 text-sm font-medium text-amber-800 transition-colors hover:bg-amber-100"
>
{mfaMessages.logoutAction}
</button>
{isLoading.value && (
<span class="inline-flex items-center rounded-md border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
</span>
)}
</div>
</div>
) : null}
<div class="flex w-full flex-1 flex-col gap-5 text-slate-900 transition-colors md:gap-6">{children}</div>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,249 @@
/**
* Sidebar Island - Fresh + Preact
*
* Navigation sidebar for the user panel
*/
import { useSignal, useComputed } from '@preact/signals'
import { Home, User, Settings, Mail, Key, Palette, Shield, Users } from 'lucide-preact'
import type { ComponentType } from 'preact'
import type { User as SessionUser } from '@/lib/userSession.ts'
interface SidebarProps {
className?: string
onNavigate?: () => void
user: SessionUser | null
currentPath: string
}
interface NavItem {
href: string
label: string
description: string
// deno-lint-ignore no-explicit-any
Icon: any
disabled: boolean
}
interface NavSection {
title: string
items: NavItem[]
}
function isActive(pathname: string, href: string) {
if (href === '/panel') {
return pathname === '/panel'
}
return pathname.startsWith(href)
}
export default function Sidebar({ className = '', onNavigate, user, currentPath }: SidebarProps) {
const requiresSetup = useComputed(() => Boolean(user && (!user.mfaEnabled || user.mfaPending)))
// Define static navigation structure
const navSections: NavSection[] = [
{
title: '个人设置',
items: [
{
href: '/panel',
label: 'Dashboard',
description: '总览与快捷入口',
Icon: Home,
disabled: false,
},
{
href: '/panel/account',
label: 'Account',
description: '账户与安全设置',
Icon: User,
disabled: false,
},
{
href: '/panel/appearance',
label: 'Appearance',
description: '主题与外观',
Icon: Palette,
disabled: requiresSetup.value,
},
],
},
{
title: '功能服务',
items: [
{
href: '/panel/api',
label: 'API Keys',
description: 'API 密钥管理',
Icon: Key,
disabled: requiresSetup.value,
},
{
href: '/panel/mail',
label: 'Mail Service',
description: '邮件服务配置',
Icon: Mail,
disabled: requiresSetup.value,
},
{
href: '/panel/agent',
label: 'Agent',
description: 'Agent 配置',
Icon: Shield,
disabled: requiresSetup.value,
},
],
},
{
title: '管理',
items: [
{
href: '/panel/management',
label: 'Management',
description: '资源管理',
Icon: Settings,
disabled: requiresSetup.value || !user?.isAdmin,
},
{
href: '/panel/subscription',
label: 'Subscription',
description: '订阅管理',
Icon: Users,
disabled: requiresSetup.value,
},
],
},
]
const mfaMessages = {
pendingHint: '待设置 MFA',
lockedMessage: '请先完成双因素认证设置后访问其他功能',
setupAction: '立即设置',
docsAction: '查看文档',
docsUrl: '/docs/security/mfa',
}
return (
<aside
class={`flex h-full w-64 flex-col gap-6 border-r border-slate-200 bg-white/90 p-6 text-slate-900 shadow-md backdrop-blur transition-colors ${className}`}
>
<div class="space-y-1 text-slate-900">
<p class="text-xs font-semibold uppercase tracking-wide text-sky-600">XControl</p>
<h2 class="text-lg font-bold text-slate-900">User Center</h2>
<p class="text-sm text-slate-600"></p>
</div>
{requiresSetup.value && (
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p class="font-semibold">{mfaMessages.pendingHint}</p>
<p class="mt-1">{mfaMessages.lockedMessage}</p>
<div class="mt-2 flex flex-wrap gap-2">
<a
href="/panel/account?setupMfa=1"
onClick={onNavigate}
class="inline-flex items-center justify-center rounded-md bg-sky-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-sky-700"
>
{mfaMessages.setupAction}
</a>
<a
href={mfaMessages.docsUrl}
target="_blank"
rel="noreferrer"
class="inline-flex items-center justify-center rounded-md border border-sky-300 px-3 py-1.5 text-xs font-medium text-sky-700 transition-colors hover:border-sky-500 hover:bg-sky-50"
>
{mfaMessages.docsAction}
</a>
</div>
</div>
)}
<nav class="flex flex-1 flex-col gap-6 overflow-y-auto">
{navSections.map((section) => {
const sectionDisabled = section.items.every((item) => item.disabled)
return (
<div key={section.title} class="space-y-3">
<p
class={`text-xs font-semibold uppercase tracking-wide ${
sectionDisabled
? 'text-slate-400 opacity-60'
: 'text-slate-600'
}`}
>
{section.title}
</p>
<div class={`space-y-2 ${sectionDisabled ? 'opacity-60' : ''}`}>
{section.items.map((item) => {
const active = isActive(currentPath, item.href)
const { Icon } = item
const baseClasses = [
'group flex items-center gap-3 rounded-2xl border px-3 py-3 text-sm transition-colors',
]
if (item.disabled) {
baseClasses.push(
'cursor-not-allowed border-dashed border-slate-200 text-slate-400 opacity-60',
)
} else {
baseClasses.push(
'border-transparent text-slate-600 hover:border-sky-200 hover:bg-slate-50 hover:text-sky-600',
)
}
if (active) {
baseClasses.push(
'border-sky-500 bg-sky-50 text-sky-700 shadow-sm',
)
}
const iconClasses = ['flex h-8 w-8 items-center justify-center rounded-xl transition-colors']
if (active) {
iconClasses.push('bg-sky-600 text-white')
} else if (item.disabled) {
iconClasses.push('bg-slate-100 text-slate-400 opacity-60')
} else {
iconClasses.push(
'bg-slate-100 text-slate-600 group-hover:bg-sky-100 group-hover:text-sky-600',
)
}
const descriptionClasses = [
'text-xs transition-colors',
item.disabled
? 'text-slate-400 opacity-60'
: 'text-slate-500 group-hover:text-sky-600',
]
const content = (
<div class={baseClasses.join(' ')}>
<span class={iconClasses.join(' ')}>
<Icon class="h-4 w-4" />
</span>
<span class="flex flex-col">
<span class="font-semibold">{item.label}</span>
<span class={descriptionClasses.join(' ')}>{item.description}</span>
</span>
</div>
)
if (item.disabled) {
return (
<div key={item.href} aria-disabled={true} class="select-none">
{content}
</div>
)
}
return (
<a key={item.href} href={item.href} onClick={onNavigate}>
{content}
</a>
)
})}
</div>
</div>
)
})}
</nav>
</aside>
)
}

View File

@ -0,0 +1,212 @@
/**
* User Session Utilities - Fresh + Deno
*
* Server-side and client-side utilities for user session management
*/
export type UserRole = 'guest' | 'user' | 'operator' | 'admin'
export type TenantMembership = {
id: string
name?: string
role?: UserRole
}
export type User = {
id: string
uuid: string
email: string
name?: string
username: string
mfaEnabled: boolean
mfaPending: boolean
role: UserRole
groups: string[]
permissions: string[]
isGuest: boolean
isUser: boolean
isOperator: boolean
isAdmin: boolean
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
}
const KNOWN_ROLE_MAP: Record<string, UserRole> = {
admin: 'admin',
administrator: 'admin',
operator: 'operator',
ops: 'operator',
user: 'user',
member: 'user',
}
function normalizeRole(input?: string | null): UserRole {
if (!input || typeof input !== 'string') {
return 'guest'
}
const normalized = input.trim().toLowerCase()
if (!normalized) {
return 'guest'
}
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
}
export async function fetchSessionUser(): Promise<User | null> {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
return null
}
const payload = (await response.json()) as {
user?: {
id?: string
uuid?: string
email: string
name?: string
username?: string
mfaEnabled?: boolean
mfaPending?: boolean
role?: string
groups?: string[]
permissions?: string[]
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
totpSecretIssuedAt?: string
totpConfirmedAt?: string
totpLockedUntil?: string
}
} | null
}
const sessionUser = payload?.user
if (!sessionUser) {
return null
}
const { id, uuid, email, name, username, mfaEnabled, mfa, mfaPending, role, groups, permissions } = sessionUser
const identifier =
typeof uuid === 'string' && uuid.trim().length > 0
? uuid.trim()
: typeof id === 'string'
? id.trim()
: ''
if (!identifier) {
return null
}
const normalizedName = typeof name === 'string' && name.trim().length > 0 ? name.trim() : undefined
const normalizedUsername =
typeof username === 'string' && username.trim().length > 0 ? username.trim() : normalizedName
const normalizedMfa = mfa
? {
...mfa,
totpEnabled: Boolean(mfa.totpEnabled ?? mfaEnabled),
totpPending: Boolean(mfa.totpPending ?? mfaPending) && !Boolean(mfa.totpEnabled ?? mfaEnabled),
}
: {
totpEnabled: Boolean(mfaEnabled),
totpPending: Boolean(mfaPending) && !Boolean(mfaEnabled),
}
const normalizedRole = normalizeRole(role)
const normalizedGroups = Array.isArray(groups)
? groups
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedPermissions = Array.isArray(permissions)
? permissions
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedTenantId =
typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0
? sessionUser.tenantId.trim()
: undefined
const normalizedTenants = Array.isArray(sessionUser.tenants)
? sessionUser.tenants
.map((tenant) => {
if (!tenant || typeof tenant !== 'object') {
return null
}
const identifier =
typeof tenant.id === 'string' && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined
if (!identifier) {
return null
}
const normalizedTenant: TenantMembership = {
id: identifier,
}
if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) {
normalizedTenant.name = tenant.name.trim()
}
if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) {
normalizedTenant.role = normalizeRole(tenant.role)
}
return normalizedTenant
})
.filter((tenant): tenant is TenantMembership => Boolean(tenant))
: undefined
return {
id: identifier,
uuid: identifier,
email,
name: normalizedName,
username: normalizedUsername ?? email,
mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfa: normalizedMfa,
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
tenantId: normalizedTenantId,
tenants: normalizedTenants,
}
} catch (error) {
console.warn('Failed to resolve user session', error)
return null
}
}
export async function logoutUser(): Promise<void> {
try {
await fetch('/api/auth/session', {
method: 'DELETE',
credentials: 'include',
})
} catch (error) {
console.warn('Failed to clear user session', error)
}
}

View File

@ -0,0 +1,195 @@
/**
* Panel Account Page - Fresh + Deno
*
* User account settings page
*/
import { Head } from '$fresh/runtime.ts'
import { Handlers, PageProps } from '$fresh/server.ts'
import { FreshState } from '@/middleware.ts'
import PanelLayout from '@/islands/panel/PanelLayout.tsx'
import type { User } from '@/lib/userSession.ts'
// Helper to adapt AccountUser from middleware to User type with computed properties
function adaptUser(accountUser: any): User | null {
if (!accountUser) return null
const normalizedRole = (accountUser.role?.toLowerCase() || 'guest') as User['role']
return {
...accountUser,
role: normalizedRole,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
}
}
interface AccountPageData {
user: User | null
pathname: string
setupMfa: boolean
}
export const handler: Handlers<AccountPageData, FreshState> = {
async GET(req, ctx) {
const accountUser = ctx.state.user || null
// Redirect to login if not authenticated
if (!accountUser) {
return new Response(null, {
status: 302,
headers: { Location: '/login' },
})
}
const user = adaptUser(accountUser)
const url = new URL(req.url)
const setupMfa = url.searchParams.get('setupMfa') === '1'
return ctx.render({
user,
pathname: url.pathname,
setupMfa,
})
},
}
export default function AccountPage({ data }: PageProps<AccountPageData>) {
const { user, pathname, setupMfa } = data
return (
<>
<Head>
<title>Account Settings - CloudNative Suite</title>
<meta name="description" content="Manage your account settings" />
<link rel="stylesheet" href="/styles/globals.css" />
</Head>
<PanelLayout user={user} currentPath={pathname}>
<div class="space-y-6">
<div class="space-y-2">
<h1 class="text-3xl font-bold text-slate-900">Account Settings</h1>
<p class="text-slate-600">Manage your account information and security settings</p>
</div>
{setupMfa && (
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-amber-800">
<p class="font-semibold"> MFA Setup Required</p>
<p class="mt-1 text-sm">You need to set up two-factor authentication to access all features.</p>
</div>
)}
{/* Account Information */}
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold text-slate-900">Account Information</h2>
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-slate-600">User ID</label>
<p class="mt-1 font-mono text-sm text-slate-900">{user?.uuid}</p>
</div>
<div>
<label class="text-sm font-medium text-slate-600">Role</label>
<p class="mt-1 text-sm text-slate-900">{user?.role}</p>
</div>
<div>
<label class="text-sm font-medium text-slate-600">Email</label>
<p class="mt-1 text-sm text-slate-900">{user?.email}</p>
</div>
<div>
<label class="text-sm font-medium text-slate-600">Username</label>
<p class="mt-1 text-sm text-slate-900">{user?.username}</p>
</div>
{user?.name && (
<div>
<label class="text-sm font-medium text-slate-600">Display Name</label>
<p class="mt-1 text-sm text-slate-900">{user.name}</p>
</div>
)}
</div>
</div>
</div>
{/* Security Settings */}
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold text-slate-900">Security Settings</h2>
<div class="space-y-4">
<div class="flex items-center justify-between rounded-xl border border-slate-200 p-4">
<div>
<p class="font-semibold text-slate-900">Two-Factor Authentication (MFA)</p>
<p class="text-sm text-slate-600">
{user?.mfaEnabled
? 'MFA is enabled and protecting your account'
: user?.mfaPending
? 'MFA setup is pending completion'
: 'Enable MFA for enhanced security'}
</p>
</div>
<div class="flex items-center gap-2">
{user?.mfaEnabled ? (
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">
Enabled
</span>
) : user?.mfaPending ? (
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
Pending
</span>
) : (
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
Disabled
</span>
)}
</div>
</div>
{!user?.mfaEnabled && (
<div class="rounded-xl border border-dashed border-sky-200 bg-sky-50 p-4">
<p class="text-sm text-sky-800">
<strong>Note:</strong> MFA setup functionality will be available in the next update. This page
currently displays your current security status.
</p>
</div>
)}
</div>
</div>
{/* Permissions */}
{user?.permissions && user.permissions.length > 0 && (
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold text-slate-900">Permissions</h2>
<div class="flex flex-wrap gap-2">
{user.permissions.map((permission) => (
<span
key={permission}
class="rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700"
>
{permission}
</span>
))}
</div>
</div>
)}
{/* Groups */}
{user?.groups && user.groups.length > 0 && (
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold text-slate-900">Groups</h2>
<div class="flex flex-wrap gap-2">
{user.groups.map((group) => (
<span
key={group}
class="rounded-full bg-purple-100 px-3 py-1 text-xs font-semibold text-purple-700"
>
{group}
</span>
))}
</div>
</div>
)}
</div>
</PanelLayout>
</>
)
}

View File

@ -0,0 +1,160 @@
/**
* Panel Index Page - Fresh + Deno
*
* Main dashboard page for the user panel
*/
import { Head } from '$fresh/runtime.ts'
import { Handlers, PageProps } from '$fresh/server.ts'
import { FreshState } from '@/middleware.ts'
import PanelLayout from '@/islands/panel/PanelLayout.tsx'
import type { User } from '@/lib/userSession.ts'
// Helper to adapt AccountUser from middleware to User type with computed properties
function adaptUser(accountUser: any): User | null {
if (!accountUser) return null
const normalizedRole = (accountUser.role?.toLowerCase() || 'guest') as User['role']
return {
...accountUser,
role: normalizedRole,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
}
}
interface PanelPageData {
user: User | null
pathname: string
}
export const handler: Handlers<PanelPageData, FreshState> = {
async GET(req, ctx) {
const accountUser = ctx.state.user || null
// Redirect to login if not authenticated
if (!accountUser) {
return new Response(null, {
status: 302,
headers: { Location: '/login' },
})
}
const user = adaptUser(accountUser)
return ctx.render({
user,
pathname: new URL(req.url).pathname,
})
},
}
export default function PanelPage({ data }: PageProps<PanelPageData>) {
const { user, pathname } = data
return (
<>
<Head>
<title>Dashboard - CloudNative Suite</title>
<meta name="description" content="User Control Panel" />
<link rel="stylesheet" href="/styles/globals.css" />
</Head>
<PanelLayout user={user} currentPath={pathname}>
<div class="space-y-6">
<div class="space-y-2">
<h1 class="text-3xl font-bold text-slate-900">Dashboard</h1>
<p class="text-slate-600">Welcome to your control panel, {user?.name || user?.username || user?.email}</p>
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Quick Stats */}
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-sky-100 text-sky-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-slate-600">Account</p>
<p class="text-lg font-semibold text-slate-900">{user?.role}</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-slate-600">MFA Status</p>
<p class="text-lg font-semibold text-slate-900">
{user?.mfaEnabled ? 'Enabled' : user?.mfaPending ? 'Pending' : 'Disabled'}
</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100 text-purple-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-slate-600">Permissions</p>
<p class="text-lg font-semibold text-slate-900">{user?.permissions?.length || 0}</p>
</div>
</div>
</div>
</div>
{/* Quick Links */}
<div class="space-y-4">
<h2 class="text-xl font-semibold text-slate-900">Quick Links</h2>
<div class="grid gap-4 sm:grid-cols-2">
<a
href="/panel/account"
class="group flex items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 transition-all hover:border-sky-300 hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 transition-colors group-hover:bg-sky-100 group-hover:text-sky-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p class="font-semibold text-slate-900">Account Settings</p>
<p class="text-sm text-slate-600">Manage your account and security</p>
</div>
</a>
<a
href="/panel/api"
class="group flex items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 transition-all hover:border-sky-300 hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 transition-colors group-hover:bg-sky-100 group-hover:text-sky-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<p class="font-semibold text-slate-900">API Keys</p>
<p class="text-sm text-slate-600">Manage your API credentials</p>
</div>
</a>
</div>
</div>
</div>
</PanelLayout>
</>
)
}

View File

@ -0,0 +1,199 @@
/**
* Panel Mail Page - Fresh + Deno
*
* Mail service management page (simplified version)
*/
import { Head } from '$fresh/runtime.ts'
import { Handlers, PageProps } from '$fresh/server.ts'
import { FreshState } from '@/middleware.ts'
import PanelLayout from '@/islands/panel/PanelLayout.tsx'
import type { User } from '@/lib/userSession.ts'
// Helper to adapt AccountUser from middleware to User type with computed properties
function adaptUser(accountUser: any): User | null {
if (!accountUser) return null
const normalizedRole = (accountUser.role?.toLowerCase() || 'guest') as User['role']
return {
...accountUser,
role: normalizedRole,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
}
}
interface MailPageData {
user: User | null
pathname: string
}
export const handler: Handlers<MailPageData, FreshState> = {
async GET(req, ctx) {
const accountUser = ctx.state.user || null
// Redirect to login if not authenticated
if (!accountUser) {
return new Response(null, {
status: 302,
headers: { Location: '/login' },
})
}
const user = adaptUser(accountUser)
return ctx.render({
user,
pathname: new URL(req.url).pathname,
})
},
}
export default function MailPage({ data }: PageProps<MailPageData>) {
const { user, pathname } = data
return (
<>
<Head>
<title>Mail Service - CloudNative Suite</title>
<meta name="description" content="Multi-tenant mail service management" />
<link rel="stylesheet" href="/styles/globals.css" />
</Head>
<PanelLayout user={user} currentPath={pathname}>
<div class="space-y-6">
{/* Header */}
<div class="space-y-2">
<h1 class="text-3xl font-bold text-slate-900">Mail Service</h1>
<p class="text-slate-600">AI </p>
</div>
{/* Feature Overview Card */}
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold text-slate-900"></h2>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex items-start gap-3 rounded-xl border border-slate-200 p-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-sky-100">
<svg class="h-5 w-5 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-slate-900"></h3>
<p class="mt-1 text-sm text-slate-600"></p>
</div>
</div>
<div class="flex items-start gap-3 rounded-xl border border-slate-200 p-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-purple-100">
<svg class="h-5 w-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-slate-900">AI </h3>
<p class="mt-1 text-sm text-slate-600"></p>
</div>
</div>
<div class="flex items-start gap-3 rounded-xl border border-slate-200 p-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-100">
<svg class="h-5 w-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-slate-900"></h3>
<p class="mt-1 text-sm text-slate-600">AI </p>
</div>
</div>
<div class="flex items-start gap-3 rounded-xl border border-slate-200 p-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-amber-100">
<svg class="h-5 w-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-slate-900"></h3>
<p class="mt-1 text-sm text-slate-600"></p>
</div>
</div>
</div>
</div>
{/* Status Notice */}
<div class="rounded-2xl border border-dashed border-sky-200 bg-sky-50 p-6">
<div class="flex items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-sky-100">
<svg class="h-5 w-5 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex-1">
<h3 class="font-semibold text-sky-900"></h3>
<p class="mt-1 text-sm text-sky-700">
</p>
<ul class="mt-2 space-y-1 text-sm text-sky-700">
<li> </li>
<li> 线</li>
<li> AI </li>
<li> </li>
<li> </li>
</ul>
<p class="mt-3 text-sm text-sky-700">
{' '}
<a href="/docs" class="font-semibold underline hover:text-sky-900">
</a>
</p>
</div>
</div>
</div>
{/* Quick Actions */}
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold text-slate-900"></h2>
<div class="grid gap-3 sm:grid-cols-2">
<button
class="flex items-center gap-3 rounded-xl border border-slate-200 p-4 text-left transition-colors hover:border-sky-300 hover:bg-sky-50"
disabled
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div class="flex-1">
<p class="font-semibold text-slate-400"></p>
<p class="text-sm text-slate-400"></p>
</div>
</button>
<button
class="flex items-center gap-3 rounded-xl border border-slate-200 p-4 text-left transition-colors hover:border-sky-300 hover:bg-sky-50"
disabled
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div class="flex-1">
<p class="font-semibold text-slate-400"> AI </p>
<p class="text-sm text-slate-400"></p>
</div>
</button>
</div>
</div>
</div>
</PanelLayout>
</>
)
}