Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions frontend/workspace/src/components/settings/Credentials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// secret is write-only: values are never returned after saving.
import { type Component, type JSX, For, Show, createMemo, createSignal, onMount } from "solid-js"
import { Button } from "@synsci/ui/button"
import type { Provider } from "@synsci/sdk/v2/client"
import { useGlobalSDK } from "@/context/global-sdk"
import { usePlatform } from "@/context/platform"
import { useProviders } from "@/hooks/use-providers"
Expand Down Expand Up @@ -34,6 +35,27 @@ const PROVIDER_LABEL: Record<string, string> = {
}
const BYOK_PROVIDERS = ["anthropic", "openai", "google", "openrouter", "groq", "mistral", "xai", "deepseek"] as const

// Where a connected provider's credential actually lives. Only "api" keys sit in
// the local auth store — the others reappear after a remove, so remove is gated.
const SOURCE_INFO: Record<Provider["source"], { label: string; removable: boolean; title: string }> = {
api: { label: "local", removable: true, title: "API key stored in the local auth store on this machine" },
env: {
label: "env",
removable: false,
title: "API key from an environment variable or dashboard sync — unset it where it is defined to remove it",
},
config: {
label: "config",
removable: false,
title: "API key set in openscience.json — edit the config file to remove it",
},
custom: {
label: "custom",
removable: false,
title: "Custom provider defined in openscience.json — edit the config file to remove it",
},
}

