Merge pull request #59 from cloud-neutral-toolkit/release/v0.2
Release/v0.2
This commit is contained in:
commit
6ed69a0f73
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 />
|
||||
|
||||
61
src/server/account/adminAccess.test.ts
Normal file
61
src/server/account/adminAccess.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
src/server/account/adminAccess.ts
Normal file
77
src/server/account/adminAccess.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user