evOo-local/apps/web/src/pages/AccountSettingsOverlay.tsx
cynarAI 2c6b5c3d7b
chore: remove legacy orchestrator, trading and one-off leftovers (#109)
* chore: remove legacy orchestrator, trading and one-off script leftovers

The repository still carried the pre-evOo prototype: a Python orchestrator,
the old HTML "evoo-platform" cockpit (whose test ran in the root unit
suite), an apex-trading package, the hot-reload deploy.sh for it, a
permissions manifest, an architecture note with local file links, agent
handoff notes, a Steve Jobs persona template and about ninety one-off
scripts that probe a developer database with hard-coded local URLs. None
of it is used by the product, CI or the real deploy in ops/evo-deploy.

Delete those files, drop the evoo-platform test glob from the root vitest
config, the Cockpit section from .env.example and the dead biome and
gitignore entries, and update the docs that listed the removed tools.
scripts/start-tmux.sh no longer starts the removed sentinel and resolves
the checkout from its own location instead of a machine-specific path;
scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a
local URL with a username.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(web): drop trading and persona prompts from the conversation shell

The web shell still showed leftovers from the trading prototype: composer
chips with German trading and "Steve Jobs" prompts, trading suggestion
chips under the empty conversation line, a German trading hero for the
empty transcript, and a German "data protection" status panel in the
sidebar. They suggested actions the product does not have and added
persistent chrome.

Remove the composer chips and their helper, the empty-state chips and the
hero (the empty conversation keeps its existing one-line start hint), the
sidebar trading indicator and the data trust panel. The new-bot E2E now
asserts the plain empty state; the sidebar spec no longer expects the
trading status, and the placeholder Steve Jobs spec with no assertions is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* refactor: remove the trading bot status integration

With the sidebar trading indicator gone, the only remaining consumer of
the trading bot status was a banner in General settings that reused the
same component. evOo is a personal assistant; a status proxy and poller
for one external trading service, configured through service-specific
TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in
the core product.

Remove the settings banner and its prop plumbing, the web poller, the
indicator component, the /api/trading-bot/status proxy, the shared core
contract, the env loading and both variables from .env.example, together
with their tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore: remove one-off database scripts and a stale review note

packages/db carried four hand-run scripts outside the package build that
query or seed a developer database with a hard-coded connection string,
one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a
one-time verification note for a workflow that no longer exists and
named private machines and local paths. Nothing references them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore: replace machine-specific paths and addresses with placeholders

ORIGIN.md pinned the intended checkout to an absolute path under a
personal home directory, docs/LOCAL-SETUP.md listed folders on one
developer's machine, and two tests used what looks like a real tailnet
address. Use a home-relative path, drop the machine-specific folder list,
and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10,
so both tests check the same thing as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test(web): drop the trading status from the shell contrast test

main's contrast test now waits for the sidebar trading status before
measuring; this branch removes that status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-04 01:13:52 +02:00

538 lines
17 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 { 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;
};
export function GeneralSettingsPanels({
email,
name,
avatarStyle,
onAvatarStyleChange,
messagingEnabled = false,
onOpenMessaging,
isDeploymentOwner = false,
}: 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">
<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>
);
}