feat(pool): one board of pools with split/merge by drag; plan names from tier and seat

- The Users page account pool is one board: a column per pool holding its
  Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its
  members. Accounts sit in one pool; drag moves them. Drop on New pool to
  split one off; drag a pool's only account into another pool to merge
  (members follow, the emptied pool is deleted on Save). One password Save.
- Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and
  seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said
  'pro' after an upgrade and 'team' for both seats. Shown on profiles, the
  status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
This commit is contained in:
2026-10-28 11:47:19 +07:00
parent 9d07f64154
commit 26aabd6660
18 changed files with 1638 additions and 642 deletions
+152 -285
View File
@@ -1,15 +1,15 @@
/**
* @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`), one
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
* Codex and Cursor have a pool — the accounts every user's new sessions of that
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
* Oh My Pi has no pool but can share the host's login (the same switch as
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
* other installed tool says that its users sign in themselves. A tab shows
* how many accounts its pool holds, and an empty pool says so instead of
* vanishing. An admin adds an account to the open tab's tool — for Claude a
* @description The Users page's account pool card (`GET /api/accounts`).
* Claude Code, Codex and Cursor accounts live on the POOLS board
* (`PoolGroups`): one column per pool with its accounts of every tool — each
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
* tools with no pool get a tab each below it: Oh My Pi can share the host's
* login (the same switch as Settings → AI tools,
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
* says that its users sign in themselves. An admin adds an account from the
* header's Add account, picking its tool — for Claude a
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
* `claude setup-token` token; for Codex a saved Codex profile
@@ -17,11 +17,11 @@
* Cursor API key — or removes one, each time with their own password again;
* an analyst only reads. The secret only goes IN: the server never sends it
* back. An account's Edit (pencil) picks the users who run on it
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
* or "no group"): their sessions skip the balancer, and a
* Claude login goes into their home at once — no restart. A profile's
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
* which opens that tool's tab and Add on that profile; the header links back
* to the profiles.
* which opens Add on that profile; the header links back to the profiles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Link, useSearchParams } from "react-router-dom";
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
import {
api,
type AccountPin,
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsSwitchRow } from "../settings/SettingsRow";
import { PoolGroups, groupsOf } from "./PoolGroups";
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
import { MemberPicker } from "./MemberPicker";
type Row = {
tool: PoolTool;
@@ -69,6 +69,8 @@ type Row = {
full: boolean;
/** The 5-hour and weekly windows, as the account last reported them. */
windows: QuotaWindow[];
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
plan?: string | null;
};
/** One quota window of an account: % used and when it rolls over. */
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
/** Fixed tabs, always shown; any other installed tool follows them. */
const FIXED_TABS: { id: string; label: string }[] = [
{ id: "claude", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "cursor", label: "Cursor CLI" },
{ id: "omp", label: "Oh My Pi" },
];
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
const METERED = new Set<PoolTool>(["claude", "codex"]);
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const profile = params.get("poolProfile");
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
})();
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
const [tab, setTab] = useState<string>("omp");
// What Add opens on: a tool, and a saved profile to share into its pool.
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
isAdmin && preset ? preset : null
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const tabs = [
...FIXED_TABS,
...aiTools.tools
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
.map((x) => ({ id: x.id, label: x.label })),
];
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
const badge = (id: string): string | null => {
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
return null;
};
const badge = (id: string): string | null =>
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
const rowOf = (tool: PoolTool, name: string) =>
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
return (
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
{t("pool.profilesLink")}
</Link>
{isAdmin && (
<label className="relative">
<span className="btn-primary pointer-events-none text-xs">
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
</span>
<select
className="absolute inset-0 cursor-pointer opacity-0"
value=""
onChange={(e) => {
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
}}
aria-label={t("pool.addAccount")}
data-testid="pool-add-account"
>
<option value="">{t("pool.addAccount")}</option>
{POOL_TOOL_LABELS.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</label>
)}
<button
type="button"
onClick={refresh}
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{pool && (
<PoolGroups
pool={pool}
isAdmin={isAdmin}
onChanged={() => void load()}
renderAccount={(tool, name) => {
const r = rowOf(tool, name);
return r ? (
<AccountCard
row={r}
pool={pool}
isAdmin={isAdmin}
onEdit={() => setEditing(r)}
onRemove={() => setRemoving(r)}
/>
) : null;
}}
/>
)}
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.otherTools")}
</h3>
<div
role="tablist"
aria-label={t("pool.title")}
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
aria-label={t("pool.otherTools")}
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
>
{tabs.map((x) => {
const selected = x.id === current.id;
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
);
})}
</div>
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
{isPoolTool(current.id) ? (
<PoolPanel
tool={current.id}
label={current.label}
pool={pool}
isAdmin={isAdmin}
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
onEdit={setEditing}
onRemove={setRemoving}
/>
) : current.id === "omp" ? (
{current.id === "omp" ? (
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
) : (
<NoPoolPanel id={current.id} label={current.label} />
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
{adding && (
<AddAccountDialog
tool={adding.tool}
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
onClose={closeAdd}
onDone={(name) => {
@@ -351,89 +380,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
);
}
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
function PoolPanel({
tool,
label,
pool,
isAdmin,
installed,
onAdd,
onEdit,
onRemove,
}: {
tool: PoolTool;
label: string;
pool: AccountPool | null;
isAdmin: boolean;
installed: boolean;
/** Add an account; `profile` = share that saved profile into the pool. */
onAdd: (profile?: string) => void;
onEdit: (r: Row) => void;
onRemove: (r: Row) => void;
}) {
const { t } = useTranslation("users");
const rows = pool ? rowsOf(pool, tool) : [];
const grouped = (pool?.groups ?? []).length > 0;
const addButton = isAdmin && (
<button type="button" onClick={() => onAdd()} className="btn-primary">
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
</button>
);
return (
<>
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
{t(`pool.about.${tool}`)}
{!installed && (
<span className="ml-1 text-status-warning">
{t("pool.notInstalled", { tool: label })}
</span>
)}
</p>
{rows.length > 0 && addButton}
</div>
{pool && rows.length === 0 && (
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
{addButton}
</div>
)}
{rows.length > 0 && (
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((r) => (
<AccountCard
key={r.name}
row={r}
pool={pool!}
grouped={grouped}
isAdmin={isAdmin}
onEdit={() => onEdit(r)}
onRemove={() => onRemove(r)}
/>
))}
</div>
)}
{isAdmin && SOURCES[tool].includes("profile") && (
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
)}
</>
);
}
/** One pool account: who it is, how loaded, which groups and users run on it. */
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
function AccountCard({
row: r,
pool,
grouped,
isAdmin,
onEdit,
onRemove,
}: {
row: Row;
pool: AccountPool;
grouped: boolean;
isAdmin: boolean;
onEdit: () => void;
onRemove: () => void;
@@ -443,39 +399,52 @@ function AccountCard({
const users = pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username);
const inGroups = groupsOf(pool, r.tool, r.name);
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
return (
<div
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
}`}
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
data-testid="pool-account"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
{r.plan && (
<span
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
data-testid="pool-account-plan"
>
{r.plan}
</span>
)}
</div>
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
</div>
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
{t(`pool.kindShort.${r.kind}`)}
</span>
{isAdmin && (
<div className="-mr-1 -mt-1 flex shrink-0">
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={onEdit}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
className="btn-icon p-1"
>
<Pencil className="h-4 w-4" />
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon"
className="btn-icon p-1 hover:text-status-danger"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
@@ -488,68 +457,47 @@ function AccountCard({
</span>
)}
{r.refreshError && !r.expired && (
<span
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={r.refreshError}
>
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
{t("pool.refreshFailed")}
</span>
)}
{r.full && (
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
{t("pool.full")}
</span>
)}
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
</div>
)}
<div className="flex items-center gap-3 text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{metered && (
<div className="space-y-1" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1 text-fg-secondary">
<span
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
aria-hidden
/>
{t("pool.liveCount", { count: r.livePanes })}
</span>
{metered && (
<>
<span aria-hidden>·</span>
<span>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
<span className="basis-full">
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</span>
</>
)}
</div>
{metered && (
<div className="space-y-1.5" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
{metered && (
<div className="text-[11px] tabular-nums text-fg-muted">
<div>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</div>
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
</div>
)}
{grouped && (
<div className="flex flex-wrap items-center gap-1 text-[11px]">
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
{inGroups.length === 0 ? (
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
) : (
inGroups.map((g) => (
<span key={g.id} className="badge py-0 text-[10px]">
{g.name}
</span>
))
)}
</div>
)}
{users.length > 0 && (
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<UserRound className="h-3 w-3" aria-hidden />
{t("pool.pinned", { users: users.join(", ") })}
</div>
)}
<div className="mt-auto text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
</div>
);
}
@@ -568,7 +516,7 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full ${
className={`block h-full transition-[width] duration-500 ${
left == null
? ""
: left === 0
@@ -596,72 +544,6 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
);
}
/**
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
* leaves the switcher, since a login's refresh token may have one holder.
*/
function SavedProfiles({
tool,
pool,
onShare,
}: {
tool: PoolTool;
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
pool: AccountPool | null;
onShare: (name: string) => void;
}) {
const { t } = useTranslation("users");
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
useEffect(() => {
api.accountPool
.profiles(tool)
.then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([]));
}, [tool, pool]);
return (
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
<div className="flex flex-wrap items-center gap-2">
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.savedProfiles.title")}
</h3>
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
{t("pool.profilesLink")}
</Link>
</div>
{profiles !== null && profiles.length === 0 && (
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
)}
{profiles && profiles.length > 0 && (
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => (
<div
key={p.name}
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
>
<div className="flex-1 min-w-0">
<div className="truncate text-sm text-fg-primary">{p.label}</div>
<div className="truncate text-[11px] text-fg-muted">
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
</div>
</div>
<button
type="button"
onClick={() => onShare(p.name)}
className="btn-ghost shrink-0 text-xs"
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
>
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
</button>
</div>
))}
</div>
)}
</div>
);
}
/**
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
@@ -777,13 +659,8 @@ function EditAccountDialog({
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const toggle = (id: number, on: boolean) =>
setChosen((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const groupOf = (userId: number) =>
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
const submit = async () => {
if (!ready || busy) return;
@@ -822,38 +699,28 @@ function EditAccountDialog({
>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return (
<div key={u.id} className="px-3 py-2">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => toggle(u.id, on)}
label={
<span>
{u.username}
{u.disabled && (
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
)}
{elsewhere && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.onAccount", { account: elsewhere.account })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
<div className="mt-1">
<MemberPicker
users={users}
chosen={chosen}
onChange={setChosen}
groupOf={groupOf}
grouped={(pool.groups ?? []).length > 0}
note={(u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
const group = groupOf(u.id);
return (
<span className="ml-1.5 text-[11px] text-fg-muted">
{[
group && t("pool.groups.inGroup", { group }),
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
]
.filter(Boolean)
.join(" · ")}
</span>
);
}}
/>
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>