diff --git a/.trellis/spec/frontend/components.md b/.trellis/spec/frontend/components.md
index f36fe27..09f5629 100644
--- a/.trellis/spec/frontend/components.md
+++ b/.trellis/spec/frontend/components.md
@@ -117,6 +117,47 @@ import { Table } from "@/components/ui/table";
Visible form controls and data tables should use the Kumo-backed adapters. Native hidden
inputs are acceptable only inside adapters when needed to preserve FormData semantics.
+### Select Adapter Positioning Contract
+
+`components/ui/select.tsx` is intentionally project-owned instead of directly wrapping
+`@cloudflare/kumo/components/select`. Kumo Select 2.6 can inherit Base UI
+`alignItemWithTrigger=true`, which may render `data-side="none"` and overlap the trigger in
+production.
+
+The project Select adapter must:
+
+- Preserve the local API: `options`, `value`, `defaultValue`, `name`, `placeholder`,
+ `disabled`, `required`, `aria-*`, and `onValueChange`.
+- Render a hidden form value only inside the adapter when `name` is provided.
+- Use portal/fixed positioning for the popup and keep a positive gap between trigger and
+ panel. Browser validation should assert `verticalOverlap === 0` on desktop and mobile.
+- Keep keyboard behavior for ArrowUp/ArrowDown, Home/End, Enter/Space, Escape, and Tab.
+
+```typescript
+// Good: feature code stays on the project adapter.
+
+
+// Avoid: this can reintroduce production overlay regressions.
+import { Select } from "@cloudflare/kumo/components/select";
+```
+
+### Dialog Adapter Centering Contract
+
+`components/ui/dialog.tsx` may use Kumo Dialog primitives for accessibility, but the
+project adapter owns panel geometry. Kumo Dialog includes fixed `left-1/2 top-1/2`
+and translate classes, and those transforms can interact with project animations or
+production CSS ordering.
+
+The project Dialog adapter must:
+
+- Center the panel without relying on translate transforms. Use `fixed inset-0 m-auto`
+ plus explicit width, max-height, and `h-fit`.
+- Override Kumo minimum width so mobile dialogs stay inside `100vw`.
+- Keep `transform: none` and `translate: 0 0` on `.teatea-dialog`; use
+ opacity/scale-only motion if animation is needed.
+- Browser validation should assert the panel center is within 2px of the viewport center
+ on desktop and mobile.
+
### Use Proper Elements
```typescript
diff --git a/app/(app)/app/template.tsx b/app/(app)/app/template.tsx
new file mode 100644
index 0000000..7267a42
--- /dev/null
+++ b/app/(app)/app/template.tsx
@@ -0,0 +1,7 @@
+type AppTemplateProps = {
+ children: React.ReactNode;
+};
+
+export default function AppTemplate({ children }: AppTemplateProps): React.ReactElement {
+ return
{children}
;
+}
diff --git a/app/globals.css b/app/globals.css
index 2e5241a..895ae1b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -101,6 +101,17 @@ a,
}
}
+@keyframes route-transition {
+ from {
+ opacity: 0;
+ transform: translateY(0.375rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
@keyframes loading-progress {
0% {
transform: translateX(-100%);
@@ -128,6 +139,10 @@ a,
animation: page-enter 180ms ease-out both;
}
+.route-transition {
+ animation: route-transition 180ms ease-out both;
+}
+
.table-row-enter {
animation: page-enter 160ms ease-out both;
}
@@ -141,9 +156,15 @@ a,
}
.teatea-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
min-height: 2.5rem;
border-radius: var(--radius-md);
font-weight: 600;
+ line-height: 1.25;
+ white-space: nowrap;
transition:
background-color 140ms ease,
border-color 140ms ease,
@@ -152,8 +173,13 @@ a,
opacity 140ms ease;
}
+.teatea-button :where(svg) {
+ flex-shrink: 0;
+}
+
.teatea-button:focus-visible,
.teatea-control:focus-visible,
+.teatea-select-trigger:focus-visible,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]):focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 24%, transparent);
@@ -198,6 +224,7 @@ a,
}
.teatea-control,
+.teatea-select-trigger,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) {
min-height: 2.5rem;
width: 100%;
@@ -207,6 +234,7 @@ a,
color: var(--foreground);
font-size: 0.875rem;
font-weight: 500;
+ line-height: 1.35;
box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 7%, transparent);
transition:
background-color 140ms ease,
@@ -218,7 +246,12 @@ a,
padding: 0.5rem 0.75rem;
}
+.teatea-select-trigger,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
appearance: none;
outline: none;
padding: 0.5rem 0.75rem;
@@ -226,27 +259,89 @@ a,
}
.teatea-control:hover,
+.teatea-select-trigger:hover,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]):hover {
border-color: color-mix(in oklch, var(--primary) 45%, var(--input));
background: color-mix(in oklch, var(--secondary) 34%, white);
}
+.teatea-select-trigger[data-open="true"] {
+ border-color: color-mix(in oklch, var(--primary) 58%, var(--input));
+ background: var(--card);
+}
+
.teatea-control::placeholder {
color: var(--muted-foreground);
}
.teatea-control:disabled,
.teatea-button:disabled,
+.teatea-select-trigger:disabled,
:where([data-kumo-component="Select"][data-kumo-part="trigger"][data-disabled]) {
cursor: not-allowed;
opacity: 0.55;
}
+.teatea-select-trigger :where(svg),
.teatea-select :where(svg),
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) :where(svg) {
color: var(--primary);
}
+.teatea-select-panel {
+ position: fixed;
+ z-index: 60;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+ color: var(--foreground);
+ padding: 0.375rem;
+ box-shadow:
+ 0 18px 44px color-mix(in oklch, var(--foreground) 14%, transparent),
+ 0 0 0 1px color-mix(in oklch, var(--primary) 10%, transparent);
+ animation: page-enter 120ms ease-out both;
+}
+
+.teatea-select-option {
+ display: flex;
+ min-height: 2.25rem;
+ width: 100%;
+ cursor: pointer;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.625rem;
+ border: 0;
+ border-radius: calc(var(--radius-md) - 2px);
+ background: transparent;
+ color: var(--foreground);
+ padding: 0.5rem 0.625rem;
+ text-align: left;
+ font-size: 0.875rem;
+ outline: none;
+}
+
+.teatea-select-option[data-highlighted="true"],
+.teatea-select-option:hover {
+ background: var(--secondary);
+ color: var(--secondary-foreground);
+}
+
+.teatea-select-option:focus-visible {
+ box-shadow: inset 0 0 0 2px color-mix(in oklch, var(--ring) 36%, transparent);
+}
+
+.teatea-select-option[data-selected="true"] {
+ color: var(--primary);
+ font-weight: 600;
+}
+
+.teatea-select-option:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
:where([role="listbox"]:has([data-kumo-component="Select"][data-kumo-part="option"])) {
z-index: 60;
outline: none;
@@ -332,6 +427,14 @@ a,
}
.teatea-dialog {
+ inset: 0 !important;
+ height: fit-content;
+ margin: auto !important;
+ max-width: calc(100vw - 1.5rem);
+ min-width: min(calc(100vw - 1.5rem), 48rem) !important;
+ transform: none !important;
+ translate: 0 0 !important;
+ width: min(calc(100vw - 1.5rem), 48rem) !important;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
@@ -348,8 +451,10 @@ a,
@media (prefers-reduced-motion: reduce) {
.page-enter,
+ .route-transition,
.table-row-enter,
.dialog-panel-enter,
+ .teatea-select-panel,
.loading-progress {
animation: none;
}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index 633db80..c4808ea 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -16,13 +16,34 @@ const variantMap: Record = {
danger: "error",
};
-export type BadgeProps = Omit & {
+export type BadgeProps = Omit & {
variant?: LegacyBadgeVariant | KumoBadgeProps["variant"];
- children?: React.ReactNode;
};
-export function Badge({ className, variant = "default", ...props }: BadgeProps): React.ReactElement {
- const mappedVariant = variant in variantMap ? variantMap[variant as LegacyBadgeVariant] : (variant as KumoBadgeProps["variant"]);
-
- return ;
+function isLegacyBadgeVariant(variant: BadgeProps["variant"]): variant is LegacyBadgeVariant {
+ switch (variant) {
+ case "default":
+ case "secondary":
+ case "outline":
+ case "success":
+ case "warning":
+ case "danger":
+ return true;
+ default:
+ return false;
+ }
+}
+
+function mapBadgeVariant(variant: BadgeProps["variant"]): KumoBadgeProps["variant"] {
+ if (isLegacyBadgeVariant(variant)) {
+ return variantMap[variant];
+ }
+
+ return variant;
+}
+
+export function Badge({ className, variant = "default", ...props }: BadgeProps): React.ReactElement {
+ const mappedVariant = mapBadgeVariant(variant);
+
+ return ;
}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index 2291a03..61a97f9 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -26,11 +26,21 @@ const sizeMap: Record = {
icon: "base",
};
-export type ButtonProps = Omit & {
+type BaseButtonProps = Omit & {
variant?: ButtonVariant;
- size?: ButtonSize;
};
+type TextButtonProps = BaseButtonProps & {
+ size?: Exclude;
+};
+
+type IconButtonProps = BaseButtonProps & {
+ "aria-label": string;
+ size: "icon";
+};
+
+export type ButtonProps = TextButtonProps | IconButtonProps;
+
export type LinkButtonProps = Omit & {
variant?: ButtonVariant;
size?: Exclude;
@@ -40,17 +50,34 @@ export const Button = React.forwardRef(function
{ className, variant = "default", size = "default", ...props },
ref,
): React.ReactElement {
- const kumoProps = {
- className: cn("teatea-button", `teatea-button-${variant}`, className),
- ref,
- shape: size === "icon" ? "square" : "base",
- size: sizeMap[size],
- variant: variantMap[variant],
- ...props,
- } as KumoButtonProps & React.RefAttributes;
+ if (size === "icon") {
+ const iconAriaLabel = props["aria-label"];
+
+ if (!iconAriaLabel) {
+ throw new Error("Icon buttons require an aria-label.");
+ }
+
+ return (
+
+ );
+ }
return (
-
+
);
});
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
index 45156c8..97b082b 100644
--- a/components/ui/dialog.tsx
+++ b/components/ui/dialog.tsx
@@ -40,7 +40,7 @@ export function Dialog({
>
;
+ onFocus?: React.FocusEventHandler;
onValueChange?: (value: string) => void;
options: SelectOption[];
placeholder?: string;
@@ -25,37 +38,414 @@ type SelectProps = {
value?: string;
};
+const SELECT_GAP = 6;
+const SELECT_MAX_HEIGHT = 288;
+const VIEWPORT_PADDING = 8;
+
+function getEnabledValues(options: SelectOption[]): string[] {
+ return options.filter((option) => option.disabled !== true).map((option) => option.value);
+}
+
+function getInitialValue(options: SelectOption[], defaultValue?: string, placeholder?: string): string {
+ if (defaultValue !== undefined) {
+ return defaultValue;
+ }
+
+ if (placeholder !== undefined) {
+ return "";
+ }
+
+ return getEnabledValues(options)[0] ?? "";
+}
+
+function getAdjacentValue(options: SelectOption[], currentValue: string, direction: 1 | -1): string {
+ const enabledValues = getEnabledValues(options);
+
+ if (enabledValues.length === 0) {
+ return currentValue;
+ }
+
+ const currentIndex = enabledValues.indexOf(currentValue);
+ const nextIndex =
+ currentIndex === -1
+ ? direction === 1
+ ? 0
+ : enabledValues.length - 1
+ : (currentIndex + direction + enabledValues.length) % enabledValues.length;
+
+ return enabledValues[nextIndex] ?? currentValue;
+}
+
+function getBoundaryValue(options: SelectOption[], boundary: "first" | "last", fallbackValue: string): string {
+ const enabledValues = getEnabledValues(options);
+
+ if (enabledValues.length === 0) {
+ return fallbackValue;
+ }
+
+ if (boundary === "first") {
+ return enabledValues[0] ?? fallbackValue;
+ }
+
+ return enabledValues[enabledValues.length - 1] ?? fallbackValue;
+}
+
+function findOption(options: SelectOption[], value: string): SelectOption | undefined {
+ return options.find((option) => option.value === value);
+}
+
export function Select({
+ "aria-describedby": ariaDescribedBy,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledBy,
className,
+ defaultValue,
+ disabled = false,
+ id,
+ label,
+ name,
+ onBlur,
+ onFocus,
onValueChange,
options,
placeholder,
+ required,
value,
- defaultValue,
- ...props
}: SelectProps): React.ReactElement {
+ const generatedId = React.useId().replace(/:/g, "");
+ const controlId = id ?? `teatea-select-${generatedId}`;
+ const labelId = label ? `${controlId}-label` : undefined;
+ const listboxId = `${controlId}-listbox`;
+ const triggerRef = React.useRef(null);
+ const listboxRef = React.useRef(null);
+ const [portalRoot, setPortalRoot] = React.useState(null);
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [position, setPosition] = React.useState(null);
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
+ getInitialValue(options, defaultValue, placeholder),
+ );
+ const isControlled = value !== undefined;
+ const selectedValue = isControlled ? value : uncontrolledValue;
+ const selectedOption = findOption(options, selectedValue);
+ const [highlightedValue, setHighlightedValue] = React.useState(selectedValue);
+ const highlightedIndex = options.findIndex((option) => option.value === highlightedValue);
+ const activeDescendant =
+ isOpen && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined;
+
+ const updatePosition = React.useCallback(() => {
+ const trigger = triggerRef.current;
+
+ if (!trigger || typeof window === "undefined") {
+ return;
+ }
+
+ const rect = trigger.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const availableBelow = viewportHeight - rect.bottom - SELECT_GAP - VIEWPORT_PADDING;
+ const availableAbove = rect.top - SELECT_GAP - VIEWPORT_PADDING;
+ const estimatedHeight = Math.min(SELECT_MAX_HEIGHT, Math.max(120, options.length * 40 + 12));
+ const shouldOpenBelow = availableBelow >= estimatedHeight || availableBelow >= availableAbove;
+ const availableHeight = shouldOpenBelow ? availableBelow : availableAbove;
+ const maxHeight = Math.max(96, Math.min(SELECT_MAX_HEIGHT, availableHeight));
+ const left = Math.min(
+ Math.max(VIEWPORT_PADDING, rect.left),
+ Math.max(VIEWPORT_PADDING, viewportWidth - rect.width - VIEWPORT_PADDING),
+ );
+ const top = shouldOpenBelow
+ ? Math.min(rect.bottom + SELECT_GAP, viewportHeight - maxHeight - VIEWPORT_PADDING)
+ : Math.max(VIEWPORT_PADDING, rect.top - SELECT_GAP - maxHeight);
+
+ setPosition({
+ left,
+ maxHeight,
+ top,
+ width: rect.width,
+ });
+ }, [options.length]);
+
+ const openSelect = React.useCallback(() => {
+ if (disabled || options.length === 0) {
+ return;
+ }
+
+ const nextHighlightedValue =
+ findOption(options, selectedValue)?.disabled === true
+ ? getBoundaryValue(options, "first", selectedValue)
+ : selectedValue;
+
+ setHighlightedValue(nextHighlightedValue);
+ setIsOpen(true);
+ }, [disabled, options, selectedValue]);
+
+ const closeSelect = React.useCallback(() => {
+ setIsOpen(false);
+ }, []);
+
+ const commitValue = React.useCallback(
+ (nextValue: string) => {
+ const nextOption = findOption(options, nextValue);
+
+ if (!nextOption || nextOption.disabled === true) {
+ return;
+ }
+
+ if (!isControlled) {
+ setUncontrolledValue(nextValue);
+ }
+
+ if (nextValue !== selectedValue) {
+ onValueChange?.(nextValue);
+ }
+
+ setHighlightedValue(nextValue);
+ closeSelect();
+ triggerRef.current?.focus();
+ },
+ [closeSelect, isControlled, onValueChange, options, selectedValue],
+ );
+
+ React.useEffect(() => {
+ setPortalRoot(document.body);
+ }, []);
+
+ React.useEffect(() => {
+ if (isControlled) {
+ return;
+ }
+
+ if (findOption(options, uncontrolledValue)) {
+ return;
+ }
+
+ setUncontrolledValue(getInitialValue(options, defaultValue, placeholder));
+ }, [defaultValue, isControlled, options, placeholder, uncontrolledValue]);
+
+ React.useEffect(() => {
+ const trigger = triggerRef.current;
+ const form = trigger?.form;
+
+ if (!form || isControlled) {
+ return;
+ }
+
+ function handleReset(): void {
+ setUncontrolledValue(getInitialValue(options, defaultValue, placeholder));
+ }
+
+ form.addEventListener("reset", handleReset);
+
+ return () => {
+ form.removeEventListener("reset", handleReset);
+ };
+ }, [defaultValue, isControlled, options, placeholder]);
+
+ React.useLayoutEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ updatePosition();
+ }, [isOpen, updatePosition]);
+
+ React.useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ function handlePointerDown(event: MouseEvent | TouchEvent): void {
+ const target = event.target;
+
+ if (!(target instanceof Node)) {
+ return;
+ }
+
+ if (triggerRef.current?.contains(target) || listboxRef.current?.contains(target)) {
+ return;
+ }
+
+ closeSelect();
+ }
+
+ document.addEventListener("mousedown", handlePointerDown);
+ document.addEventListener("touchstart", handlePointerDown);
+ window.addEventListener("resize", updatePosition);
+ window.addEventListener("scroll", updatePosition, true);
+
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ document.removeEventListener("touchstart", handlePointerDown);
+ window.removeEventListener("resize", updatePosition);
+ window.removeEventListener("scroll", updatePosition, true);
+ };
+ }, [closeSelect, isOpen, updatePosition]);
+
+ function handleTriggerClick(): void {
+ if (isOpen) {
+ closeSelect();
+ return;
+ }
+
+ openSelect();
+ }
+
+ function handleKeyDown(event: React.KeyboardEvent): void {
+ switch (event.key) {
+ case "ArrowDown": {
+ event.preventDefault();
+ if (!isOpen) {
+ openSelect();
+ return;
+ }
+ setHighlightedValue((currentValue) => getAdjacentValue(options, currentValue, 1));
+ return;
+ }
+ case "ArrowUp": {
+ event.preventDefault();
+ if (!isOpen) {
+ openSelect();
+ return;
+ }
+ setHighlightedValue((currentValue) => getAdjacentValue(options, currentValue, -1));
+ return;
+ }
+ case "Home": {
+ if (!isOpen) {
+ return;
+ }
+ event.preventDefault();
+ setHighlightedValue((currentValue) => getBoundaryValue(options, "first", currentValue));
+ return;
+ }
+ case "End": {
+ if (!isOpen) {
+ return;
+ }
+ event.preventDefault();
+ setHighlightedValue((currentValue) => getBoundaryValue(options, "last", currentValue));
+ return;
+ }
+ case "Enter":
+ case " ": {
+ event.preventDefault();
+ if (!isOpen) {
+ openSelect();
+ return;
+ }
+ commitValue(highlightedValue);
+ return;
+ }
+ case "Escape": {
+ if (!isOpen) {
+ return;
+ }
+ event.preventDefault();
+ closeSelect();
+ return;
+ }
+ case "Tab": {
+ closeSelect();
+ return;
+ }
+ default:
+ return;
+ }
+ }
+
+ const field = (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+ {selectedOption?.label ?? placeholder ?? ""}
+
+
+
+ {name ? : null}
+
+ );
+
+ const popup =
+ portalRoot && isOpen && position
+ ? createPortal(
+
+ {options.map((option, index) => {
+ const isSelected = option.value === selectedValue;
+ const isHighlighted = option.value === highlightedValue;
+
+ return (
+ commitValue(option.value)}
+ onMouseDown={(event) => event.preventDefault()}
+ onMouseEnter={() => {
+ if (option.disabled !== true) {
+ setHighlightedValue(option.value);
+ }
+ }}
+ role="option"
+ type="button"
+ >
+ {option.label}
+ {isSelected ? : null}
+
+ );
+ })}
+
,
+ portalRoot,
+ )
+ : null;
+
return (
-
- className={cn(
- "teatea-select",
- "min-h-10 w-full rounded-md border border-input bg-card px-3 py-2 text-left text-sm font-medium text-foreground shadow-sm",
- "transition-colors hover:border-primary/45 hover:bg-secondary/35",
- "focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25",
- "disabled:cursor-not-allowed disabled:opacity-50",
- "[&_[data-placeholder]]:text-muted-foreground [&_svg]:text-primary",
- className,
- )}
- defaultValue={defaultValue}
- onValueChange={(nextValue) => onValueChange?.(String(nextValue))}
- placeholder={placeholder}
- value={value}
- {...props}
- >
- {options.map((option) => (
-
- {option.label}
-
- ))}
-
+ <>
+ {field}
+ {popup}
+ >
);
}
diff --git a/modules/auth/components/AuthPanel.tsx b/modules/auth/components/AuthPanel.tsx
index df5c834..eb352c1 100644
--- a/modules/auth/components/AuthPanel.tsx
+++ b/modules/auth/components/AuthPanel.tsx
@@ -25,6 +25,26 @@ const modeCopy: Record>(response: Response): Promise> {
+ const text = await response.text();
+
+ if (!text) {
+ return {
+ success: false,
+ reason: response.ok ? "服务器返回了空响应" : `请求失败:${response.status}`,
+ };
+ }
+
+ try {
+ return JSON.parse(text) as ApiResult;
+ } catch {
+ return {
+ success: false,
+ reason: response.ok ? "服务器返回了无法解析的数据" : `请求失败:${response.status}`,
+ };
+ }
+}
+
function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
@@ -48,11 +68,14 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
fetch("/api/auth/session"),
fetch("/api/organizations"),
]);
- const bootstrap = (await bootstrapResponse.json()) as ApiResult<{ setupRequired: boolean }>;
- const session = (await sessionResponse.json()) as ApiResult<{ account: unknown | null }>;
- const organizationsResult = (await organizationsResponse.json()) as ApiResult<{ organizations: Organization[] }>;
+ const bootstrap = await readApiResult<{ setupRequired: boolean }>(bootstrapResponse);
+ const session = await readApiResult<{ account: unknown | null }>(sessionResponse);
+ const organizationsResult = await readApiResult<{ organizations: Organization[] }>(organizationsResponse);
if (!isMounted || !bootstrap.success || !session.success) {
+ if (isMounted) {
+ setMessage(!bootstrap.success ? bootstrap.reason : session.reason);
+ }
return;
}
@@ -122,7 +145,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
invitationToken,
}),
});
- const result = (await response.json()) as ApiResult<{ account: unknown; pendingApproval?: boolean }>;
+ const result = await readApiResult<{ account: unknown; pendingApproval?: boolean }>(response);
setIsPending(false);
if (!result.success) {
diff --git a/modules/auth/components/SignOutButton.tsx b/modules/auth/components/SignOutButton.tsx
index e0d5c26..2faf9fe 100644
--- a/modules/auth/components/SignOutButton.tsx
+++ b/modules/auth/components/SignOutButton.tsx
@@ -5,8 +5,13 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
-export function SignOutButton(): React.ReactElement {
+type SignOutButtonProps = {
+ className?: string;
+};
+
+export function SignOutButton({ className }: SignOutButtonProps): React.ReactElement {
const router = useRouter();
const [isPending, setIsPending] = useState(false);
@@ -25,6 +30,7 @@ export function SignOutButton(): React.ReactElement {
onClick={handleSignOut}
disabled={isPending}
aria-label="退出登录"
+ className={cn(className)}
>
退出
diff --git a/modules/shared/components/AppShell.tsx b/modules/shared/components/AppShell.tsx
index 009f084..c04d4da 100644
--- a/modules/shared/components/AppShell.tsx
+++ b/modules/shared/components/AppShell.tsx
@@ -192,6 +192,17 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
))}
+
+
+
+
+
+
{account.name}
+
{account.email}
+
+
+
+
@@ -205,12 +216,12 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
工作台
-
-
-
-
{account.name}
-
{account.email}
-
+
+
+
+ {account.name}
+ {account.email}
+