export const Credentials: Component = () => {
const sdk = useGlobalSDK()
const platform = usePlatform()
Expand Down Expand Up @@ -129,6 +151,9 @@ export const Credentials: Component = () => {
const [keyValue, setKeyValue] = createSignal("")
const [savingKey, setSavingKey] = createSignal(false)
const connectedProviders = createMemo(() => providers.connected().filter((p) => p.id !== "synsci"))
// The list endpoint's generated type omits `source`, but the payload carries it
// for every connected provider (see Provider in @synsci/sdk/v2/client).
const sourceInfo = (p: { id: string }) => SOURCE_INFO[(p as { source?: Provider["source"] }).source ?? "api"]
const saveKey = async () => {
if (savingKey()) return
const key = keyValue().trim()
Expand Down Expand Up @@ -372,10 +397,32 @@ export const Credentials: Component = () => {
<div class="flex items-center gap-2.5 min-w-0">
<StatusDot status="active" />
<span class="text-13-regular text-text-strong truncate">{PROVIDER_LABEL[p.id] ?? p.id}</span>
<span
class="flex-shrink-0 px-2 py-0.5 rounded-full text-11-regular border"
style={{
color: "var(--color-text-faint)",
"border-color": "var(--color-border)",
background: "transparent",
}}
title={sourceInfo(p).title}
>
{sourceInfo(p).label}
</span>
</div>
<Button size="small" variant="secondary" onClick={() => void removeKey(p.id)}>
remove
</Button>
<Show
when={sourceInfo(p).removable}
fallback={
<span title={sourceInfo(p).title}>
<Button size="small" variant="secondary" disabled>
remove
</Button>
</span>
}
>
<Button size="small" variant="secondary" onClick={() => void removeKey(p.id)}>
remove
</Button>
</Show>
</div>
)}
</For>
Expand Down
22 changes: 20 additions & 2 deletions frontend/workspace/src/components/settings/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
import { Component, Show, createMemo, createSignal, onMount, type JSX } from "solid-js"
import { Button } from "@synsci/ui/button"
import { Select } from "@synsci/ui/select"
import { showToast } from "@synsci/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useModels } from "@/context/models"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { URLS } from "@/config/urls"
import { FONT_SANS } from "@/styles/tokens"
import { FONT_CODE, FONT_SANS } from "@/styles/tokens"
import { AppearanceSections } from "../settings-general"
import { settingsApi } from "./api"

Expand Down Expand Up @@ -85,8 +86,12 @@ export default function General() {
if (!window.confirm("Disconnect this local server from OpenScience?")) return
setBusy(true)
try {
await sdk.client.account.logout()
const res = await sdk.client.account.logout()
if (res.error)
throw new Error(typeof res.error === "string" ? res.error : "The server could not clear the session")
setAccount({ session: false })
} catch (err) {
showToast({ variant: "error", title: "Sign out failed", description: message(err) })
} finally {
setBusy(false)
}
Expand Down Expand Up @@ -166,6 +171,15 @@ export default function General() {
sign out
</Button>
</Row>
<Show when={account()?.session === false}>
<div class="px-4 py-3">
<p class="text-12-regular text-text-weak">
Signed out — run{" "}
<code style={{ "font-family": FONT_CODE, "font-size": "11px" }}>openscience connect login</code> in a
terminal to reconnect this machine.
</p>
</div>
</Show>
</div>
</Section>

Expand Down Expand Up @@ -238,6 +252,10 @@ export default function General() {
)
}

function message(err: unknown) {
return err instanceof Error ? err.message : String(err)
}

const Section: Component<{ title: string; description?: string; children: JSX.Element }> = (props) => (
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-0.5">
Expand Down
65 changes: 51 additions & 14 deletions frontend/workspace/src/components/settings/Usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// the actual cost + tokens recorded on assistant messages across sessions.
// • Extra usage budget → /settings/preferences (real JSON store).
// • Buy credits → opens the Atlas top-up / checkout (URLS.dashboardCli).
import { Component, For, Show, createMemo, createSignal, onMount, type JSX } from "solid-js"
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { Button } from "@synsci/ui/button"
import { useGlobalSDK } from "@/context/global-sdk"
Expand Down Expand Up @@ -78,7 +78,14 @@ export default function Usage() {
setBudgetDraft(prefs.extra_budget_usd ? String(prefs.extra_budget_usd) : "")
} catch {}
}
onMount(() => void load())
// Refetch when the window regains focus so a dashboard top-up (buy credits
// opens a new tab) shows up as soon as the user comes back.
const focus = () => void load()
onMount(() => {
void load()
window.addEventListener("focus", focus)
})
onCleanup(() => window.removeEventListener("focus", focus))

const saveBudget = async () => {
const value = Math.max(0, Number(budgetDraft()) || 0)
Expand All @@ -94,6 +101,10 @@ export default function Usage() {
}
}

// Distinguish "no data yet" and "signed out" from real account values — the
// panel must not present "Free"/"byok" defaults as if they came from Atlas.
const loading = () => account() === undefined
const signedOut = () => account()?.session === false
const plan = () => (account()?.user?.subscription_plan as string | undefined) ?? "Free"
const balance = () => account()?.balance_usd
const managed = () => account()?.billing_mode?.mode === "managed"
Expand Down Expand Up @@ -138,31 +149,57 @@ export default function Usage() {
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-4 border-b border-border-weak-base">
<div class="flex flex-col gap-0.5">
<span class="text-12-regular text-text-weak">Plan</span>
<span class="text-16-medium text-text-strong capitalize">{plan()}</span>
<Show
when={!loading() && !signedOut()}
fallback={<span class="text-16-medium text-text-weak">{loading() ? "…" : "—"}</span>}
>
<span class="text-16-medium text-text-strong capitalize">{plan()}</span>
</Show>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-12-regular text-text-weak">Wallet balance</span>
<span class="text-16-medium text-text-strong">
{typeof balance() === "number" && balance()! >= 0 ? money(balance()!) : "—"}
</span>
<Show
when={!loading() && !signedOut() && typeof balance() === "number" && balance()! >= 0}
fallback={<span class="text-16-medium text-text-weak">{loading() ? "…" : "—"}</span>}
>
<span class="text-16-medium text-text-strong">{money(balance()!)}</span>
</Show>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-12-regular text-text-weak">Billing</span>
<span class="text-13-medium text-text-strong capitalize">
{account()?.billing_mode?.mode ?? "byok"}
</span>
<Show
when={!loading() && !signedOut()}
fallback={<span class="text-13-medium text-text-weak">{loading() ? "…" : "—"}</span>}
>
<span class="text-13-medium text-text-strong capitalize">
{account()?.billing_mode?.mode ?? "byok"}
</span>
</Show>
</div>
<Button size="small" variant="primary" onClick={() => platform.openLink(URLS.dashboardCli)}>
buy credits
</Button>
</div>
<div class="px-4 py-3">
<p class="text-12-regular text-text-weak">
<Show
when={managed()}
fallback="You're on BYOK — provider calls are billed directly by each provider. Switch to managed mode (Credentials) to bill from this wallet."
>
Managed calls debit this wallet. Top up any time — credits never expire.
<Show when={!loading()} fallback="Checking your Atlas account…">
<Show
when={!signedOut()}
fallback={
<>
Not connected — run{" "}
<code style={{ "font-family": FONT_CODE, "font-size": "11px" }}>openscience connect login</code>{" "}
in a terminal to see your plan and wallet.
</>
}
>
<Show
when={managed()}
fallback="You're on BYOK — provider calls are billed directly by each provider. Switch to managed mode (Credentials) to bill from this wallet."
>
Managed calls debit this wallet. Top up any time — credits never expire.
</Show>
</Show>
</Show>
</p>
</div>
Expand Down
Loading