evOo-local/apps/web/src/pages/AccountSettingsOverlay.tsx
Cursor Agent 2f2f0eb21e Move integrations and live trading into settings navigation
Integrations and Live Trading no longer compete as sidebar peers with
the account entry. Settings opens from a dedicated sidebar control;
integrations live in the settings nav, and Live Trading is a discover
banner above the account block in General.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-21 17:10:17 +00:00

601 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Trans, useLingui } from "@lingui/react/macro";
import type { AvatarStyle } from "@rakazo/contracts";
import { BotAvatar, Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web";
import { Activity, ChevronDown } from "lucide-react";
import {
type KeyboardEvent as ReactKeyboardEvent,
type RefObject,
useEffect,
useId,
useRef,
useState,
} from "react";
import { Link } from "react-router-dom";
import { ApprovalRulesSettings } from "../components/ApprovalRulesSettings";
import { SuccessPop } from "../components/ai/primitives";
import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
import { DesktopUpdateSection } from "../components/DesktopUpdates";
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
import { authClient } from "../lib/auth";
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
import {
type AppearancePreference,
getUiAppearancePreference,
setUiAppearance,
} from "../lib/ui-appearance";
import { UI_LOCALE_LABELS, UI_LOCALES, type UiLocale } from "../lib/ui-locale";
export type SettingsGeneralProps = {
email?: string | null;
name: string;
avatarStyle: AvatarStyle;
onAvatarStyleChange: (style: AvatarStyle) => Promise<void>;
messagingEnabled?: boolean;
onOpenMessaging?: () => void;
isDeploymentOwner?: boolean;
tradingMode?: "live" | "paper";
onDiscoverLiveTrading?: () => void;
};
export function LiveTradingBanner({
tradingMode,
onDiscover,
}: {
tradingMode: "live" | "paper";
onDiscover: () => void;
}) {
const isLive = tradingMode === "live";
return (
<section
className={`rounded-xl border px-4 py-4 ${
isLive ? "border-amber-500/30 bg-amber-500/5" : "border-border bg-card/40"
}`}
data-testid="live-trading-banner"
>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<span
className={`grid size-10 shrink-0 place-items-center rounded-lg ${
isLive
? "bg-amber-500/10 text-amber-500 shadow-[0_0_12px_rgba(245,158,11,0.2)]"
: "bg-muted text-muted-foreground"
}`}
>
<Activity
strokeWidth={1.8}
className={`size-5 ${isLive ? "animate-pulse" : ""}`}
/>
</span>
<div className="min-w-0">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Live Trading</Trans>
</h3>
{isLive ? (
<p className="mt-0.5 text-ui-body-sm text-muted-foreground/70">
<Trans>Active</Trans>
</p>
) : null}
</div>
</div>
{!isLive ? (
<Button
type="button"
variant="secondary"
className="shrink-0 rounded-full"
onClick={onDiscover}
>
<Trans>Discover now</Trans>
</Button>
) : null}
</div>
</section>
);
}
export function GeneralSettingsPanels({
email,
name,
avatarStyle,
onAvatarStyleChange,
messagingEnabled = false,
onOpenMessaging,
isDeploymentOwner = false,
tradingMode,
onDiscoverLiveTrading,
}: SettingsGeneralProps) {
const { t } = useLingui();
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
const localeRequestRef = useRef(0);
const [appearance, setAppearance] = useState<AppearancePreference>(() =>
getUiAppearancePreference(),
);
const [avatarPending, setAvatarPending] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
function chooseLocale(next: UiLocale) {
if (next === locale) return;
const requestId = ++localeRequestRef.current;
setLocale(next);
void setUiLocale(next).then((activated) => {
if (requestId !== localeRequestRef.current) return;
setLocale(activated);
});
}
async function chooseAvatarStyle(next: AvatarStyle) {
if (avatarPending || next === avatarStyle) return;
setAvatarPending(true);
setAvatarError(null);
try {
await onAvatarStyleChange(next);
} catch {
setAvatarError(t`Couldn't update avatars`);
} finally {
setAvatarPending(false);
}
}
return (
<div className="space-y-5">
{tradingMode && onDiscoverLiveTrading ? (
<LiveTradingBanner tradingMode={tradingMode} onDiscover={onDiscoverLiveTrading} />
) : null}
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Account</Trans>
</h3>
<p className="mt-3 text-ui-body text-foreground/75">{name}</p>
{email ? <p className="mt-1 text-ui-body-sm text-muted-foreground/70">{email}</p> : null}
</section>
<ChangePasswordSection email={email} />
{messagingEnabled && onOpenMessaging ? (
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Messaging</Trans>
</h3>
<p className="mt-3 text-ui-body-sm text-muted-foreground/70">
<Trans>Chat apps, group channels, and agent connections.</Trans>
</p>
<Button variant="secondary" className="mt-3 rounded-full" onClick={onOpenMessaging}>
<Trans>Manage messaging settings</Trans>
</Button>
</section>
) : null}
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Appearance</Trans>
</h3>
<AppearancePicker
value={appearance}
onChange={(next) => {
setAppearance(next);
setUiAppearance(next);
}}
/>
</section>
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Language</Trans>
</h3>
<UiLocalePicker value={locale} onChange={chooseLocale} />
</section>
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Avatars</Trans>
</h3>
<div className="mt-3 grid grid-cols-2 gap-3">
{(["robot", "organic"] as const).map((style) => (
<Toggle
key={style}
variant="outline"
pressed={style === avatarStyle}
disabled={avatarPending}
onPressedChange={() => void chooseAvatarStyle(style)}
className="h-auto justify-start gap-3 px-3.5 py-3 text-ui-body font-normal"
>
<BotAvatar
color="#D9508A"
identity="avatar-style-preview"
size={32}
variant={style}
/>
<span>{style === "robot" ? <Trans>Robot</Trans> : <Trans>Organic</Trans>}</span>
</Toggle>
))}
</div>
{avatarError ? (
<p role="alert" className="mt-3 text-ui-body-sm text-destructive">
{avatarError}
</p>
) : null}
</section>
{isDeploymentOwner ? (
<Button variant="outline" render={<Link to="/integrations/setup" />}>
<Trans>Server integrations</Trans>
</Button>
) : null}
<details data-testid="advanced-settings" className="group rounded-xl border border-border">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-4 text-ui-body text-foreground/75">
<span>
<span className="block text-ui-body text-foreground">
<Trans>Advanced</Trans>
</span>
<span className="mt-1 block text-ui-body-sm text-muted-foreground/80">
<Trans>Optional controls most people never need</Trans>
</span>
</span>
<span aria-hidden="true" className="transition-transform group-open:rotate-90">
›
</span>
</summary>
<div className="border-t border-border px-4 pb-5">
<ApprovalRulesSettings />
</div>
</details>
</div>
);
}
export function UsageSettingsPanel({
usage,
panelRef,
}: {
usage?: { runs: number; inputTokens: number; outputTokens: number } | null;
panelRef?: RefObject<HTMLDivElement | null>;
}) {
return (
<div
ref={panelRef}
tabIndex={-1}
data-testid="usage-settings"
className="rounded-xl border border-border px-4 py-4 outline-none"
>
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Usage</Trans>
</h3>
{usage ? (
<p className="mt-3 text-ui-body text-foreground/75">
<Trans>
{usage.runs} runs · {usage.inputTokens + usage.outputTokens} tokens
</Trans>
</p>
) : null}
<p className={`text-ui-body-sm text-muted-foreground/80 ${usage ? "mt-2" : "mt-3"}`}>
<Trans>Model spend uses your provider keys.</Trans>
</p>
</div>
);
}
export function ComputerSettingsPanel() {
return (
<div
data-testid="computers-setup-settings"
className="rounded-xl border border-border px-4 py-4"
>
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Computers</Trans>
</h3>
<ComputersUnavailableHint className="mt-3 text-ui-body-sm leading-relaxed text-muted-foreground" />
</div>
);
}
export function UpdatesSettingsPanel({
isDeploymentOwner = false,
}: {
isDeploymentOwner?: boolean;
}) {
return (
<div className="space-y-5">
<DesktopUpdateSection />
<SoftwareUpdateSection isDeploymentOwner={isDeploymentOwner} />
</div>
);
}
function ChangePasswordSection({ email }: { email?: string | null }) {
const { t } = useLingui();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [pending, setPending] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
async function changePassword() {
if (pending) return;
if (newPassword !== confirmation) {
setError(t`Passwords do not match`);
return;
}
setPending(true);
setSaved(false);
setError(null);
try {
const result = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
});
if (result.error) {
setError(result.error.message ?? t`Could not change password`);
return;
}
setCurrentPassword("");
setNewPassword("");
setConfirmation("");
setSaved(true);
} catch {
setError(t`Could not reach the server`);
} finally {
setPending(false);
}
}
return (
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Password</Trans>
</h3>
<div className="mt-3 grid gap-3">
<input
type="text"
name="username"
autoComplete="username"
value={email ?? ""}
readOnly
tabIndex={-1}
aria-hidden="true"
className="sr-only"
/>
<SettingsPasswordInput
label={t`Current password`}
autoComplete="current-password"
value={currentPassword}
onChange={setCurrentPassword}
/>
<SettingsPasswordInput
label={t`New password`}
autoComplete="new-password"
value={newPassword}
onChange={setNewPassword}
/>
<SettingsPasswordInput
label={t`Confirm password`}
autoComplete="new-password"
value={confirmation}
onChange={setConfirmation}
/>
</div>
{error ? (
<p role="alert" className="mt-3 text-ui-body-sm text-destructive">
{error}
</p>
) : null}
<div className="mt-4 flex items-center gap-3">
<Button
className="rounded-full"
disabled={pending || currentPassword.length < 8 || newPassword.length < 8}
onClick={() => void changePassword()}
>
{pending ? <Trans>Changing…</Trans> : <Trans>Change password</Trans>}
</Button>
{saved ? <SuccessPop label={t`Password updated`} /> : null}
</div>
</section>
);
}
function SettingsPasswordInput({
label,
autoComplete,
value,
onChange,
}: {
label: string;
autoComplete: "current-password" | "new-password";
value: string;
onChange: (value: string) => void;
}) {
const id = useId();
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
id={id}
type="password"
autoComplete={autoComplete}
minLength={8}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</Field>
);
}
function AppearancePicker({
value,
onChange,
}: {
value: AppearancePreference;
onChange: (next: AppearancePreference) => void;
}) {
const { t } = useLingui();
const options: { value: AppearancePreference; label: string }[] = [
{ value: "system", label: t`System` },
{ value: "light", label: t`Light` },
{ value: "dark", label: t`Dark` },
];
return (
<fieldset
aria-label={t`Appearance`}
data-testid="ui-appearance-select"
className="mt-3 grid min-w-0 grid-cols-3 gap-1 rounded-lg bg-muted p-1"
>
{options.map((option) => (
<Toggle
key={option.value}
data-testid={`ui-appearance-${option.value}`}
pressed={option.value === value}
onPressedChange={() => onChange(option.value)}
className="text-ui-body-sm aria-pressed:bg-background aria-pressed:shadow-sm"
>
{option.label}
</Toggle>
))}
</fieldset>
);
}
function UiLocalePicker({
value,
onChange,
}: {
value: UiLocale;
onChange: (locale: UiLocale) => void;
}) {
const { t } = useLingui();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const listboxId = useId();
const selectedIndex = Math.max(0, UI_LOCALES.indexOf(value));
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(selectedIndex);
useEffect(() => {
setHighlightedIndex(selectedIndex);
setOpen(false);
}, [selectedIndex, value]);
useEffect(() => {
if (!open) return;
optionRefs.current[highlightedIndex]?.focus();
}, [highlightedIndex, open]);
useEffect(() => {
if (!open) return;
function closeOnOutsidePointer(event: PointerEvent) {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
}
document.addEventListener("pointerdown", closeOnOutsidePointer);
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
}, [open]);
function choose(index: number) {
const next = UI_LOCALES[index];
if (!next) return;
onChange(next);
setOpen(false);
triggerRef.current?.focus();
}
function moveHighlight(index: number) {
setHighlightedIndex((index + UI_LOCALES.length) % UI_LOCALES.length);
}
function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
if (event.key === "Escape" && open) {
event.preventDefault();
event.stopPropagation();
setOpen(false);
return;
}
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
setHighlightedIndex(UI_LOCALES.length - 1);
}
}
function onOptionKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>, index: number) {
if (event.key === "ArrowDown") {
event.preventDefault();
moveHighlight(index + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveHighlight(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
setHighlightedIndex(0);
} else if (event.key === "End") {
event.preventDefault();
setHighlightedIndex(UI_LOCALES.length - 1);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
choose(index);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
}
}
return (
<div ref={rootRef} className="relative mt-3">
<button
ref={triggerRef}
type="button"
role="combobox"
data-testid="ui-locale-select"
aria-label={t`Language`}
aria-controls={listboxId}
aria-expanded={open}
aria-haspopup="listbox"
className="flex h-9 w-full items-center justify-between rounded-lg border border-input bg-transparent px-3 text-start text-sm text-foreground outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
onClick={() => setOpen((current) => !current)}
onKeyDown={onTriggerKeyDown}
>
<span className="min-w-0 truncate">{UI_LOCALE_LABELS[value]}</span>
<span className="ml-3 shrink-0 text-muted-foreground" aria-hidden="true">
<ChevronDown strokeWidth={1.8} className="size-icon-md" />
</span>
</button>
{open ? (
<div
id={listboxId}
role="listbox"
aria-label={t`Language`}
className="rk-scroll absolute left-0 right-0 top-full z-20 mt-1 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
>
{UI_LOCALES.map((code, index) => (
<button
key={code}
ref={(element) => {
optionRefs.current[index] = element;
}}
type="button"
role="option"
aria-selected={code === value}
tabIndex={index === highlightedIndex ? 0 : -1}
className={`w-full rounded-md px-2 py-1.5 text-start text-sm outline-none hover:bg-accent focus-visible:bg-accent ${
code === value ? "bg-accent" : ""
}`}
onClick={() => choose(index)}
onKeyDown={(event) => onOptionKeyDown(event, index)}
>
{UI_LOCALE_LABELS[code]}
</button>
))}
</div>
) : null}
</div>
);
}