Merge pull request #59 from cloud-neutral-toolkit/release/v0.2

Release/v0.2
This commit is contained in:
Haitao Pan 2026-03-17 00:58:06 +00:00 committed by GitHub
commit 6ed69a0f73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 566 additions and 345 deletions

View File

@ -78,12 +78,9 @@ export default function LoginContent({
const googleAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/google`;
useEffect(() => {
const publicToken = searchParams.get("public_token");
const userId = searchParams.get("userId");
const email = searchParams.get("email");
const role = searchParams.get("role");
const exchangeCode = searchParams.get("exchange_code");
if (!publicToken || !userId || !email) {
if (!exchangeCode) {
return;
}
@ -101,10 +98,7 @@ export default function LoginContent({
"Content-Type": "application/json",
},
body: JSON.stringify({
publicToken,
userId,
email,
role: role || "user",
exchangeCode,
}),
});

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "../components/theme";
import { LanguageProvider } from "../i18n/LanguageProvider";
@ -20,18 +20,42 @@ export function AppProviders({
const { isOpen, isMinimized, close, toggleOpen } = useMoltbotStore();
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
const pathname = usePathname();
const [isMobileViewport, setIsMobileViewport] = useState(false);
const isOpenClawWorkspace =
pathname.startsWith("/xworkmate") ||
pathname.startsWith("/services/openclaw");
// Always reserve space if open and not minimized, since we only have "Float/Sidebar" mode now
// and user wants it to NEVER cover the homepage.
const reserveSpace = !isOpenClawWorkspace && isOpen && !isMinimized;
const reserveSpace =
!isOpenClawWorkspace && isOpen && !isMinimized && !isMobileViewport;
useEffect(() => {
applyDefaults(assistantDefaults);
}, [applyDefaults, assistantDefaults]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 1023px)");
const syncViewport = () => {
setIsMobileViewport(mediaQuery.matches);
};
syncViewport();
mediaQuery.addEventListener("change", syncViewport);
return () => {
mediaQuery.removeEventListener("change", syncViewport);
};
}, []);
useEffect(() => {
if (isMobileViewport && !isOpenClawWorkspace) {
close();
}
}, [close, isMobileViewport, isOpenClawWorkspace]);
return (
<ThemeProvider>
<LanguageProvider>
@ -40,7 +64,7 @@ export function AppProviders({
style={
{
"--assistant-reserve-offset": reserveSpace ? "400px" : "0px",
} as React.CSSProperties
} as CSSProperties
}
className={cn(
"flex-1 flex flex-col relative w-full overflow-hidden transition-[padding] duration-300 ease-in-out",

View File

@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.settings.write']
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -25,12 +23,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const headers = new Headers({

View File

@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const READ_PERMISSIONS = ['admin.settings.read']
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -25,12 +23,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: READ_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
try {

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_PERMISSIONS = ['admin.users.pause.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_PERMISSIONS = ['admin.users.renew_uuid.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_PERMISSIONS = ['admin.users.resume.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.users.role.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params
@ -78,8 +84,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const DELETE_PERMISSIONS = ['admin.users.delete.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: DELETE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.users.role.write']
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@ -48,10 +50,6 @@ function normalizeGroups(value: unknown): string[] | null {
return Array.from(new Set(result))
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -60,12 +58,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const body = (await request.json().catch(() => null)) as CreateUserBody | null

View File

@ -7,9 +7,9 @@ const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
export async function POST(request: NextRequest) {
try {
const payload = await request.json()
const { publicToken, userId, email, role } = payload
const { exchangeCode } = payload
if (!publicToken || !userId || !email) {
if (!exchangeCode || typeof exchangeCode !== 'string') {
return NextResponse.json({ success: false, error: 'invalid_request' }, { status: 400 })
}
@ -19,10 +19,7 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json',
},
body: JSON.stringify({
public_token: publicToken,
user_id: userId,
email,
roles: role,
exchange_code: exchangeCode,
}),
cache: 'no-store',
})
@ -33,12 +30,20 @@ export async function POST(request: NextRequest) {
}
const data = await response.json()
const { access_token, expires_in } = data
const sessionToken = typeof data.token === 'string' && data.token.trim().length > 0
? data.token.trim()
: typeof data.access_token === 'string' && data.access_token.trim().length > 0
? data.access_token.trim()
: ''
if (!sessionToken) {
return NextResponse.json({ success: false, error: 'invalid_response' }, { status: 502 })
}
const result = NextResponse.json({ success: true })
// If backend returns expires_in (seconds), use it; otherwise derive from expiresAt if it exists
const maxAge = typeof expires_in === 'number' ? expires_in : deriveMaxAgeFromExpires(data.expiresAt)
applySessionCookie(result, access_token, maxAge)
const maxAge =
typeof data.expires_in === 'number' ? data.expires_in : deriveMaxAgeFromExpires(data.expiresAt)
applySessionCookie(result, sessionToken, maxAge)
return result
} catch (error) {

View File

@ -3,12 +3,14 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.settings.write']
const ROOT_BACKUP_COOKIE = 'xc_session_root'
const SANDBOX_EMAIL = 'sandbox@svc.plus'
@ -17,10 +19,6 @@ type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
function secureCookies(): boolean {
if (process.env.NODE_ENV === 'production') {
return true
@ -37,12 +35,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
try {
@ -96,4 +95,3 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -1,13 +1,13 @@
@import 'react-grid-layout/css/styles.css';
@import 'react-resizable/css/styles.css';
@import "react-grid-layout/css/styles.css";
@import "react-resizable/css/styles.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-geist-sans: 'Geist', sans-serif;
--font-geist-mono: 'Geist Mono', monospace;
--font-geist-sans: "Geist", sans-serif;
--font-geist-mono: "Geist Mono", monospace;
--app-shell-nav-offset: 5.5rem;
/* Light theme defaults */
@ -59,7 +59,8 @@
--gradient-primary-from: #3366ff;
--gradient-primary-to: #254edb;
--shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.06), 0 1px 3px rgba(17, 24, 39, 0.04);
--shadow-sm:
0 1px 2px rgba(17, 24, 39, 0.06), 0 1px 3px rgba(17, 24, 39, 0.04);
--shadow-md: 0 10px 24px rgba(17, 24, 39, 0.08);
--radius-lg: 0.875rem;
@ -91,7 +92,9 @@ body {
color: var(--color-text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: background-color 150ms ease, color 150ms ease;
transition:
background-color 150ms ease,
color 150ms ease;
}
button,
@ -145,3 +148,34 @@ button {
background: rgba(51, 102, 255, 0.18);
}
}
@media (max-width: 1023px) {
.mobile-home-shell {
--color-background: #fbfaf7;
--color-background-muted: #f4f1eb;
--color-surface: #fffdfa;
--color-surface-elevated: rgba(255, 253, 250, 0.96);
--color-surface-translucent: rgba(255, 253, 250, 0.92);
--color-surface-muted: #f1ece5;
--color-surface-hover: #f6f1ea;
--color-surface-border: rgba(15, 23, 42, 0.1);
--color-surface-border-strong: rgba(15, 23, 42, 0.16);
--color-text: #171717;
--color-heading: #0f172a;
--color-text-muted: #525866;
--color-text-subtle: #747b88;
--color-primary: #111827;
--color-primary-hover: #1f2937;
--color-primary-muted: #f1ece5;
--color-primary-border: rgba(15, 23, 42, 0.12);
--color-accent: #2563eb;
--color-accent-muted: #e5edff;
--color-accent-foreground: #1d4ed8;
--gradient-app-from: #fffdf8;
--gradient-app-via: #f8f4ee;
--gradient-app-to: #fbfaf7;
--shadow-sm:
0 1px 2px rgba(15, 23, 42, 0.05), 0 6px 18px rgba(15, 23, 42, 0.04);
--shadow-md: 0 18px 45px rgba(15, 23, 42, 0.08);
}
}

View File

@ -67,7 +67,7 @@ export default function HomePage() {
const { mode, isOpen } = useMoltbotStore();
return (
<div className="min-h-screen bg-background text-text transition-colors duration-150 flex flex-col">
<div className="mobile-home-shell min-h-screen bg-background text-text transition-colors duration-150 flex flex-col overflow-x-hidden">
<UnifiedNavigation />
<div
@ -77,12 +77,12 @@ export default function HomePage() {
)}
>
<div className="flex-1 overflow-y-auto relative">
<div className="relative mx-auto max-w-6xl px-6 pb-20">
<div className="relative mx-auto max-w-6xl px-4 pb-16 sm:px-6 sm:pb-20">
<div
className="absolute inset-0 bg-gradient-app-from opacity-20 pointer-events-none"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(37,78,219,0.08),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(15,23,42,0.05),transparent_32%),linear-gradient(180deg,rgba(255,255,255,0.82),transparent_58%)]"
aria-hidden
/>
<main className="relative space-y-12 pt-10">
<main className="relative space-y-8 pt-6 sm:space-y-12 sm:pt-10">
<HeroSection />
<NextStepsSection />
<StatsSection />
@ -104,43 +104,45 @@ export function HeroSection() {
const t = translations[language].marketing.home;
return (
<section className="grid gap-12 lg:grid-cols-[0.9fr_1.1fr]">
<div className="flex flex-col justify-center space-y-8">
<div className="space-y-4">
<section className="grid gap-8 lg:grid-cols-[0.9fr_1.1fr] lg:gap-12">
<div className="flex flex-col justify-center space-y-6 sm:space-y-8">
<div className="space-y-3 sm:space-y-4">
{t.hero.eyebrow && (
<p className="font-semibold uppercase tracking-wider text-text-subtle">
<p className="font-semibold uppercase tracking-[0.28em] text-text-subtle">
{t.hero.eyebrow}
</p>
)}
<h1 className="text-xl font-bold tracking-tight text-heading sm:text-3xl">
<h1 className="max-w-[12ch] text-[2.22rem] font-semibold leading-[0.92] tracking-[-0.075em] text-heading sm:max-w-none sm:text-3xl lg:text-[3.35rem]">
{t.hero.title}
</h1>
<p className="text-base text-text-muted">{t.hero.subtitle}</p>
<p className="max-w-xl text-[1.02rem] leading-7 text-text-muted">
{t.hero.subtitle}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
{user ? (
<div className="flex items-center gap-2 rounded-full border border-success/30 bg-success/10 px-4 py-1.5 text-sm font-medium text-success">
<div className="flex items-center justify-center gap-2 rounded-full border border-success/30 bg-success/10 px-4 py-2 text-sm font-medium text-success sm:justify-start sm:py-1.5">
<div className="h-2 w-2 rounded-full bg-success animate-pulse" />
{t.signedIn.replace("{{username}}", user.username)}
</div>
) : (
<button className="flex items-center gap-2 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-primary-hover">
<button className="flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3 text-sm font-semibold text-white transition hover:bg-primary-hover sm:py-2.5">
<PlusCircle className="h-4 w-4" />
{t.heroButtons.create}
</button>
)}
<button className="flex items-center gap-2 rounded-full border border-surface-border bg-surface px-6 py-2.5 text-sm font-semibold text-text transition hover:bg-surface-hover">
<button className="flex items-center justify-center gap-2 rounded-full border border-surface-border bg-surface/90 px-6 py-3 text-sm font-semibold text-text transition hover:bg-surface-hover sm:py-2.5">
<Play className="h-4 w-4" />
{t.heroButtons.playground}
</button>
<button className="flex items-center gap-2 rounded-full border border-surface-border bg-surface px-6 py-2.5 text-sm font-semibold text-text transition hover:bg-surface-hover">
<button className="flex items-center justify-center gap-2 rounded-full border border-surface-border bg-surface/90 px-6 py-3 text-sm font-semibold text-text transition hover:bg-surface-hover sm:py-2.5">
<BookOpen className="h-4 w-4" />
{t.heroButtons.tutorials}
</button>
</div>
<div className="flex flex-col gap-3 text-sm">
<p className="text-text-muted">{t.trustedBy}</p>
<div className="flex gap-2 flex-wrap">
<div className="flex flex-wrap gap-2">
<LogoPill label="Next.js" />
<LogoPill label="Go" />
<LogoPill label="Vercel" />
@ -149,8 +151,8 @@ export function HeroSection() {
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 relative">
<div className="flex flex-col gap-3 sm:gap-4">
<div className="relative flex flex-col gap-3 sm:gap-4">
{t.heroCards.map((card) => {
const Icon = getIcon(card.title, PlusCircle);
return (
@ -174,12 +176,12 @@ export function NextStepsSection() {
const t = translations[language].marketing.home;
return (
<section className="space-y-4">
<header className="flex items-center gap-3 text-sm text-text-muted">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">
<section className="space-y-4 rounded-[1.75rem] border border-surface-border/70 bg-white/70 p-5 shadow-[0_16px_45px_rgba(15,23,42,0.05)] lg:rounded-none lg:border-transparent lg:bg-transparent lg:p-0 lg:shadow-none">
<header className="flex flex-col gap-2 text-sm text-text-muted sm:flex-row sm:items-center sm:gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-text-subtle">
{t.nextSteps.title}
</p>
<span className="rounded-full bg-surface-muted px-3 py-1 text-xs font-semibold text-primary">
<span className="w-fit rounded-full bg-surface-muted px-3 py-1 text-xs font-semibold text-primary">
{t.nextSteps.badge}
</span>
</header>
@ -189,9 +191,9 @@ export function NextStepsSection() {
return (
<div
key={index}
className="flex items-start gap-3 rounded-xl border border-surface-border bg-surface p-4 shadow-lg shadow-shadow-sm"
className="flex items-start gap-3 rounded-[1.4rem] border border-surface-border bg-surface/92 p-4 shadow-lg shadow-shadow-sm"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/12 text-primary">
<Icon className="h-5 w-5" aria-hidden />
</div>
<div className="space-y-2">
@ -284,14 +286,19 @@ export function StatsSection() {
];
return (
<section className="rounded-2xl border border-surface-border bg-gradient-to-r from-surface-muted via-surface/0 to-surface-muted p-6 shadow-inner shadow-shadow-sm">
<div className="grid gap-6 grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
<section className="overflow-hidden rounded-[1.9rem] border border-surface-border/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.92),rgba(243,244,246,0.88))] p-5 shadow-[0_18px_40px_rgba(15,23,42,0.05)] sm:p-6">
<div className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
{displayStats.map((stat, index: number) => (
<div key={index} className="space-y-1 text-center md:text-left">
<div className="text-3xl font-semibold text-heading">
<div
key={index}
className="space-y-1 text-left even:text-right md:text-left"
>
<div className="text-[2rem] font-semibold tracking-[-0.06em] text-heading sm:text-3xl">
{stat.value}
</div>
<p className="text-sm text-text-muted">{stat.label}</p>
<p className="max-w-[9rem] text-sm text-text-muted even:ml-auto md:max-w-none">
{stat.label}
</p>
</div>
))}
</div>
@ -345,22 +352,22 @@ export function ShortcutsSection() {
}));
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<section className="space-y-4 rounded-[1.75rem] border border-surface-border/70 bg-white/70 p-5 shadow-[0_16px_45px_rgba(15,23,42,0.05)] lg:rounded-none lg:border-transparent lg:bg-transparent lg:p-0 lg:shadow-none">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-text-subtle">
{t.shortcuts.title}
</p>
<p className="text-sm text-text-muted">{t.shortcuts.subtitle}</p>
<p className="mt-1 text-sm text-text-muted">{t.shortcuts.subtitle}</p>
</div>
<div className="flex gap-2 text-xs font-semibold text-primary">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
<div className="flex flex-wrap gap-2 text-xs font-semibold text-primary">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-2 transition hover:bg-surface-hover">
{t.shortcuts.buttons.start}
</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-2 transition hover:bg-surface-hover">
{t.shortcuts.buttons.docs}
</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-2 transition hover:bg-surface-hover">
{t.shortcuts.buttons.guides}
</button>
</div>
@ -372,12 +379,12 @@ export function ShortcutsSection() {
<a
key={index}
href={item.href}
className="group flex items-start gap-3 rounded-xl border border-surface-border bg-surface p-4 transition hover:-translate-y-[1px] hover:border-primary/50 hover:bg-surface-hover"
className="group flex items-start gap-3 rounded-[1.4rem] border border-surface-border bg-surface/92 p-4 transition hover:-translate-y-[1px] hover:border-primary/50 hover:bg-surface-hover"
>
<div className="mt-1 flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
<div className="mt-1 flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/12 text-primary">
<Icon className="h-5 w-5" aria-hidden />
</div>
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<div className="text-sm font-semibold text-heading">
{item.title}
</div>
@ -403,7 +410,7 @@ type LatestBlogPost = {
function LogoPill({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-surface-border bg-surface-muted px-3 py-1 text-xs font-semibold text-text">
<span className="inline-flex items-center gap-2 rounded-full border border-surface-border bg-surface/88 px-3.5 py-1.5 text-xs font-semibold text-text shadow-[0_8px_22px_rgba(15,23,42,0.04)]">
<div className="h-2 w-2 rounded-full bg-success" />
{label}
</span>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useState, Suspense } from "react";
import Link from "next/link";
import { Check, Shield } from "lucide-react";
@ -163,7 +163,9 @@ export default function PricesPage() {
</p>
</div>
<CheckoutStatusBanner className="mx-auto mb-6 max-w-3xl" />
<Suspense fallback={null}>
<CheckoutStatusBanner className="mx-auto mb-6 max-w-3xl" />
</Suspense>
{statusMessage ? (
<p className="mx-auto mb-6 max-w-3xl rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{statusMessage}

View File

@ -50,57 +50,67 @@ export function AskAIDialog({
}
return (
<div
className={cn(
"fixed bottom-0 right-0 z-[40] border-l border-[color:var(--color-surface-border)] bg-[var(--color-background)]/95 shadow-xl backdrop-blur",
)}
style={{
width: "400px",
top: "var(--app-shell-nav-offset, 64px)",
height: "calc(100vh - var(--app-shell-nav-offset, 64px))",
display: open ? "block" : "none",
}}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
XWorkmate
</p>
<h2 className="text-sm font-semibold text-[var(--color-heading)]">
AI Assistant
</h2>
<>
{open ? (
<button
type="button"
aria-label="Close assistant"
onClick={onMinimize}
className="fixed inset-0 z-[35] bg-black/18 backdrop-blur-sm md:hidden"
/>
) : null}
<div
className={cn(
"fixed bottom-0 left-0 right-0 z-[40] overflow-hidden border border-[color:var(--color-surface-border)] bg-[var(--color-background)]/96 shadow-2xl backdrop-blur transition-transform duration-300 ease-out md:left-auto md:right-0 md:w-[400px] md:border-l md:border-t-0 md:rounded-none",
open
? "translate-y-0 md:translate-x-0"
: "translate-y-full md:translate-x-full",
"rounded-t-[1.75rem] md:rounded-none",
"top-[calc(var(--app-shell-nav-offset,64px)+0.75rem)] h-[calc(100vh-var(--app-shell-nav-offset,64px)-0.75rem)] md:top-[var(--app-shell-nav-offset,64px)] md:h-[calc(100vh-var(--app-shell-nav-offset,64px))]",
)}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-4 py-3 md:px-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--color-text-subtle)]">
XWorkmate
</p>
<h2 className="text-sm font-semibold text-[var(--color-heading)]">
AI Assistant
</h2>
</div>
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
<button
type="button"
onClick={handleMaximize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Open workspace"
>
<Maximize2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={onMinimize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Close sidebar"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
<button
type="button"
onClick={handleMaximize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Open workspace"
>
<Maximize2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={onMinimize}
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
title="Close sidebar"
>
<X className="h-4 w-4" />
</button>
<div className="min-h-0 flex-1">
<OpenClawAssistantPane
defaults={resolvedDefaults}
initialQuestion={initialQuestion?.text}
initialQuestionKey={initialQuestion?.key}
variant="sidebar"
/>
</div>
</div>
<div className="min-h-0 flex-1">
<OpenClawAssistantPane
defaults={resolvedDefaults}
initialQuestion={initialQuestion?.text}
initialQuestionKey={initialQuestion?.key}
variant="sidebar"
/>
</div>
</div>
</div>
</>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { Github, Linkedin, Moon, Sun, Twitter } from "lucide-react";
import Link from 'next/link';
import Link from "next/link";
import { useLanguage } from "../i18n/LanguageProvider";
import { useThemeStore } from "@components/theme";
@ -36,16 +36,25 @@ export default function Footer() {
};
return (
<footer className="mt-12 flex flex-col items-center justify-center gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-sm text-slate-300">
<footer className="mt-12 flex flex-col items-center justify-center gap-4 rounded-[1.75rem] border border-surface-border bg-surface/88 px-6 py-4 text-sm text-text-muted shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:rounded-2xl lg:border-white/10 lg:bg-white/5 lg:text-slate-300 lg:shadow-none">
<div className="flex w-full flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div className="flex gap-4 order-2 sm:order-1">
<Link href="/terms" className="hover:text-white transition-colors">
<Link
href="/terms"
className="transition-colors hover:text-text lg:hover:text-white"
>
{isChinese ? "服务条款" : "Terms of Service"}
</Link>
<Link href="/privacy" className="hover:text-white transition-colors">
<Link
href="/privacy"
className="transition-colors hover:text-text lg:hover:text-white"
>
{isChinese ? "隐私政策" : "Privacy Policy"}
</Link>
<Link href="/support" className="hover:text-white transition-colors">
<Link
href="/support"
className="transition-colors hover:text-text lg:hover:text-white"
>
{isChinese ? "联系我们" : "Contact Us"}
</Link>
</div>
@ -55,7 +64,7 @@ export default function Footer() {
<a
key={label}
href={href}
className="flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white transition hover:border-indigo-400/50 hover:text-indigo-100"
className="flex h-9 w-9 items-center justify-center rounded-full border border-surface-border bg-surface-muted text-text transition hover:border-surface-border-strong hover:text-text lg:border-white/10 lg:bg-white/5 lg:text-white lg:hover:border-indigo-400/50 lg:hover:text-indigo-100"
>
<Icon className="h-4 w-4" aria-hidden />
<span className="sr-only">{label}</span>
@ -69,7 +78,7 @@ export default function Footer() {
onClick={handleViewToggle}
aria-label={viewToggleLabel}
title={viewToggleLabel}
className="group flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white transition hover:border-indigo-400/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
className="group flex h-10 w-10 items-center justify-center rounded-full border border-surface-border bg-surface-muted text-text transition hover:border-surface-border-strong focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 lg:border-white/10 lg:bg-white/5 lg:text-white lg:hover:border-indigo-400/50"
>
<span className="material-symbols-outlined text-xl">
{view === "classic" ? "view_quilt" : "view_cozy"}
@ -81,21 +90,21 @@ export default function Footer() {
aria-pressed={isDark}
aria-label={toggleLabel}
title={toggleLabel}
className="group relative flex h-10 w-20 items-center rounded-full border border-white/10 bg-white/5 px-2 text-white transition hover:border-indigo-400/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
className="group relative flex h-10 w-20 items-center rounded-full border border-surface-border bg-surface-muted px-2 text-text transition hover:border-surface-border-strong focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 lg:border-white/10 lg:bg-white/5 lg:text-white lg:hover:border-indigo-400/50"
>
<span className="relative z-10 flex w-full items-center justify-between text-slate-300">
<span className="relative z-10 flex w-full items-center justify-between text-text-subtle lg:text-slate-300">
<Moon
className={`h-4 w-4 transition-colors ${isDark ? "text-indigo-100" : "text-slate-500"}`}
className={`h-4 w-4 transition-colors ${isDark ? "text-text" : "text-text-subtle lg:text-slate-500"}`}
aria-hidden
/>
<Sun
className={`h-4 w-4 transition-colors ${isDark ? "text-slate-500" : "text-amber-300"}`}
className={`h-4 w-4 transition-colors ${isDark ? "text-text-subtle lg:text-slate-500" : "text-amber-500 lg:text-amber-300"}`}
aria-hidden
/>
</span>
<span
aria-hidden
className={`absolute inset-y-1 left-1 h-8 w-8 rounded-full bg-white/90 shadow-sm transition-transform duration-300 ease-out ${isDark ? "translate-x-0" : "translate-x-10"}`}
className={`absolute inset-y-1 left-1 h-8 w-8 rounded-full bg-background shadow-sm transition-transform duration-300 ease-out lg:bg-white/90 ${isDark ? "translate-x-0" : "translate-x-10"}`}
/>
</button>
</div>

View File

@ -59,23 +59,25 @@ export function HeroCard({
onClick={hasGuide ? openGuide : undefined}
onKeyDown={handleCardKeyDown}
className={cn(
"group relative flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition-all duration-300",
"group relative flex items-start gap-4 overflow-hidden rounded-[1.6rem] border border-surface-border bg-white/88 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.05)] transition-all duration-300 sm:rounded-2xl sm:p-6",
hasGuide
? "cursor-pointer hover:border-primary/50 hover:bg-surface-hover"
: "hover:border-primary/50 hover:bg-surface-hover",
showGuide ? "border-primary/50 shadow-lg" : "",
)}
>
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2 group-hover:border-primary/50 group-hover:text-primary">
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2.5 group-hover:border-primary/50 group-hover:text-primary">
<Icon className="h-5 w-5" />
</div>
<div className="flex w-full items-start justify-between gap-4">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="space-y-1">
<h3 className="font-semibold text-heading">{title}</h3>
<p className="text-sm text-text-muted">{description}</p>
<h3 className="text-base font-semibold tracking-[-0.03em] text-heading">
{title}
</h3>
<p className="text-sm leading-6 text-text-muted">{description}</p>
</div>
{hasGuide ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
<span className="inline-flex w-fit shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
<ArrowRight className="h-3.5 w-3.5" />
</span>
@ -86,13 +88,16 @@ export function HeroCard({
{guide ? (
<div
className={cn(
"fixed top-0 right-0 z-[100] h-full w-[400px] transform border-l border-surface-border bg-surface shadow-2xl transition-transform duration-300 ease-in-out",
showGuide ? "translate-x-0" : "translate-x-full",
"fixed bottom-3 left-3 right-3 z-[100] transform overflow-hidden rounded-[1.75rem] border border-surface-border bg-surface shadow-2xl transition-transform duration-300 ease-in-out md:bottom-0 md:left-auto md:right-0 md:w-[400px] md:rounded-none md:border-l md:border-t-0",
"top-[calc(var(--app-shell-nav-offset,64px)+0.75rem)] h-[calc(100vh-var(--app-shell-nav-offset,64px)-0.75rem)] md:top-[var(--app-shell-nav-offset,64px)] md:h-[calc(100vh-var(--app-shell-nav-offset,64px))]",
showGuide
? "translate-y-0 md:translate-x-0"
: "translate-y-full md:translate-x-full",
)}
>
<div className="flex h-full flex-col overflow-y-auto p-8">
<div className="mb-8 flex items-center justify-between">
<h4 className="flex items-center gap-3 text-xl font-bold text-heading">
<div className="flex h-full flex-col overflow-y-auto p-5 sm:p-8">
<div className="mb-6 flex items-center justify-between sm:mb-8">
<h4 className="flex items-center gap-3 text-lg font-bold text-heading sm:text-xl">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
@ -109,7 +114,7 @@ export function HeroCard({
</button>
</div>
<div className="flex-1 space-y-8">
<div className="flex-1 space-y-6 sm:space-y-8">
{guide.steps.map((step, idx) => (
<div key={idx} className="group/step relative pl-8">
{idx !== guide.steps.length - 1 ? (

View File

@ -36,11 +36,10 @@ export default function UnifiedNavigation() {
"stable",
]);
const navRef = useRef<HTMLElement | null>(null);
const { language } = useLanguage();
const { language, setLanguage } = useLanguage();
const user = useUserStore((state) => state.user);
const { setIsOpen, setMode, toggleOpen } = useMoltbotStore();
const { toggleOpen } = useMoltbotStore();
const nav = translations[language].nav;
const accountCopy = nav.account;
const accountInitial =
user?.username?.charAt(0)?.toUpperCase() ??
user?.email?.charAt(0)?.toUpperCase() ??
@ -186,6 +185,13 @@ export default function UnifiedNavigation() {
const mobileQuickLinks = mobilePrimaryNav.filter((item) =>
["chat", "console", "docs", "services"].includes(item.key),
);
const mobileMenuNav = mobilePrimaryNav.filter((item) => item.key !== "home");
const primaryAccountAction = user
? (accountNav.find((item) => item.key !== "logout") ?? accountNav[0])
: (accountNav.find((item) => item.key === "login") ?? accountNav[0]);
const secondaryAccountAction = user
? accountNav.find((item) => item.key === "logout")
: accountNav.find((item) => item.key === "register");
const isHiddenRoute = pathname
? [
@ -219,7 +225,7 @@ export default function UnifiedNavigation() {
}}
className="sticky top-0 z-50 w-full border-b border-surface-border bg-background/95 text-text backdrop-blur transition-colors duration-150"
>
<div className="lg:hidden flex items-center justify-between border-b border-surface-border/70 bg-background px-4 py-3">
<div className="flex items-center justify-between border-b border-surface-border/70 bg-background px-5 pb-3 pt-[max(0.875rem,env(safe-area-inset-top))] lg:hidden">
<Link
href="/"
className="flex items-center gap-2"
@ -233,13 +239,13 @@ export default function UnifiedNavigation() {
className="h-6 w-6"
unoptimized
/>
<span className="text-base font-semibold tracking-tight text-text">
<span className="text-[1.05rem] font-semibold tracking-tight text-text">
Cloud-Neutral
</span>
</Link>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="rounded-xl bg-surface-muted p-2 text-text transition-colors hover:bg-surface-hover"
className="rounded-[1.15rem] bg-surface-muted p-3 text-text transition-colors hover:bg-surface-hover"
aria-label="Toggle menu"
>
{menuOpen ? (
@ -420,66 +426,61 @@ export default function UnifiedNavigation() {
</div>
</div>
</div>
</nav>
{menuOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<div
className="absolute inset-0 bg-white/92 backdrop-blur-md transition-opacity"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute inset-0 bg-background transition-transform duration-300 ease-in-out">
<div className="relative flex h-full flex-col overflow-y-auto bg-[radial-gradient(circle_at_bottom_right,rgba(15,23,42,0.06),transparent_32%),linear-gradient(180deg,#ffffff_0%,#fbfbfa_100%)]">
<div className="sticky top-0 z-10 flex items-center justify-between bg-transparent px-5 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
<Link
href="/"
className="flex items-center gap-2"
onClick={() => setMenuOpen(false)}
{menuOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<button
type="button"
aria-label={isChinese ? "关闭菜单" : "Close menu"}
className="absolute inset-0 bg-white/72 backdrop-blur-[2px] transition-opacity"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute inset-0 bg-background transition-transform duration-300 ease-in-out">
<div className="flex h-full flex-col overflow-y-auto px-5 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-[max(1rem,env(safe-area-inset-top))]">
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2"
onClick={() => setMenuOpen(false)}
>
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
<span className="text-[1.7rem] font-semibold tracking-[-0.05em] text-text">
Cloud-Neutral
</span>
</Link>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLanguage(language === "zh" ? "en" : "zh")}
className="inline-flex h-10 min-w-10 items-center justify-center rounded-full border border-surface-border bg-surface-muted/75 px-3 text-xs font-semibold uppercase tracking-[0.18em] text-text shadow-sm transition hover:bg-surface-hover"
aria-label={
isChinese ? "切换到英文" : "Switch language to Chinese"
}
>
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
<span className="text-[1.625rem] font-semibold tracking-[-0.04em] text-text">
Cloud-Neutral
</span>
</Link>
<div className="flex items-center gap-2">
<div className="rounded-full border border-surface-border bg-white/80 px-2 py-1 shadow-sm">
<LanguageToggle />
</div>
<button
onClick={() => setMenuOpen(false)}
className="rounded-full border border-surface-border bg-white/80 p-2 text-text-muted shadow-sm transition-colors hover:bg-surface-muted"
aria-label={isChinese ? "关闭菜单" : "Close menu"}
>
<X className="h-5 w-5" />
</button>
</div>
{language === "zh" ? "EN" : "中"}
</button>
<button
onClick={() => setMenuOpen(false)}
className="rounded-full border border-surface-border bg-surface-muted/75 p-2.5 text-text shadow-sm transition-colors hover:bg-surface-hover"
aria-label={isChinese ? "关闭菜单" : "Close menu"}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="flex-1 px-6 pb-8 pt-3">
{user ? (
<div className="mb-6 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-text text-sm font-semibold text-background">
{accountInitial}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-text">
{user.username}
</p>
<p className="truncate text-xs text-text-muted">
{user.email}
</p>
</div>
</div>
) : null}
<div className="space-y-1.5">
{mobilePrimaryNav.map((item) => {
<div className="flex flex-1 flex-col justify-between pt-8">
<div className="relative min-h-0 flex-1">
<div className="max-w-[13.5rem] space-y-3">
{mobileMenuNav.map((item) => {
const active = isActive(item);
if (item.key === "chat") {
return (
@ -489,24 +490,13 @@ export default function UnifiedNavigation() {
toggleOpen();
setMenuOpen(false);
}}
className={`group flex w-full items-center justify-between rounded-2xl px-1 py-3 text-left transition-colors ${
className={`block w-full py-1 text-left text-[2rem] font-semibold tracking-[-0.055em] transition-colors ${
active
? "text-text"
: "text-text hover:text-primary"
}`}
>
<span className="text-[2rem] font-semibold tracking-[-0.045em]">
{getLabel(item.label, language)}
</span>
<span
className={`text-sm transition-transform ${
active
? "text-primary"
: "text-text-muted group-hover:translate-x-1 group-hover:text-primary"
}`}
>
{isChinese ? "进入" : "Open"}
</span>
{getLabel(item.label, language)}
</button>
);
}
@ -514,37 +504,23 @@ export default function UnifiedNavigation() {
<Link
key={item.key}
href={item.href}
className={`group flex items-center justify-between rounded-2xl px-1 py-3 transition-colors ${
className={`block py-1 text-[2rem] font-semibold tracking-[-0.055em] transition-colors ${
active
? "text-text"
: "text-text hover:text-primary"
}`}
onClick={() => setMenuOpen(false)}
>
<span className="text-[2rem] font-semibold tracking-[-0.045em]">
{getLabel(item.label, language)}
</span>
<span
className={`text-sm transition-transform ${
active
? "text-primary"
: "text-text-muted group-hover:translate-x-1 group-hover:text-primary"
}`}
>
{isChinese ? "查看" : "View"}
</span>
{getLabel(item.label, language)}
</Link>
);
})}
</div>
{mobileQuickLinks.length > 0 ? (
<div className="pointer-events-none mt-10 flex justify-end">
<div className="pointer-events-auto w-[10.5rem] rounded-[1.6rem] bg-slate-100/88 p-4 shadow-[0_18px_40px_rgba(15,23,42,0.08)] backdrop-blur">
<p className="mb-3 text-[0.7rem] font-semibold uppercase tracking-[0.22em] text-text-muted/70">
{isChinese ? "快捷入口" : "Shortcuts"}
</p>
<div className="space-y-2">
<div className="pointer-events-none absolute bottom-4 right-0 flex justify-end">
<div className="pointer-events-auto w-[11rem] rounded-[1.75rem] bg-surface-muted/82 p-4 shadow-[0_18px_40px_rgba(15,23,42,0.08)]">
<div className="space-y-2.5">
{mobileQuickLinks.map((item) =>
item.key === "chat" ? (
<button
@ -553,7 +529,7 @@ export default function UnifiedNavigation() {
toggleOpen();
setMenuOpen(false);
}}
className="block text-left text-[1.05rem] font-medium tracking-[-0.03em] text-text transition hover:text-primary"
className="block text-left text-[1.08rem] font-medium tracking-[-0.03em] text-text transition hover:text-primary"
>
{getLabel(item.label, language)}
</button>
@ -562,7 +538,7 @@ export default function UnifiedNavigation() {
key={item.key}
href={item.href}
onClick={() => setMenuOpen(false)}
className="block text-[1.05rem] font-medium tracking-[-0.03em] text-text transition hover:text-primary"
className="block text-[1.08rem] font-medium tracking-[-0.03em] text-text transition hover:text-primary"
>
{getLabel(item.label, language)}
</Link>
@ -574,55 +550,43 @@ export default function UnifiedNavigation() {
) : null}
</div>
<div className="mt-auto px-6 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4">
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-3 max-w-[11rem] rounded-full border border-surface-border bg-white/80 px-3 py-2 shadow-sm">
<ReleaseChannelSelector
selected={selectedChannels}
onToggle={toggleChannel}
variant="icon"
/>
</div>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-surface-border bg-white/80 shadow-sm">
{accountInitial}
</span>
<span className="truncate">
{user
? user.username || user.email
: isChinese
? "访客模式"
: "Guest mode"}
</span>
</div>
</div>
<div className="flex w-[10.5rem] shrink-0 flex-col gap-2">
{accountNav.map((item) => (
<Link
key={item.key}
href={item.href}
className={`flex min-h-12 items-center justify-center rounded-full px-4 text-base font-semibold transition ${
item.key === "logout"
? "bg-rose-500/8 text-rose-600 hover:bg-rose-500/15"
: "bg-slate-100/88 text-text hover:bg-slate-200/88"
}`}
onClick={() => setMenuOpen(false)}
>
{typeof item.label === "function"
? item.label(language)
: item.label}
</Link>
))}
<div className="flex items-end justify-between gap-5 pt-10">
<div className="flex flex-col items-start gap-3 text-sm text-text-muted">
{secondaryAccountAction ? (
<Link
href={secondaryAccountAction.href}
onClick={() => setMenuOpen(false)}
className="text-sm font-medium text-text-muted transition hover:text-text"
>
{typeof secondaryAccountAction.label === "function"
? secondaryAccountAction.label(language)
: secondaryAccountAction.label}
</Link>
) : null}
<div className="inline-flex items-center gap-2 rounded-full border border-surface-border bg-surface px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted shadow-sm">
{isChinese ? "单页导航" : "Single Page"}
</div>
</div>
{primaryAccountAction ? (
<div className="flex shrink-0 flex-col items-end gap-2">
<Link
href={primaryAccountAction.href}
onClick={() => setMenuOpen(false)}
className="inline-flex min-h-[3.25rem] min-w-[6.5rem] items-center justify-center rounded-full bg-surface-muted px-6 text-lg font-semibold text-text shadow-sm transition hover:bg-surface-hover"
>
{typeof primaryAccountAction.label === "function"
? primaryAccountAction.label(language)
: primaryAccountAction.label}
</Link>
</div>
) : null}
</div>
</div>
</div>
</div>
)}
</nav>
</div>
)}
{/* <div className="hidden lg:block">
<AskAIButton />

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
evaluateAccountAdminAccess,
isPlatformRootEmail,
} from "@server/account/adminAccess";
import type { AccountSessionUser } from "@server/account/session";
function buildUser(overrides: Partial<AccountSessionUser> = {}): AccountSessionUser {
return {
id: "user-1",
uuid: "user-1",
email: "user@example.com",
role: "user",
groups: [],
permissions: [],
...overrides,
};
}
describe("adminAccess", () => {
it("allows platform admin roles without an explicit permission", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({ role: "admin" }), {
roles: ["admin", "operator"],
permissions: ["admin.users.list.read"],
});
expect(decision).toEqual({ allowed: true });
});
it("allows permission-scoped operators without requiring the admin role", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({
role: "operator",
permissions: ["admin.users.pause.write"],
}), {
roles: ["admin", "operator"],
permissions: ["admin.users.pause.write"],
});
expect(decision).toEqual({ allowed: true });
});
it("enforces root-only routes after role and permission checks pass", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({
role: "admin",
permissions: ["admin.settings.write"],
}), {
roles: ["admin"],
permissions: ["admin.settings.write"],
rootOnly: true,
});
expect(decision).toEqual({ allowed: false, reason: "root_only" });
});
it("recognizes the shared platform root email", () => {
expect(isPlatformRootEmail("admin@svc.plus")).toBe(true);
expect(isPlatformRootEmail("ADMIN@svc.plus")).toBe(true);
expect(isPlatformRootEmail("user@example.com")).toBe(false);
});
});

View File

@ -0,0 +1,77 @@
import type {
AccountSessionUser,
AccountUserRole,
} from "@server/account/session";
export const PLATFORM_ROOT_EMAIL = "admin@svc.plus";
type AccountAdminAccessRule = {
roles?: AccountUserRole[];
permissions?: string[];
rootOnly?: boolean;
};
type AccountAdminAccessDecision = {
allowed: boolean;
reason?: "forbidden" | "root_only";
};
export function isPlatformRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === PLATFORM_ROOT_EMAIL;
}
function hasRole(
user: AccountSessionUser,
roles: AccountUserRole[],
): boolean {
return roles.includes(user.role);
}
function hasPermission(
user: AccountSessionUser,
permissions: string[],
): boolean {
if (permissions.length === 0) {
return false;
}
const normalizedPermissions = new Set(
user.permissions.map((permission) => permission.trim()),
);
if (normalizedPermissions.has("*")) {
return true;
}
return permissions.every((permission) =>
normalizedPermissions.has(permission.trim()),
);
}
export async function evaluateAccountAdminAccess(
user: AccountSessionUser | null,
rule: AccountAdminAccessRule,
): Promise<AccountAdminAccessDecision> {
if (!user) {
return { allowed: false, reason: "forbidden" };
}
const roles = rule.roles ?? [];
const permissions = rule.permissions ?? [];
let allowed = false;
if (roles.length > 0 && permissions.length > 0) {
allowed = hasRole(user, roles) || hasPermission(user, permissions);
} else if (roles.length > 0) {
allowed = hasRole(user, roles);
} else if (permissions.length > 0) {
allowed = hasPermission(user, permissions);
}
if (!allowed) {
return { allowed: false, reason: "forbidden" };
}
if (rule.rootOnly && !isPlatformRootEmail(user.email)) {
return { allowed: false, reason: "root_only" };
}
return { allowed: true };
}