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:
parent
b542a0ae17
commit
dd6bb68d25
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
98
dashboard-fresh/islands/panel/Header.tsx
Normal file
98
dashboard-fresh/islands/panel/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
dashboard-fresh/islands/panel/PanelLayout.tsx
Normal file
125
dashboard-fresh/islands/panel/PanelLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
249
dashboard-fresh/islands/panel/Sidebar.tsx
Normal file
249
dashboard-fresh/islands/panel/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
212
dashboard-fresh/lib/userSession.ts
Normal file
212
dashboard-fresh/lib/userSession.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
195
dashboard-fresh/routes/panel/account.tsx
Normal file
195
dashboard-fresh/routes/panel/account.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
160
dashboard-fresh/routes/panel/index.tsx
Normal file
160
dashboard-fresh/routes/panel/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
199
dashboard-fresh/routes/panel/mail.tsx
Normal file
199
dashboard-fresh/routes/panel/mail.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user