feat(login): 未绑定 Owner 时说明原因和绑定方式
This commit is contained in:
18 files changed
+579
-20
No files matched your search
@@ -70,7 +70,7 @@ curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap
|
||||
|
||||
脚本将安装 K3s,在 K3s 中部署 PostgreSQL 与控制平面,随后启动设置向导。设置完成后,通过浏览器访问所配置的域名即可进入控制面板。
|
||||
|
||||
* **设置向导**:向导首先绑定平台所有者:以 Minecraft Java 版加入向导所示的地址,登录服务器会给出 8 位绑定码(10 分钟内有效),将其输入向导即可。该步骤可以跳过,之后再次执行 `sudo felis setup` 补做;绑定所有者之前,任何人均无法登录控制面板。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。
|
||||
* **设置向导**:向导首先绑定平台所有者:以 Minecraft Java 版加入向导所示的地址,登录服务器会给出 8 位绑定码(10 分钟内有效),将其输入向导即可。该步骤可以跳过,之后再次执行 `sudo felis setup` 补做;绑定所有者之前,任何人均无法登录控制面板,登录页届时会说明原因并列出绑定步骤及连接地址。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。
|
||||
|
||||
* **支持的系统**:CentOS Stream 9(aarch64)已在实机上验证;Ubuntu 24.04(x86_64)在每次推送时由 CI 执行全新安装、重复安装、升级及上述安装命令(参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
|
||||
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap
|
||||
|
||||
The script installs K3s, deploys PostgreSQL and the control plane inside it, and launches a setup wizard. When setup completes, open the configured domain in a browser to reach the control panel.
|
||||
|
||||
* **Setup wizard**: The wizard first binds the platform Owner: join the address it shows in Minecraft Java Edition, then enter the 8-character link code that the login server displays (valid for 10 minutes). The step can be skipped and completed later by running `sudo felis setup` again; until an Owner is bound, nobody can sign in to the control panel. The installer launches the wizard automatically only on an interactive terminal; when output is redirected to a log or the install runs under cloud-init, run `sudo felis setup` after it finishes. Setting `FELIS_NO_SETUP=1` makes the installer end at its summary.
|
||||
* **Setup wizard**: The wizard first binds the platform Owner: join the address it shows in Minecraft Java Edition, then enter the 8-character link code that the login server displays (valid for 10 minutes). The step can be skipped and completed later by running `sudo felis setup` again; until an Owner is bound, nobody can sign in to the control panel, and the sign-in page states this together with the binding steps and the address to join. The installer launches the wizard automatically only on an interactive terminal; when output is redirected to a log or the install runs under cloud-init, run `sudo felis setup` after it finishes. Setting `FELIS_NO_SETUP=1` makes the installer end at its summary.
|
||||
|
||||
* **Supported hosts**: CentOS Stream 9 (aarch64) is verified on physical hardware; Ubuntu 24.04 (x86_64) is tested in CI on every push with a fresh install, a rerun, an upgrade and the install command above (see [operations §1](docs/operations.md#1-supported-hosts)).
|
||||
|
||||
|
||||
@@ -3941,6 +3941,34 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/auth/owner-status:
|
||||
get:
|
||||
tags: [auth]
|
||||
operationId: ownerStatus
|
||||
summary: 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.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: public
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: Whether any Owner or admin account exists.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [owner_bound]
|
||||
properties:
|
||||
owner_bound: { type: boolean }
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
|
||||
/api/v1/auth/logout:
|
||||
post:
|
||||
tags: [auth]
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
@@ -237,6 +238,9 @@ type API struct {
|
||||
mailOnce sync.Once
|
||||
mailBuckets *bucketSet
|
||||
|
||||
// ownerBound caches the first "an Owner exists" answer (handleOwnerStatus).
|
||||
ownerBound atomic.Bool
|
||||
|
||||
drainInit sync.Once
|
||||
drainClose sync.Once
|
||||
drain chan struct{}
|
||||
@@ -502,6 +506,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
{Method: "POST", Pattern: "/api/v1/auth/options", Public: true, AuthDoor: true, h: a.handleAuthOptions},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/setup/redeem", Public: true, AuthDoor: true, h: a.handleSetupRedeem},
|
||||
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
|
||||
// Whether an Owner is bound yet: before one is, every login door here is off, and the
|
||||
// sign-in page says so instead of offering them (handlers_auth_owner.go).
|
||||
{Method: "GET", Pattern: "/api/v1/auth/owner-status", Public: true, h: a.handleOwnerStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, AuthDoor: true, h: a.handlePasskeyLoginBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, AuthDoor: true, h: a.handlePasskeyLoginFinish},
|
||||
// Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the
|
||||
|
||||
@@ -94,6 +94,7 @@ type fakeRepo struct {
|
||||
failRevokeOthers error
|
||||
failMarkReauth error
|
||||
failGetSetting error
|
||||
failAdminExists error // AdminExists fails with it (a store outage)
|
||||
failRedeemSetup error
|
||||
failUserDetail error
|
||||
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
|
||||
@@ -1106,6 +1107,17 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) AdminExists(_ context.Context) (bool, error) {
|
||||
if f.failAdminExists != nil {
|
||||
return false, f.failAdminExists
|
||||
}
|
||||
for _, u := range f.staff {
|
||||
if u.Role == "admin" || u.Role == "owner" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
|
||||
f.sessions[ns.TokenHash] = &fakeSession{
|
||||
userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: ns.CreatedAt, lastSeen: ns.CreatedAt,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Pre-session install-state probe. Until `felis setup` binds an Owner, local sign-in is
|
||||
// off and every login door answers 403 local_auth_disabled, so the sign-in page would
|
||||
// offer four doors that all fail. This Public route lets the page say instead that no
|
||||
// Owner exists yet and how to bind one.
|
||||
//
|
||||
// It discloses one bit: whether the install is still unclaimed. Claiming it needs root
|
||||
// on the host (`felis setup` or the break-glass console) plus a Minecraft join whose
|
||||
// link code is typed into that terminal; no web door works before then, so knowing the
|
||||
// bit gives a remote caller nothing to act on. It must answer while local auth is off,
|
||||
// so unlike its sibling doors it is not gated on local_auth_enabled.
|
||||
//
|
||||
// The first true is cached in API.ownerBound. An Owner is never unbound through the
|
||||
// product, so from then on the probe costs no query; before it, each call is one
|
||||
// indexed LIMIT 1 read. It is not an AuthDoor: the page polls it on every load, and
|
||||
// sharing the doors' per-address bucket would throttle the sign-in that follows.
|
||||
type ownerStatusView struct {
|
||||
OwnerBound bool `json:"owner_bound"`
|
||||
}
|
||||
|
||||
// handleOwnerStatus reports whether any staff account exists. A store failure is a 503,
|
||||
// so the page falls back to its normal doors rather than claiming the install is unbound.
|
||||
func (a *API) handleOwnerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if a.ownerBound.Load() {
|
||||
writeJSON(w, http.StatusOK, ownerStatusView{OwnerBound: true})
|
||||
return
|
||||
}
|
||||
bound, err := a.Repo.AdminExists(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("owner status: %v", err)
|
||||
writeError(w, r, errAuthUnavailable)
|
||||
return
|
||||
}
|
||||
if bound {
|
||||
a.ownerBound.Store(true)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ownerStatusView{OwnerBound: bound})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const ownerStatusPath = "/api/v1/auth/owner-status"
|
||||
|
||||
func ownerBoundOf(t *testing.T, w *httptest.ResponseRecorder) bool {
|
||||
t.Helper()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var v struct {
|
||||
OwnerBound *bool `json:"owner_bound"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil || v.OwnerBound == nil {
|
||||
t.Fatalf("body = %s, want {\"owner_bound\": bool} (err %v)", w.Body.String(), err)
|
||||
}
|
||||
return *v.OwnerBound
|
||||
}
|
||||
|
||||
// TestOwnerStatusReportsAnUnclaimedInstall pins what the sign-in page reads: false before
|
||||
// any staff account exists, true once one does, and a player account alone is not an
|
||||
// Owner. It answers with local auth still off, which is the state it exists to explain.
|
||||
func TestOwnerStatusReportsAnUnclaimedInstall(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
if localAuthEnabled(t.Context(), repo) {
|
||||
t.Fatal("precondition: a fresh fake must have local auth off")
|
||||
}
|
||||
if ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an install with no accounts reports an Owner")
|
||||
}
|
||||
|
||||
repo.staff["player"] = &StaffUser{ID: "u1", Username: "player", Role: "user"}
|
||||
if ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("a player account alone reports an Owner")
|
||||
}
|
||||
|
||||
repo.staff["boss"] = &StaffUser{ID: "o1", Username: "boss", Role: "owner"}
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an install with an Owner reports none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerStatusCachesTheBoundAnswer pins that once an Owner is seen the probe stops
|
||||
// querying: a store that then fails still gets the cached true.
|
||||
func TestOwnerStatusCachesTheBoundAnswer(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.staff["boss"] = &StaffUser{ID: "o1", Username: "boss", Role: "owner"}
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Fatal("an install with an Owner reports none")
|
||||
}
|
||||
repo.failAdminExists = errors.New("store down")
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("the bound answer was not cached")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerStatusStoreFailure pins that an outage is a 503, never a false "no Owner":
|
||||
// the page must fall back to its doors rather than tell a claimed install to run setup.
|
||||
// An unbound answer is not cached either, so the next call reads the store again.
|
||||
func TestOwnerStatusStoreFailure(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
repo.failAdminExists = errors.New("store down")
|
||||
w := do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := decodeErr(t, w); got != "auth_unavailable" {
|
||||
t.Errorf("error = %q, want auth_unavailable", got)
|
||||
}
|
||||
|
||||
repo.failAdminExists = nil
|
||||
if ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an install with no accounts reports an Owner")
|
||||
}
|
||||
repo.staff["boss"] = &StaffUser{ID: "o1", Username: "boss", Role: "owner"}
|
||||
if !ownerBoundOf(t, do(api.ExternalHandler(), "GET", ownerStatusPath, "", nil)) {
|
||||
t.Error("an unbound answer was cached past the Owner's arrival")
|
||||
}
|
||||
}
|
||||
@@ -1334,7 +1334,6 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse
|
||||
// break-glass console's bootstrap-vs-recovery switch: false means the typed
|
||||
// credential mints the first Owner (no prior identity to verify against), true
|
||||
// means the operator must identify against an existing staff account for accountability.
|
||||
// It is not on the Repo interface because only the break-glass CLI consults it.
|
||||
func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) {
|
||||
const q = `SELECT 1 FROM users WHERE role IN ('admin', 'owner') LIMIT 1`
|
||||
var one int
|
||||
|
||||
@@ -702,6 +702,10 @@ type Repo interface {
|
||||
// re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is
|
||||
// promoted. The account is passwordless by design.
|
||||
UpsertOwner(ctx context.Context, id, username, email string) error
|
||||
// AdminExists reports whether any staff account (admin or owner) exists. It is false
|
||||
// only on an install `felis setup` has not bound an Owner on yet, where local sign-in
|
||||
// is still off: the sign-in page reads it to say so (handleOwnerStatus).
|
||||
AdminExists(ctx context.Context) (bool, error)
|
||||
// CreateSession records a minted session (spec §B sessions). Only the hash of
|
||||
// the cookie is stored, mirroring tokens, so a database read never yields a
|
||||
// usable cookie. The session counts as seen at creation.
|
||||
|
||||
@@ -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("@")) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "仅限管理员:验证码将发送至您的邮箱,且需要一位在线管理员在游戏内批准此次登录。",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user