feat(ui): introduce LinearBorderContainer component and enhance NotFound pages

- Added LinearBorderContainer component for improved UI styling with gradient borders.
- Refactored NotFound components in both web and dashboard to utilize LinearBorderContainer, enhancing visual presentation and user experience.
- Updated layout and messaging for 404 error pages to provide clearer navigation options.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-12 22:47:56 +08:00
parent 84fe2b70ec
commit 76d60fd672
24 changed files with 221 additions and 132 deletions

View File

@@ -1,22 +1,49 @@
import { Button } from '@afilmory/ui'
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { useLocation, useNavigate } from 'react-router'
export const NotFound = () => {
const location = useLocation()
const navigate = useNavigate()
return (
<div className="prose center dark:prose-invert m-auto size-full flex-col">
<main className="flex grow flex-col items-center justify-center">
<p className="font-semibold">You have come to a desert of knowledge where there is nothing.</p>
<p>
Current path: <code>{location.pathname}</code>
</p>
<p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</p>
</main>
return (
<div className="bg-background text-text relative flex min-h-dvh flex-1 flex-col">
<div className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6">
<LinearBorderContainer>
<div className="relative w-full max-w-[640px] overflow-hidden border border-white/5">
{/* Glassmorphic background effects */}
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="from-accent/20 absolute -inset-32 bg-linear-to-br via-transparent to-transparent blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%)]" />
</div>
<div className="relative p-10 sm:p-12">
<div>
<p className="text-text-tertiary mb-3 text-xs font-semibold tracking-[0.55em] uppercase">404</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">Page Not Found</h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">
The page you're looking for doesn't exist. It may have been moved or deleted. Please check the URL or
return to the home page to continue exploring.
</p>
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
<p className="text-text-secondary">
Current path: <span className="text-text font-medium">{location.pathname}</span>
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button variant="primary" className="glassmorphic-btn flex-1" onClick={() => navigate('/')}>
Back to Home
</Button>
<Button variant="ghost" className="flex-1" onClick={() => navigate(-1)}>
Go Back
</Button>
</div>
</div>
</div>
</div>
</LinearBorderContainer>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import type { TenantResolutionOptions } from '../modules/tenant/tenant-context-resolver.service'
import type { TenantResolutionOptions } from '../modules/platform/tenant/tenant-context-resolver.service'
export const TENANT_RESOLUTION_OPTIONS = Symbol('core:tenantResolutionOptions')

View File

@@ -43,7 +43,7 @@ export class BuilderSettingService {
const tenant = requireTenantContext()
const config = await this.builderConfigService.getConfigForTenant(tenant.tenant.id)
return {
system: config.system,
system: this.normalizeSystemSettings(config.system),
}
}
@@ -76,4 +76,31 @@ export class BuilderSettingService {
'system.observability.performance.worker.timeout': system.observability.performance.worker.timeout,
}
}
private normalizeSystemSettings(system: BuilderConfig['system']): BuilderSystemSettingsDto {
const supportedFormats =
system.processing.supportedFormats instanceof Set
? Array.from(system.processing.supportedFormats)
: system.processing.supportedFormats
return {
processing: {
...system.processing,
digestSuffixLength: system.processing.digestSuffixLength ?? 0,
supportedFormats,
},
observability: {
...system.observability,
logging: {
...system.observability.logging,
},
performance: {
...system.observability.performance,
worker: {
...system.observability.performance.worker,
},
},
},
}
}
}

View File

@@ -8,7 +8,7 @@ import type {
UiGroupNode,
UiSchema,
UiSectionNode,
} from '../ui-schema/ui-schema.type'
} from '../../ui/ui-schema/ui-schema.type'
import type { DEFAULT_SETTING_DEFINITIONS } from './setting.constant'
export type SettingDefinition<Schema extends z.ZodTypeAny = z.ZodTypeAny> = {

View File

@@ -1,8 +1,8 @@
import { injectable } from 'tsyringe'
import type { UiNode } from '../../ui/ui-schema/ui-schema.type'
import type { SettingEntryInput } from '../setting/setting.service'
import { SettingService } from '../setting/setting.service'
import type { UiNode } from '../ui-schema/ui-schema.type'
import type { SiteSettingEntryInput, SiteSettingKey, SiteSettingUiSchemaResponse } from './site-setting.type'
import { ONBOARDING_SITE_SETTING_KEYS, SITE_SETTING_KEYS } from './site-setting.type'
import { SITE_SETTING_UI_SCHEMA, SITE_SETTING_UI_SCHEMA_KEYS } from './site-setting.ui-schema'

View File

@@ -1,6 +1,6 @@
import type { UiSchema } from '../../ui/ui-schema/ui-schema.type'
import type { SettingEntryInput } from '../setting/setting.service'
import type { SettingKeyType } from '../setting/setting.type'
import type { UiSchema } from '../ui-schema/ui-schema.type'
export const SITE_SETTING_KEYS = [
'site.name',

View File

@@ -1,4 +1,4 @@
import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type'
import type { UiNode, UiSchema } from '../../ui/ui-schema/ui-schema.type'
import type { SiteSettingKey } from './site-setting.type'
export const SITE_SETTING_UI_SCHEMA_VERSION = '1.0.0'

View File

@@ -120,7 +120,6 @@ export class FeedService implements OnModuleInit, OnModuleDestroy {
url: siteConfig.author.url,
avatar: siteConfig.author.avatar ?? null,
},
feed: siteConfig.feed,
}
const xml = generateRSSFeed(manifest.data, feedConfig)

View File

@@ -196,7 +196,7 @@ export abstract class StaticAssetService {
private extractRelativePath(fullPath: string): string {
const index = fullPath.indexOf(this.routeSegment)
if (index === -1) {
return ''
return this.stripLeadingSlashes(fullPath)
}
const sliceStart = index + this.routeSegment.length

View File

@@ -293,11 +293,12 @@ export class AuthRegistrationService {
const initialSettings = this.normalizeSettings(settings)
if (initialSettings.length > 0 && tenantId) {
const scopedTenantId = tenantId
await this.settingService.setMany(
initialSettings.map((entry) => ({
...entry,
options: {
tenantId,
tenantId: scopedTenantId,
isSensitive: false,
},
})),
@@ -335,6 +336,9 @@ export class AuthRegistrationService {
if (!sessionUserId) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '当前登录状态无效,请重新登录。' })
}
if (!sessionUser) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '无法获取当前用户信息,请重新登录。' })
}
const db = this.dbAccessor.get()
const [record] = await db

View File

@@ -1,4 +1,4 @@
import { Button } from '@afilmory/ui'
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { useLocation, useNavigate } from 'react-router'
export function NotFound() {
@@ -6,88 +6,43 @@ export function NotFound() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col">
{/* Header spacer */}
<div className="h-16" />
{/* Main content */}
<div className="flex flex-1 items-center justify-center px-6">
<div className="w-full max-w-lg">
{/* 404 icon and status */}
<div className="mb-8 text-center">
<div className="bg-background-secondary mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full">
<svg
className="text-blue h-8 w-8"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"
/>
</svg>
<div className="relative flex min-h-dvh flex-1 flex-col bg-background text-text">
<div className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6">
<LinearBorderContainer>
<div className="relative w-full max-w-[640px] overflow-hidden border border-white/5">
{/* Glassmorphic background effects */}
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute -inset-32 bg-linear-to-br from-accent/20 via-transparent to-transparent blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%)]" />
</div>
<h1 className="text-text mb-2 text-6xl font-bold">404</h1>
<h2 className="text-text mb-2 text-2xl font-medium">Page not found</h2>
<p className="text-text-secondary text-lg">The page you're looking for doesn't exist</p>
</div>
{/* Current path info */}
<div className="bg-material-medium border-fill-tertiary mb-8 rounded-lg border p-4">
<div className="flex items-start gap-3">
<svg
className="text-text-tertiary mt-0.5 h-5 w-5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
</svg>
<div className="min-w-0 flex-1">
<p className="text-text-secondary mb-1 text-sm">Requested URL</p>
<code className="text-text bg-material-thin rounded px-2 py-1 font-mono text-sm break-all">
{location.pathname}
</code>
<div className="relative p-10 sm:p-12">
<div>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">404</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">Page Not Found</h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">
The page you're looking for doesn't exist. It may have been moved or deleted. Please check the URL or
navigate back to continue using the dashboard.
</p>
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
<p className="text-text-secondary">
Requested URL: <span className="text-text font-medium">{location.pathname}</span>
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button variant="primary" className="glassmorphic-btn flex-1" onClick={() => navigate('/')}>
Go Home
</Button>
<Button variant="ghost" className="flex-1" onClick={() => navigate(-1)}>
Go Back
</Button>
</div>
</div>
</div>
</div>
{/* Action buttons */}
<div className="mb-8 flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => navigate('/')}
className="bg-material-opaque text-text-vibrant hover:bg-control-enabled/90 h-10 flex-1 border-0 font-medium transition-colors"
>
Go Home
</Button>
<Button
onClick={() => navigate(-1)}
className="bg-material-thin text-text border-fill-tertiary hover:bg-fill-tertiary h-10 flex-1 border font-medium transition-colors"
>
Go Back
</Button>
</div>
{/* Help text */}
<div className="text-center">
<p className="text-text-secondary text-sm">
If you think this is a mistake, please check the URL or contact support.
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex h-16 items-center justify-center">
<p className="text-text-secondary/50 text-xs">Error 404 Page not found</p>
</LinearBorderContainer>
</div>
</div>
)

