feat(login): 未绑定 Owner 时说明原因和绑定方式

This commit is contained in:
Lemon-miaow committed 2026-10-02 19:38:36 +08:00
1 parent 9139c5bd35
commit 2e4f118939
18 files changed
+579 -20

No files matched your search

+4
View File
@@ -966,6 +966,10 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
});
return true;
}
// The mock install always has its Owner; the unbound page is covered by Login.test.tsx.
case "GET auth/owner-status":
sendJSON(ctx.res, 200, { owner_bound: true });
return true;
case "POST auth/email/start": {
const body = await readJSON<{ email?: string }>(ctx.req);
if (!body.email || !body.email.includes("@")) {
+14 -1
View File
@@ -20,7 +20,20 @@
"passkey_email_hint": "Enter your registered email above to use a passkey, or leave it empty to sign in with a discoverable passkey.",
"bind_code": "Bind Code",
"bind_code_placeholder": "e.g., ABCD2345",
"bind_hint": "Type /link in-game to generate a one-time bind code.",
"bind_hint": "In Minecraft (Java Edition), join the address below. The login server gives a one-time bind code on your first join; after that, type /link in-game for a new one.",
"bind_hint_no_address": "In Minecraft (Java Edition), join this server. The login server gives a one-time bind code on your first join; after that, type /link in-game for a new one.",
"no_owner_title": "No Owner yet, so nobody can sign in",
"no_owner_subtitle": "The Owner is the account that owns this server",
"no_owner_intro": "This server has no Owner bound yet. Every sign-in method stays off until one is. Bind one as follows:",
"no_owner_step_setup": "On the server, run this command in a terminal. It opens straight onto the Owner binding:",
"no_owner_step_join": "In Minecraft (Java Edition), join this address. The login server opens a book with your bind code, and chat shows it too:",
"no_owner_step_join_no_address": "In Minecraft (Java Edition), join this server at the address the terminal shows. The login server opens a book with your bind code, and chat shows it too.",
"no_owner_ip_fallback": "While the domain does not point at this server yet, join by the server's IP address instead.",
"no_owner_step_code": "Type the code into the terminal. The web link in the book is for players; the Owner's code goes into the terminal.",
"no_owner_step_link": "Open the setup link the terminal then shows, and set an email and a passkey. After that, you can sign in here.",
"no_owner_recheck": "Done binding? Check again",
"no_owner_rechecking": "Checking…",
"no_owner_still_unbound": "There is still no Owner. Check that the terminal has shown the setup link.",
"bind_btn": "Verify & Sign In",
"binding": "Verifying…",
"op_hint": "Staff only: a code is emailed to you, and an online operator must approve the request in-game before you can sign in.",
+14 -1
View File
@@ -20,7 +20,20 @@
"passkey_email_hint": "使用 Passkey 请在上方输入绑定邮箱,或留空直接免密登录。",
"bind_code": "绑定码",
"bind_code_placeholder": "例如:ABCD2345",
"bind_hint": "在游戏内输入 /link 即可获取一次性绑定码",
"bind_hint": "用 Minecraft Java 版加入下面的地址。第一次进入时登录服会给出一次性绑定码,之后在游戏内输入 /link 获取新的。",
"bind_hint_no_address": "用 Minecraft Java 版加入本服务器。第一次进入时登录服会给出一次性绑定码,之后在游戏内输入 /link 获取新的。",
"no_owner_title": "还没有 Owner,暂时无法登录",
"no_owner_subtitle": "Owner 是这台服务器的所有者账号",
"no_owner_intro": "这台服务器还没有绑定 Owner。绑定完成前,所有登录方式都处于关闭状态。按以下步骤完成绑定:",
"no_owner_step_setup": "在服务器终端运行下面的命令,它会直接进入 Owner 绑定:",
"no_owner_step_join": "用 Minecraft Java 版加入下面的地址。登录服会打开一本书,并在聊天栏显示绑定码:",
"no_owner_step_join_no_address": "用 Minecraft Java 版加入这台服务器,地址显示在终端里。登录服会打开一本书,并在聊天栏显示绑定码。",
"no_owner_ip_fallback": "域名还没有解析到这台服务器时,改用服务器的 IP 地址加入。",
"no_owner_step_code": "把绑定码输入终端。书里的网页链接供玩家使用,Owner 的绑定码要输入终端。",
"no_owner_step_link": "打开终端随后显示的设置链接,设置邮箱和通行密钥。完成后就能在这里登录。",
"no_owner_recheck": "已完成绑定,重新检查",
"no_owner_rechecking": "检查中…",
"no_owner_still_unbound": "还没有检测到 Owner。请确认终端已经显示设置链接。",
"bind_btn": "验证并登录",
"binding": "验证中…",
"op_hint": "仅限管理员:验证码将发送至您的邮箱,且需要一位在线管理员在游戏内批准此次登录。",
+5
View File
@@ -372,6 +372,11 @@ export const api = rejectingSync({
bind: (code: string) =>
request<BindResult>("POST", "/auth/bind", { code }),
// Whether `felis setup` has bound an Owner yet. Until it has, every door above
// answers 403 local_auth_disabled, so the sign-in page explains that instead.
authOwnerStatus: () =>
request<{ owner_bound: boolean }>("GET", "/auth/owner-status"),
authEmailStart: (email: string) =>
request<{ sent: boolean; expires_at: string }>("POST", "/auth/email/start", { email }),
+37
View File
@@ -133,3 +133,40 @@ describe("joinAddress", () => {
}
});
});
// The address the sign-in page tells people to join must be the one `felis setup`
// prints (Go setupGameAddress), or the page and the terminal disagree.
describe("entryAddress", () => {
const base = { apiBase: "/api/v1", rootDomain: "mc.example" };
it("is the IP a nip.io or sslip.io root domain spells out", async () => {
const { entryAddress } = await freshConfig();
expect(entryAddress({ ...base, rootDomain: "203.0.113.7.nip.io" })).toBe("203.0.113.7");
expect(entryAddress({ ...base, rootDomain: "203.0.113.7.sslip.io." })).toBe("203.0.113.7");
expect(entryAddress({ ...base, rootDomain: "203.0.113.7.nip.io", gamePort: 25570 })).toBe("203.0.113.7:25570");
});
it("is the root domain otherwise, with a port only off 25565", async () => {
const { entryAddress } = await freshConfig();
expect(entryAddress(base)).toBe("mc.example");
expect(entryAddress({ ...base, gamePort: 25565 })).toBe("mc.example");
expect(entryAddress({ ...base, gamePort: 25570 })).toBe("mc.example:25570");
// Not an address Go's net.ParseIP accepts, so the name is kept whole.
expect(entryAddress({ ...base, rootDomain: "203.0.113.07.nip.io" })).toBe("203.0.113.07.nip.io");
expect(entryAddress({ ...base, rootDomain: "203.0.113.256.nip.io" })).toBe("203.0.113.256.nip.io");
expect(entryAddress({ ...base, rootDomain: "play.nip.io" })).toBe("play.nip.io");
});
it("is empty when config.json could not be read", async () => {
const { entryAddress } = await freshConfig();
expect(entryAddress({ ...base, fallback: true })).toBe("");
});
it("tells an IP from a name", async () => {
const { isIPAddress } = await freshConfig();
expect(isIPAddress("203.0.113.7")).toBe(true);
expect(isIPAddress("203.0.113.7:25570")).toBe(true);
expect(isIPAddress("mc.example")).toBe(false);
expect(isIPAddress("mc.example:25570")).toBe(false);
});
});
+32
View File
@@ -106,3 +106,35 @@ export function joinAddress(subdomain: string, cfg: RuntimeConfig): string {
const host = hostFor(subdomain, cfg);
return cfg.gamePort && cfg.gamePort !== 25565 ? `${host}:${cfg.gamePort}` : host;
}
// The IPv4 address a nip.io or sslip.io root domain spells out ("203.0.113.7.nip.io"),
// read as strictly as Go's net.ParseIP: four parts, each 0-255, no leading zeros.
function embeddedIPv4(rootDomain: string): string {
const domain = rootDomain.trim().replace(/\.$/, "");
for (const suffix of [".nip.io", ".sslip.io"]) {
if (!domain.endsWith(suffix)) continue;
const base = domain.slice(0, -suffix.length);
const parts = base.split(".");
if (parts.length === 4 && parts.every((p) => /^(0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255)) {
return base;
}
}
return "";
}
/** entryAddress is where anyone joins in Minecraft to reach the login server, which
* hands out link codes: the IP a nip.io or sslip.io root domain spells out, otherwise
* the root domain, with the port when it is not 25565. It mirrors the Go side's
* setupGameAddress, so the page and the `felis setup` terminal name the same address.
* Empty when config.json could not be read, as the root domain is then a guess. */
export function entryAddress(cfg: RuntimeConfig): string {
if (cfg.fallback) return "";
const host = embeddedIPv4(cfg.rootDomain) || cfg.rootDomain.trim().replace(/\.$/, "");
if (!host) return "";
return cfg.gamePort && cfg.gamePort !== 25565 ? `${host}:${cfg.gamePort}` : host;
}
/** isIPAddress reports whether an entry address names its host by IPv4 address. */
export function isIPAddress(address: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(address);
}
+43
View File
@@ -1106,6 +1106,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/auth/owner-status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Report whether an Owner has been bound on this install.
* @description Public, pre-session probe the sign-in page reads on load. Until `felis setup` binds an Owner, local sign-in is off and every login door answers 403 local_auth_disabled; the page then explains that no Owner exists and how to bind one instead of offering the doors. It discloses only whether the install is still unclaimed, and claiming it needs root on the host. It is not gated on local_auth_enabled and does not draw on the login doors' per-address rate limit.
*/
get: operations["ownerStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/logout": {
parameters: {
query?: never;
@@ -6315,6 +6335,29 @@ export interface operations {
};
};
};
ownerStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Whether any Owner or admin account exists. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
owner_bound: boolean;
};
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
logout: {
parameters: {
query?: never;
+116 -10
View File
@@ -15,16 +15,19 @@ const calls = vi.hoisted(() => ({
authPasskeyLoginFinish: vi.fn(),
opLoginStart: vi.fn(),
opLoginStatus: vi.fn(),
authOwnerStatus: vi.fn(),
credentialsGet: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("@/lib/tier", () => ({
useTier: () => ({ loading: false, identity: null, refresh: calls.refresh }),
}));
vi.mock("@/lib/config", () => ({
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "localhost" }),
useConfig: () => null,
}));
// The page builds the join address with the real helpers; only the file is faked.
const config = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
vi.mock("@/lib/config", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/config")>();
return { ...actual, loadConfig: () => Promise.resolve(config.value), useConfig: () => null };
});
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
@@ -39,22 +42,29 @@ vi.mock("@/lib/api", async (importOriginal) => {
authPasskeyLoginFinish: calls.authPasskeyLoginFinish,
opLoginStart: calls.opLoginStart,
opLoginStatus: calls.opLoginStatus,
authOwnerStatus: calls.authOwnerStatus,
},
};
});
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
function renderLogin() {
return render(
// renderLogin waits out the Owner probe, which holds the page on a spinner, and
// returns once the heading is the one wanted: the doors by default.
async function renderLogin(heading = t("auth:login_title")) {
const view = render(
<MemoryRouter initialEntries={["/login"]}>
<Login />
</MemoryRouter>,
);
await screen.findByRole("heading", { name: heading });
return view;
}
beforeEach(() => {
for (const fn of Object.values(calls)) fn.mockReset();
calls.authOwnerStatus.mockResolvedValue({ owner_bound: true });
config.value = { apiBase: "/api/v1", rootDomain: "localhost" };
// jsdom has no WebAuthn; the browser handing back nothing is what a
// dismissed or empty authenticator looks like to the page.
Object.defineProperty(navigator, "credentials", { value: { get: calls.credentialsGet }, configurable: true });
@@ -63,7 +73,7 @@ beforeEach(() => {
describe("Login", () => {
it("reads out why the code could not be sent", async () => {
calls.authEmailStart.mockRejectedValue({ status: 429, code: "otp_resend_cooldown", message: "" });
renderLogin();
await renderLogin();
await userEvent.type(screen.getByLabelText(t("auth:email_address")), "[email protected]");
await userEvent.click(screen.getByRole("button", { name: t("auth:send_otp") }));
@@ -75,7 +85,7 @@ describe("Login", () => {
calls.authEmailStart.mockResolvedValue(undefined);
calls.authEmailVerify.mockRejectedValueOnce({ status: 400, code: "invalid_code", message: "" });
calls.authEmailVerify.mockReturnValueOnce(new Promise(() => {}));
renderLogin();
await renderLogin();
await userEvent.type(screen.getByLabelText(t("auth:email_address")), "[email protected]");
await userEvent.click(screen.getByRole("button", { name: t("auth:send_otp") }));
@@ -91,7 +101,7 @@ describe("Login", () => {
calls.credentialsGet.mockResolvedValue(null);
calls.authPasskeyDiscoverableBegin.mockResolvedValue({ login_id: "l1", publicKey: { challenge: "AAAA" } });
calls.authPasskeyLoginBegin.mockResolvedValue({ challenge: "AAAA" });
renderLogin();
await renderLogin();
await userEvent.click(screen.getByRole("button", { name: t("auth:passkey_btn") }));
expect((await screen.findByRole("alert")).textContent).toBe("The browser returned no passkey. Try again.");
@@ -122,7 +132,7 @@ describe("operator sign-in", () => {
request_id: `req-${calls.opLoginStart.mock.calls.length}`,
expires_at: new Date(Date.now() + expiresInMs).toISOString(),
}));
renderLogin();
await renderLogin();
await user.click(screen.getByRole("button", { name: t("auth:tab_op_btn") }));
await user.type(screen.getByLabelText(t("auth:email_address")), "[email protected]");
await user.click(screen.getByRole("button", { name: t("auth:op_start_btn") }));
@@ -204,3 +214,99 @@ describe("operator sign-in", () => {
});
});
// Until `felis setup` binds an Owner every door answers "disabled", so the page says
// why and how to bind one, naming the address to join in Minecraft.
describe("an install with no Owner", () => {
const NO_OWNER = () => t("auth:no_owner_title");
beforeEach(() => {
calls.authOwnerStatus.mockResolvedValue({ owner_bound: false });
});
it("explains why nobody can sign in, with the command and the address to join", async () => {
config.value = { apiBase: "/api/v1", rootDomain: "203.0.113.7.nip.io", gamePort: 25570 };
await renderLogin(NO_OWNER());
expect(screen.getByText("sudo felis setup")).toBeTruthy();
expect(await screen.findByText("203.0.113.7:25570")).toBeTruthy();
expect(screen.queryByText(t("auth:no_owner_ip_fallback"))).toBeNull();
// None of the doors that cannot work is offered.
expect(screen.queryByLabelText(t("auth:email_address"))).toBeNull();
expect(screen.queryByRole("button", { name: t("auth:passkey_btn") })).toBeNull();
expect(screen.queryByRole("button", { name: t("auth:tab_bind_btn") })).toBeNull();
});
it("offers the IP when the address is a domain name", async () => {
config.value = { apiBase: "/api/v1", rootDomain: "mc.example" };
await renderLogin(NO_OWNER());
expect(await screen.findByText("mc.example")).toBeTruthy();
expect(screen.getByText(t("auth:no_owner_ip_fallback"))).toBeTruthy();
});
it("points at the terminal for the address when config.json could not be read", async () => {
config.value = { apiBase: "/api/v1", rootDomain: "localhost", fallback: true };
await renderLogin(NO_OWNER());
expect(screen.getByText(t("auth:no_owner_step_join_no_address"))).toBeTruthy();
expect(screen.queryByText("localhost")).toBeNull();
});
it("checks again on request and shows the doors once an Owner is bound", async () => {
calls.authOwnerStatus
.mockResolvedValueOnce({ owner_bound: false })
.mockResolvedValueOnce({ owner_bound: false })
.mockResolvedValue({ owner_bound: true });
await renderLogin(NO_OWNER());
await userEvent.click(screen.getByRole("button", { name: t("auth:no_owner_recheck") }));
expect(await screen.findByText(t("auth:no_owner_still_unbound"))).toBeTruthy();
expect(calls.authOwnerStatus).toHaveBeenCalledTimes(2);
await userEvent.click(screen.getByRole("button", { name: t("auth:no_owner_recheck") }));
await screen.findByRole("heading", { name: t("auth:login_title") });
expect(screen.getByLabelText(t("auth:email_address"))).toBeTruthy();
});
it("holds the page while it asks, so an unclaimed install never flashes the doors", async () => {
calls.authOwnerStatus.mockReturnValue(new Promise(() => {}));
render(
<MemoryRouter initialEntries={["/login"]}>
<Login />
</MemoryRouter>,
);
await vi.waitFor(() => expect(calls.authOwnerStatus).toHaveBeenCalled());
expect(screen.getByText(t("common:loading"))).toBeTruthy();
expect(screen.queryByLabelText(t("auth:email_address"))).toBeNull();
});
it("shows the doors when the server cannot say", async () => {
calls.authOwnerStatus.mockRejectedValue({ status: 503, code: "auth_unavailable", message: "" });
await renderLogin();
expect(screen.getByLabelText(t("auth:email_address"))).toBeTruthy();
});
});
// A player gets a bind code by joining in Minecraft, so the hint names where.
describe("the bind-code door", () => {
it("names the address to join", async () => {
config.value = { apiBase: "/api/v1", rootDomain: "mc.example", gamePort: 25570 };
await renderLogin();
await userEvent.click(screen.getByRole("button", { name: t("auth:tab_bind_btn") }));
expect(screen.getByText(t("auth:bind_hint"))).toBeTruthy();
expect(screen.getByText("mc.example:25570")).toBeTruthy();
});
it("names the server when config.json could not be read", async () => {
config.value = { apiBase: "/api/v1", rootDomain: "localhost", fallback: true };
await renderLogin();
await userEvent.click(screen.getByRole("button", { name: t("auth:tab_bind_btn") }));
expect(screen.getByText(t("auth:bind_hint_no_address"))).toBeTruthy();
expect(screen.queryByText("localhost")).toBeNull();
});
});
+128 -5
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, type FormEvent } from "react";
import { Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react";
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
@@ -10,7 +10,8 @@ import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { loginReturnPath } from "@/lib/auth";
import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { entryAddress, isIPAddress, loadConfig } from "@/lib/config";
import { CopyAddress } from "@/components/CopyAddress";
import { requestAssertion } from "@/lib/passkey";
import { InlineError } from "@/components/MessageLine";
import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll";
@@ -50,6 +51,13 @@ export function Login() {
const [isOpHost, setIsOpHost] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Where Minecraft players join for a code; empty until config.json is read, or
// when it cannot be.
const [joinAddr, setJoinAddr] = useState("");
// Whether `felis setup` has bound an Owner: undefined while asking, null when the
// answer could not be had (the doors show as usual), false on an unclaimed install,
// where every door is off and the page explains how to bind one instead.
const [ownerBound, setOwnerBound] = useState<boolean | null | undefined>(undefined);
// Countdown timer for OTP resend
useEffect(() => {
@@ -64,6 +72,7 @@ export function Login() {
// (the player doors refuse staff accounts anyway).
useEffect(() => {
void loadConfig().then((cfg) => {
setJoinAddr(entryAddress(cfg));
if (cfg.adminHostname && window.location.hostname === cfg.adminHostname) {
setIsOpHost(true);
setActiveTab("op");
@@ -71,14 +80,26 @@ export function Login() {
});
}, []);
useEffect(() => {
let alive = true;
api.authOwnerStatus().then(
(res) => alive && setOwnerBound(res.owner_bound),
() => alive && setOwnerBound(null),
);
return () => {
alive = false;
};
}, []);
// Polls until the in-game approval lands, the request's deadline passes, or the
// server refuses outright.
const opPoll = useOpLoginPoll(opRequestId, opDeadline);
const opApproved = opPoll.approved;
// Don't flash the form while the boot /me is still in flight: a signed-in visitor
// would briefly see a login form before being redirected away.
if (loading) {
// would briefly see a login form before being redirected away. Nor while the Owner
// probe is: an unclaimed install would flash doors that cannot work.
if (loading || (!identity && ownerBound === undefined)) {
return (
<AuthLayout title={t("common:brand_name")}>
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
@@ -89,6 +110,9 @@ export function Login() {
);
}
if (identity) return <Navigate to={next} replace />;
if (ownerBound === false) {
return <NoOwnerNotice joinAddr={joinAddr} onBound={() => setOwnerBound(true)} />;
}
async function handleBindSubmit(e: FormEvent) {
e.preventDefault();
@@ -370,8 +394,9 @@ export function Login() {
aria-invalid={error ? true : undefined}
/>
<p className="text-[11px] text-muted-foreground/80 mt-1 leading-normal">
{t("bind_hint")}
{t(joinAddr ? "bind_hint" : "bind_hint_no_address")}
</p>
{joinAddr && <CopyAddress address={joinAddr} />}
</div>
<InlineError message={error} />
<Button
@@ -569,3 +594,101 @@ export function Login() {
</AuthLayout>
);
}
// NoOwnerNotice replaces the doors on an install `felis setup` has not bound an Owner
// on. Local sign-in is off until it does, so every door would answer "disabled"; this
// says why and walks through the binding, which happens in the server's terminal plus
// one Minecraft join. The steps match the terminal's own bind screen (tui_mc_bind.go).
function NoOwnerNotice({ joinAddr, onBound }: { joinAddr: string; onBound: () => void }) {
const { t } = useTranslation("auth");
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<string | null>(null);
async function recheck() {
if (checking) return;
setChecking(true);
setResult(null);
try {
const res = await api.authOwnerStatus();
if (res.owner_bound) {
onBound();
return;
}
setResult(t("no_owner_still_unbound"));
} catch (err) {
setResult(humanizeError(err));
}
setChecking(false);
}
const steps = [
<>
<p>{t("no_owner_step_setup")}</p>
<code className="mt-1.5 inline-block select-all rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
sudo felis setup
</code>
</>,
joinAddr ? (
<>
<p>{t("no_owner_step_join")}</p>
<CopyAddress address={joinAddr} className="mt-1" />
{!isIPAddress(joinAddr) && (
<p className="mt-1 text-xs text-muted-foreground/80">{t("no_owner_ip_fallback")}</p>
)}
</>
) : (
<p>{t("no_owner_step_join_no_address")}</p>
),
<p>{t("no_owner_step_code")}</p>,
<p>{t("no_owner_step_link")}</p>,
];
return (
<AuthLayout title={t("no_owner_title")} subtitle={t("no_owner_subtitle")}>
<Card>
<CardContent className="space-y-5 pt-6 text-sm">
<p className="text-muted-foreground leading-relaxed">{t("no_owner_intro")}</p>
<ol className="space-y-4">
{steps.map((step, i) => (
<li key={i} className="flex gap-3">
<span
aria-hidden
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary"
>
{i + 1}
</span>
<div className="min-w-0 flex-1 leading-relaxed">{step}</div>
</li>
))}
</ol>
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => void recheck()}
disabled={checking}
>
{checking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t("no_owner_rechecking")}
</>
) : (
<>
<RefreshCw className="h-4 w-4 text-muted-foreground" />
{t("no_owner_recheck")}
</>
)}
</Button>
{result && (
<p role="status" className="text-center text-xs text-muted-foreground leading-normal">
{result}
</p>
)}
</div>
</CardContent>
</Card>
</AuthLayout>
);
}