84e2a9f0ac
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
554 lines
21 KiB
TypeScript
554 lines
21 KiB
TypeScript
/**
|
|
* @file AiProxyConfig.tsx
|
|
* @description The **AI Proxy** page of Settings — CRUD for the named proxy
|
|
* endpoints (9Router, OpenRouter, a self-hosted gateway) a session can run on
|
|
* instead of the vendor's own API.
|
|
*
|
|
* A NEW SESSION chooses its proxy (the workspace home's prompt box), not here. An edit here reaches the sessions already on a profile
|
|
* (the server rewrites the file each reads), and a delete removes that file.
|
|
*
|
|
* The built-in `native` row is rendered read-only: it is the vendor's own API,
|
|
* and it exists so that "go back to native" is the same operation as "use a
|
|
* proxy" rather than a special case. It has no model of its own (the
|
|
* session's model picker picks), so its Model cell reads "—".
|
|
*
|
|
* An API key is write-only here by design — the server never returns one, so
|
|
* the form shows whether a key is saved, never the key.
|
|
*
|
|
* The form is a real dialog (focus trap, Escape) and shows a failed save
|
|
* inside itself, not behind its scrim; a remove asks through `confirmDialog`.
|
|
*
|
|
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
|
|
* as a menu; the buttons stay.
|
|
*
|
|
* In server mode a profile is SHARED (an admin's, usable by everyone) or
|
|
* PERSONAL (the signed-in user's own, badged "Personal"). A member edits only
|
|
* their own and every profile they add is personal; an admin chooses when
|
|
* adding; an analyst only reads.
|
|
*
|
|
* **Test** asks the endpoint itself, once as Claude Code would
|
|
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
|
* "Saved" and "works" are different claims, and the gap between them is only
|
|
* ever discovered inside a session otherwise, where the error surfaces as a
|
|
* broken CLI rather than a bad URL.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
|
|
import {
|
|
api,
|
|
type AiProxy,
|
|
type AiProxyInput,
|
|
type AiProxyModels,
|
|
type AiProxyProbe,
|
|
} from "../lib/api";
|
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
|
import { confirmDialog } from "../lib/confirm";
|
|
import { copyText } from "../lib/clipboard";
|
|
import { useDialog } from "../hooks/useDialog";
|
|
import { useAuth } from "../components/AuthGate";
|
|
|
|
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
|
|
|
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
|
|
apiKey: string;
|
|
behavesAs: string;
|
|
models: Partial<AiProxyModels>;
|
|
scope: "shared" | "personal";
|
|
};
|
|
|
|
const emptyForm = (): FormState => ({
|
|
label: "",
|
|
baseUrl: "",
|
|
apiKey: "",
|
|
model: "",
|
|
models: {},
|
|
behavesAs: "",
|
|
scope: "shared",
|
|
});
|
|
|
|
export function AiProxyConfig() {
|
|
const { t } = useTranslation("settings");
|
|
// Server mode's signed-in user; null in local mode, where the host owns
|
|
// every (shared) profile.
|
|
const { state: auth } = useAuth();
|
|
const user = auth.mode === "server" ? auth.user : null;
|
|
const isAdmin = !user || user.role === "admin";
|
|
const canWrite = (item: AiProxy) => !item.builtin && (item.scope === "personal" || isAdmin);
|
|
const [items, setItems] = useState<AiProxy[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [editing, setEditing] = useState<AiProxy | null>(null);
|
|
const [form, setForm] = useState(emptyForm());
|
|
const [open, setOpen] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
// A failed save, shown INSIDE the dialog (the page-level `error` sits behind it).
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
useDialog(open, dialogRef, () => setOpen(false));
|
|
// The proxy's own verdict on the form as it currently stands, one line per
|
|
// CLI. Cleared on every field change: a green "works" left sitting above an
|
|
// edited base URL is a claim about a configuration that no longer exists.
|
|
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }[] | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const ctx = useContextMenu<AiProxy>();
|
|
// A menu item: close the menu, then act on the row it was opened on.
|
|
const pick = (fn: (item: AiProxy) => unknown) => () => {
|
|
const item = ctx.menu?.item;
|
|
ctx.close();
|
|
if (item) fn(item);
|
|
};
|
|
// Distinct from "no profiles": an empty table rendered before the first fetch
|
|
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
|
// distinction for the same reason.
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// No dependencies, deliberately. `t` is NOT one: react-i18next returns a
|
|
// stable `t`, but nothing in this component's contract guarantees that, and
|
|
// the moment it changes identity per render this callback does too — which
|
|
// re-fires the effect below, which calls load(), which renders again. That
|
|
// loop also clears `error` on every pass, so a failed save would flash and
|
|
// vanish. Keeping the message untranslated is the cheaper half of the trade.
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setItems((await api.aiProxies.list()).items);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
// Any edit to the form invalidates the last verdict — see `testResult`.
|
|
useEffect(() => setTestResult(null), [form]);
|
|
|
|
const startAdd = () => {
|
|
setEditing(null);
|
|
setForm(emptyForm());
|
|
setFormError(null);
|
|
setOpen(true);
|
|
};
|
|
|
|
const startEdit = (item: AiProxy) => {
|
|
setEditing(item);
|
|
// No apiKey: the server never sent one. Blank means "keep what is stored".
|
|
setForm({
|
|
label: item.label,
|
|
baseUrl: item.baseUrl,
|
|
apiKey: "",
|
|
model: item.model,
|
|
models: { ...item.models },
|
|
behavesAs: item.behavesAs ?? "",
|
|
scope: item.scope ?? "shared",
|
|
});
|
|
setFormError(null);
|
|
setOpen(true);
|
|
};
|
|
|
|
/**
|
|
* Ask the endpoint itself. `name` is sent when editing so a profile whose key
|
|
* the form cannot show is still testable — the server only honours it while
|
|
* the base URL is unchanged, so a retyped endpoint needs a retyped key.
|
|
*/
|
|
const test = async () => {
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const result = await api.aiProxies.test({
|
|
baseUrl: form.baseUrl.trim(),
|
|
model: form.model.trim(),
|
|
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
|
|
...(editing ? { name: editing.name } : {}),
|
|
});
|
|
const line = (cli: string, probe: AiProxyProbe) => ({
|
|
ok: probe.ok,
|
|
text: `${cli}: ${
|
|
probe.ok
|
|
? t("aiProxy.testOk", { model: probe.message })
|
|
: t("aiProxy.testFailed", { status: probe.status ?? "—", message: probe.message })
|
|
}`,
|
|
});
|
|
setTestResult([line("Claude Code", result.claude), line("Codex", result.codex)]);
|
|
} catch (e) {
|
|
setTestResult([{ ok: false, text: e instanceof Error ? e.message : String(e) }]);
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
const submit = async () => {
|
|
setBusy(true);
|
|
setFormError(null);
|
|
try {
|
|
// Send only the tiers that differ from the headline model, so the server
|
|
// keeps filling the rest from it — that is what makes one symbolic name
|
|
// enough for a proxy that wants the same value everywhere.
|
|
const models: Partial<AiProxyModels> = {};
|
|
for (const tier of TIERS) {
|
|
const value = (form.models[tier] || "").trim();
|
|
if (value && value !== form.model.trim()) models[tier] = value;
|
|
}
|
|
const body: AiProxyInput = {
|
|
label: form.label.trim(),
|
|
baseUrl: form.baseUrl.trim(),
|
|
model: form.model.trim(),
|
|
models,
|
|
behavesAs: form.behavesAs.trim(),
|
|
};
|
|
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
|
if (user && isAdmin && !editing) body.scope = form.scope;
|
|
|
|
if (editing) await api.aiProxies.update(editing.name, body);
|
|
else await api.aiProxies.create(body);
|
|
|
|
setOpen(false);
|
|
await load();
|
|
} catch (e) {
|
|
setFormError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const remove = async (item: AiProxy) => {
|
|
if (
|
|
!(await confirmDialog({
|
|
title: t("aiProxy.removeConfirm", { label: item.label }),
|
|
confirmLabel: t("aiProxy.remove"),
|
|
}))
|
|
)
|
|
return;
|
|
setError(null);
|
|
try {
|
|
await api.aiProxies.remove(item.name);
|
|
await load();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
{/* The page header above already names the section. */}
|
|
<p className="max-w-2xl text-xs text-fg-muted">{t("aiProxy.description")}</p>
|
|
<button
|
|
type="button"
|
|
onClick={startAdd}
|
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
{t("aiProxy.add")}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="overflow-hidden rounded-lg border border-border">
|
|
<table className="w-full text-left text-xs">
|
|
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
|
<tr>
|
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colLabel")}</th>
|
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colBaseUrl")}</th>
|
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colModel")}</th>
|
|
<th className="px-3 py-2 font-medium">{t("aiProxy.colKey")}</th>
|
|
<th className="px-3 py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item) => (
|
|
<tr
|
|
key={item.name}
|
|
onContextMenu={canWrite(item) ? (e) => ctx.open(e, item) : undefined}
|
|
className="border-t border-border"
|
|
>
|
|
<td className="px-3 py-2">
|
|
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
|
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
|
{item.label}
|
|
{item.scope === "personal" && (
|
|
<span className="rounded bg-accent/15 px-1.5 py-px text-[10px] font-normal text-accent">
|
|
{t("aiProxy.personal")}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{item.builtin && (
|
|
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
|
{t("aiProxy.builtinHint")}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
|
{item.baseUrl || "—"}
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
|
{item.model || "—"}
|
|
</td>
|
|
<td className="px-3 py-2 text-fg-secondary">
|
|
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
{canWrite(item) && (
|
|
<span className="inline-flex gap-1">
|
|
<button
|
|
type="button"
|
|
aria-label={`${t("aiProxy.edit")} ${item.label}`}
|
|
onClick={() => startEdit(item)}
|
|
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={`${t("aiProxy.remove")} ${item.label}`}
|
|
onClick={() => remove(item)}
|
|
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{(loading || items.length <= 1) && (
|
|
<tr className="border-t border-border">
|
|
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
|
|
{loading ? t("aiProxy.loading") : t("aiProxy.empty")}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{ctx.menu && (
|
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-proxy-menu">
|
|
<MenuItem onSelect={pick(startEdit)}>{t("aiProxy.edit")}</MenuItem>
|
|
{ctx.menu.item.baseUrl && (
|
|
<MenuItem onSelect={pick((item) => void copyText(item.baseUrl))}>
|
|
{t("rowMenu.copyUrl")}
|
|
</MenuItem>
|
|
)}
|
|
<MenuSeparator />
|
|
<MenuItem danger onSelect={pick((item) => void remove(item))}>
|
|
{t("aiProxy.remove")}
|
|
</MenuItem>
|
|
</ContextMenu>
|
|
)}
|
|
|
|
{open && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="proxy-form-title"
|
|
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
|
>
|
|
<h3 id="proxy-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
|
|
{editing ? t("aiProxy.edit") : t("aiProxy.add")}
|
|
</h3>
|
|
|
|
<div className="space-y-3">
|
|
<Field
|
|
id="proxy-label"
|
|
label={t("aiProxy.fieldLabel")}
|
|
hint={t("aiProxy.fieldLabelHint")}
|
|
value={form.label}
|
|
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
|
/>
|
|
{user && !editing && (
|
|
<div>
|
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="proxy-scope">
|
|
{t("aiProxy.fieldScope")}
|
|
</label>
|
|
{isAdmin ? (
|
|
<select
|
|
id="proxy-scope"
|
|
value={form.scope}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, scope: e.target.value as FormState["scope"] }))
|
|
}
|
|
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
|
>
|
|
<option value="shared">{t("aiProxy.scopeShared")}</option>
|
|
<option value="personal">{t("aiProxy.scopePersonal")}</option>
|
|
</select>
|
|
) : (
|
|
<p id="proxy-scope" className="text-xs text-fg-primary">
|
|
{t("aiProxy.scopePersonal")}
|
|
</p>
|
|
)}
|
|
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.fieldScopeHint")}</p>
|
|
</div>
|
|
)}
|
|
<Field
|
|
id="proxy-base-url"
|
|
label={t("aiProxy.fieldBaseUrl")}
|
|
hint={t("aiProxy.fieldBaseUrlHint")}
|
|
value={form.baseUrl}
|
|
mono
|
|
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
|
|
/>
|
|
<Field
|
|
id="proxy-api-key"
|
|
label={t("aiProxy.fieldApiKey")}
|
|
hint={
|
|
editing && editing.hasKey
|
|
? t("aiProxy.fieldApiKeyKeep")
|
|
: t("aiProxy.fieldApiKeyHint")
|
|
}
|
|
value={form.apiKey}
|
|
type="password"
|
|
mono
|
|
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
|
/>
|
|
<Field
|
|
id="proxy-model"
|
|
label={t("aiProxy.fieldModel")}
|
|
hint={t("aiProxy.fieldModelHint")}
|
|
value={form.model}
|
|
mono
|
|
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
|
/>
|
|
<Field
|
|
id="proxy-behaves-as"
|
|
label={t("aiProxy.fieldBehavesAs")}
|
|
hint={t("aiProxy.fieldBehavesAsHint")}
|
|
value={form.behavesAs}
|
|
mono
|
|
placeholder="claude-opus-5-5"
|
|
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
|
|
/>
|
|
|
|
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
|
<summary className="cursor-pointer text-xs text-fg-secondary">
|
|
{t("aiProxy.perTier")}
|
|
</summary>
|
|
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.perTierHint")}</p>
|
|
<div className="mt-2 space-y-2">
|
|
{TIERS.map((tier) => (
|
|
<Field
|
|
key={tier}
|
|
id={`proxy-model-${tier}`}
|
|
label={t(`aiProxy.tier.${tier}`)}
|
|
value={form.models[tier] || ""}
|
|
mono
|
|
placeholder={form.model}
|
|
onChange={(v) =>
|
|
setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } }))
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
{formError && (
|
|
<p
|
|
role="alert"
|
|
className="mt-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger"
|
|
>
|
|
{formError}
|
|
</p>
|
|
)}
|
|
|
|
{testResult?.map((r) => (
|
|
<p
|
|
key={r.text}
|
|
data-testid="proxy-test-result"
|
|
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
|
|
r.ok
|
|
? "border-status-success/40 bg-status-success/10 text-status-success"
|
|
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
|
|
}`}
|
|
>
|
|
{r.text}
|
|
</p>
|
|
))}
|
|
|
|
<div className="mt-4 flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
data-testid="proxy-test"
|
|
onClick={test}
|
|
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
|
|
className="mr-auto rounded-md border border-border-light px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
|
|
>
|
|
{testing ? t("aiProxy.testing") : t("aiProxy.test")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
|
|
>
|
|
{t("aiProxy.cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={submit}
|
|
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
|
|
className="btn-primary px-3 py-1.5 text-xs"
|
|
>
|
|
{t("aiProxy.save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
id,
|
|
label,
|
|
hint,
|
|
value,
|
|
onChange,
|
|
mono,
|
|
type = "text",
|
|
placeholder,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
hint?: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
mono?: boolean;
|
|
type?: string;
|
|
placeholder?: string;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={id}>
|
|
{label}
|
|
</label>
|
|
<input
|
|
id={id}
|
|
type={type}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none ${
|
|
mono ? "font-mono" : ""
|
|
}`}
|
|
/>
|
|
{hint && <p className="mt-1 text-[10px] text-fg-muted">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|