View File

@@ -9,6 +9,7 @@ import {
import { clsxm } from '@afilmory/utils'
import { ChevronDown, LogOut, Settings, User as UserIcon } from 'lucide-react'
import { useState } from 'react'
import { Link } from 'react-router'
import { usePageRedirect } from '~/hooks/usePageRedirect'
import type { BetterAuthUser } from '~/modules/auth/types'
@@ -89,11 +90,11 @@ export function UserMenu({ user }: UserMenuProps) {
<DropdownMenuSeparator />
<DropdownMenuItem icon={<UserIcon className="size-4" />}>
<a href="/settings/account">Account Settings</a>
<Link to="/settings/account">Account Settings</Link>
</DropdownMenuItem>
<DropdownMenuItem icon={<Settings className="size-4" />}>
<a href="/settings/site">Preferences</a>
<Link to="/settings/site">Preferences</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />

View File

@@ -1,4 +1,4 @@
import { ScrollArea } from '@afilmory/ui'
import { LinearBorderContainer, ScrollArea } from '@afilmory/ui'
import { useStore } from '@tanstack/react-form'
import { useQuery } from '@tanstack/react-query'
import type { FC, KeyboardEvent } from 'react'
@@ -13,7 +13,6 @@ import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
import { getTenantSlugFromHost } from '~/modules/auth/utils/domain'
import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types'
import { getWelcomeSiteSchema } from '~/modules/welcome/api'
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/welcome/siteSchema'
import {
coerceSiteFieldValue,

View File

@@ -47,7 +47,7 @@ export function useRegisterTenant() {
},
onSuccess: ({ slug }) => {
try {
const loginUrl = buildTenantUrl(slug, '/login')
const loginUrl = buildTenantUrl(slug, '/platform/login')
setErrorMessage(null)
window.location.replace(loginUrl)
} catch (redirectError) {

View File

@@ -1,7 +1,6 @@
import { Button } from '@afilmory/ui'
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { useMemo } from 'react'
import { LinearBorderContainer } from './LinearBorderContainer'
import { buildHomeUrl, buildRegistrationUrl, getCurrentHostname } from './tenant-utils'
export const TenantMissingStandalone = () => {

View File

@@ -1,7 +1,6 @@
import { Button } from '@afilmory/ui'
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { useMemo } from 'react'
import { LinearBorderContainer } from './LinearBorderContainer'
import { buildHomeUrl, buildRegistrationUrl, getCurrentHostname } from './tenant-utils'
export const TenantRestrictedStandalone = () => {

View File

@@ -1,4 +1,4 @@
import { Button, Input, Label } from '@afilmory/ui'
import { Button, Input, Label, LinearBorderContainer } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useMemo, useState } from 'react'
@@ -6,7 +6,6 @@ import { useMemo, useState } from 'react'
import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons'
import { useLogin } from '~/modules/auth/hooks/useLogin'
import { getTenantSlugFromHost } from '~/modules/auth/utils/domain'
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
export function Component() {
const [email, setEmail] = useState('')

View File

@@ -1,4 +1,4 @@
import { Button, Input, Label } from '@afilmory/ui'
import { Button, Input, Label, LinearBorderContainer } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
@@ -6,7 +6,6 @@ import { Link } from 'react-router'
import { useLogin } from '~/modules/auth/hooks/useLogin'
import { buildRootTenantUrl, getTenantSlugFromHost } from '~/modules/auth/utils/domain'
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
export function Component() {
const [email, setEmail] = useState('')

View File

@@ -1,15 +1,10 @@
import { Button } from '@afilmory/ui'
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useMemo } from 'react'
import { useNavigate } from 'react-router'
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
function getCurrentHostname() {
if (typeof window === 'undefined') {
return null
}
try {
return window.location.hostname
} catch {

View File

@@ -1,4 +1,4 @@
import { Button } from '@afilmory/ui'
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { useQueryClient } from '@tanstack/react-query'
import { m } from 'motion/react'
@@ -11,7 +11,6 @@ import { useSetAuthUser } from '~/atoms/auth'
import { ROUTE_PATHS } from '~/constants/routes'
import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session'
import { signOutBySource } from '~/modules/auth/auth-client'
import { LinearBorderContainer } from '~/modules/welcome/components/LinearBorderContainer'
export const Component: FC = () => {
const location = useLocation()

79
docs/TENANT_FLOW.md Normal file
View File

@@ -0,0 +1,79 @@
# Tenant & OAuth Flow
This document describes how tenant resolution, Better Auth instances, and dashboard redirects tie together across the platform.
## Request Bootstrap
1. `RequestContextMiddleware` runs first on every request.
- Calls `TenantContextResolver.resolve()` to populate `HttpContext.tenant`.
- Calls `AuthProvider.getAuth()` so downstream handlers reuse the tenant-aware Better Auth instance.
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
- Extracts a slug via `tenant-host.utils.ts`.
- Loads the tenant aggregate; if none exists, falls back to the placeholder tenant.
- Always stores the original `requestedSlug` (even when placeholder), echoes headers `x-tenant-id` and `x-tenant-slug` (effective slug).
## Auth Provider
- `AuthProvider` caches Better Auth instances by `protocol://host::slug::settings-hash`.
- The slug priority is:
1. `HttpContext.tenant.requestedSlug`
2. `HttpContext.tenant.slug`
3. Derived from the host (when the context slug is still the placeholder).
- Redirect URIs are always built as `<OAuthGateway>/api/auth/callback/:provider?tenantSlug=...`.
- Because the requested slug participates in the cache key, the same Better Auth instance handles both the `/auth/social` request and the gateway callback, preserving OAuth state.
## System Settings & Gateway
- System settings only manage:
- Registration flags
- Base domain
- OAuth gateway URL
- Provider credentials
- No per-provider redirect URIs are stored; every provider points to the centralized gateway.
- `/auth/social/providers` reflects the enabled providers for the UI.
## Session Payload
`GET /auth/session` returns:
```ts
{
user: BetterAuthUser,
session: BetterAuthSession,
tenant: {
id: string,
slug: string | null, // Effective slug (requested slug if present, otherwise actual)
isPlaceholder: boolean
} | null
}
```
- When the resolver falls back to the placeholder tenant, `tenant.slug` still holds the requested subdomain, and `isPlaceholder` is `true`.
- Consumers simply check `tenant.isPlaceholder` to know whether they are in onboarding.
## Dashboard Behavior
- **Welcome flow** (`/platform/welcome`):
- Locks the slug input to `window.location.hostname` via `getTenantSlugFromHost`.
- Shows `TenantMissing` or `TenantRestricted` pages, but relies on backend redirects for actual auth.
- **Hooks (`usePageRedirect`)**:
- If `tenant` is null or `isPlaceholder`, stay on welcome routes.
- If `tenant.slug` exists and differs from the current host, sign out placeholder cookies and `window.location.replace(buildTenantUrl(tenant.slug))`.
- Superadmin routes are gated separately.
## OAuth Happy Path
1. User opens `https://slug.example.com` (maybe not provisioned yet).
2. Resolver sets `requestedSlug = "slug"`, but tenant aggregate may still be the placeholder.
3. User clicks “Sign in with GitHub” → `/auth/social` uses `requestedSlug` and redirects via the OAuth gateway.
4. Gateway forwards the callback to `https://slug.example.com/api/auth/callback/github`.
5. Resolver again sets `requestedSlug = "slug"`; Better Auth instance cache hits, so `state` matches.
6. `/auth/session` returns `{ tenant: { slug: "slug", isPlaceholder: true } }` → dashboard stays on welcome, no cross-subdomain jump.
7. Once the tenant is provisioned, future sessions have `isPlaceholder: false`, and `usePageRedirect` ensures we land on the actual workspace subdomain.
## Key Guarantees
- Only a single `tenant.slug` crosses the API boundary; there are no ambiguous fields.
- Placeholder detection is a boolean (`isPlaceholder`).
- Better Auth instances survive OAuth handshakes regardless of tenant provisioning state.
- Headers `x-tenant-id` / `x-tenant-slug` always mirror the effective slug, so backend services and the dashboard remain consistent.

View File

@@ -7,29 +7,36 @@ type LinearBorderContainerProps = {
/**
* Color tint for the border gradient.
* @default 'text' - Uses the default text color
* @example 'accent' - Uses the accent color
* @example 'red' - Uses the red system color
* @default 'var(--color-text-secondary)' - Uses the default text secondary color
* @example 'var(--color-accent)' - Uses the accent color
* @example 'var(--color-red)' - Uses the red system color
*/
tint?: string
}
/**
* A container with linear gradient borders on all sides.
* Creates a sophisticated border effect using CSS gradients.
*
* @example
* ```tsx
* <LinearBorderContainer className="bg-background-tertiary">
* <div className="grid lg:grid-cols-[280px_1fr]">
* <Sidebar />
* <Content />
* <LinearBorderContainer className="bg-background-tertiary">
* <div className="p-12">
* <h1>Content with linear gradient borders</h1>
* </div>
* </LinearBorderContainer>
* ```
*
* @example With custom tint
* ```tsx
* <LinearBorderContainer tint="var(--color-accent)">
* <div>Accent-colored borders</div>
* </LinearBorderContainer>
* ```
*/
export const LinearBorderContainer: FC<LinearBorderContainerProps> = ({
children,
className,
tint = 'var(--color-text-secondary)',
}) => {
// Generate inline styles for gradients with dynamic tint color
@@ -40,7 +47,6 @@ export const LinearBorderContainer: FC<LinearBorderContainerProps> = ({
background: `linear-gradient(to bottom, transparent -15%, ${tint} 50%, transparent 115%)`,
}
// Advanced mode: uses flex layout for borders that span full dimensions
return (
<div className="flex flex-col">
<div className={clsxm('flex flex-row', className)}>
@@ -53,13 +59,13 @@ export const LinearBorderContainer: FC<LinearBorderContainerProps> = ({
{/* Main content area */}
{children}
{/* Right border container */}
{/* Right border */}
<div className="flex shrink-0 flex-col">
<div className="absolute top-0 bottom-0 z-1 w-[0.5px]" style={verticalGradient} />
</div>
</div>
{/* Bottom border container */}
{/* Bottom border */}
<div className="w-[2px] shrink-0">
<div className="absolute right-0 left-0 z-1 h-[0.5px]" style={horizontalGradient} />
</div>

View File

@@ -0,0 +1 @@
export { LinearBorderContainer } from './LinearBorderContainer'

View File

@@ -2,6 +2,7 @@ export * from './button'
export * from './checkbox'
export * from './checkbox'
export * from './collapsible'
export * from './container'
export * from './context-menu'
export * from './dialog'
export * from './divider'