feat(auth)!: go fully passwordless and fix cross-check review findings

Remove password authentication everywhere; the only session doors are
passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and
op-login vouching. Remediates the 33-finding cross-check review across
backend, CLI, panel, plugins, and docs.

Backend/CLI:
- Drop password routes and fields from account/user/onboard/auth
  handlers; align tests (new account subtests, naming reserves
  "console", op-login/onboard/qr-login test updates).
- Add migrations 0016_op_login.sql and 0017_drop_password.sql.
- Thread panel/admin hostnames from hostcfg through api.go,
  setup_panel.go, tui_root.go and tui_preflight.go instead of
  hardcoding; bootstrap.sh writes panel-hostname/admin-hostname
  into felis.toml.
- Reword breakglass and TUI copy for passwordless flows.

Panel:
- Delete the ChangePassword page and all password UI; align
  login/auth/api/types with the passwordless contract; add the
  migration and op-login approval flows.
- i18n: convert ImageBuildPage durations/status badges and
  ServerLuckPerms strings to translation keys; drop 72 orphan keys
  per locale; unify the title as "Felis - Console".

Plugins (all six rebuilt):
- Velocity waiting router returns 503 at_capacity during wake;
  MOTD/control-channel copy and config comments.
- Paper zh menu title; Limbo bind-code TTL 600s with panel_url
  preference; unified /link lines in fabric/forge/neoforge; shared
  link-client javadoc contract fixes.

Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and
plugins/README.md aligned with the implementation.

BREAKING CHANGE: migration 0017 irreversibly drops
users.password_hash and users.must_change_password; password login
cannot be restored after migrating.
This commit is contained in:
flyemoji committed 2026-07-20 04:47:32 +09:00
1 parent c96b36a41f
commit 7860152f57
97 files changed
+1882 -1403

No files matched your search

+15 -7
View File
@@ -215,12 +215,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
Restorer: restorer,
Backuper: backuper,
Submissions: submissions,
// The external face is fronted by SessionAuth: it prefers a local-password
// session cookie and otherwise delegates to the Cloudflare-Access JWT verifier,
// so both auth models coexist on one face. The delegate's Keyfunc is
// intentionally nil — the JWT path fails closed until a JWKS-backed key function
// is wired (deployment integration point) — while the local-password path is
// live the moment `felis breakGlass` flips local_auth_enabled on.
// The external face is fronted by SessionAuth: it prefers a local session
// cookie (minted by the passwordless doors) and otherwise delegates to the
// Cloudflare-Access JWT verifier, so both auth models coexist on one face. The
// delegate's Keyfunc is intentionally nil — the JWT path fails closed until a
// JWKS-backed key function is wired (deployment integration point) — while the
// local session path is live the moment `felis breakGlass` flips
// local_auth_enabled on.
External: api.SessionAuth{
Repo: repo,
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
@@ -229,6 +230,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
},
RootDomain: cfg.Server.RootDomain,
AdminHostname: cfg.Auth.AdminHostname,
PanelHostname: cfg.Auth.PanelHostname,
WakeCooldown: 30 * time.Second,
// Bound concurrent console/build-log SSE streams per principal. Generous enough
// for legitimate multi-tab / multi-server watching, while capping how many
@@ -271,7 +273,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503")
}
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname, resolvedVersion())
// Derive the console hostnames when felis.toml leaves them unset, exactly as the
// setup/breakGlass paths do — otherwise the SPA cannot tell which face it is
// serving and falls back to the player console on op.console.<root>.
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain,
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname),
defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname),
resolvedVersion())
internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
+8 -7
View File
@@ -26,7 +26,7 @@ import (
// authority is local root, so it legitimately BYPASSES the web Zero-Trust + Passkey
// path: critical recovery runs direct-to-Postgres. The thin-thread operation it
// ships here is the one that bootstraps everything else — provision (or reset) the
// single Owner account and turn local-password login on — so that even with the web
// single Owner account and turn local session sign-in on — so that even with the web
// auth path unconfigured an operator can get into op.console. It is a genuine
// interactive TUI, NOT a CLI: bare `felis` prints CLI usage, while `felis breakGlass`
// opens this full-screen console. It refuses to run unless euid is 0 (sudo/root).
@@ -44,7 +44,7 @@ import (
// When a staff account already exists the console opens on a thin top-level menu
// (menuModel) so that operations are peers, not tails of one wizard. Two account
// operations are wired today: (1) provision/reset the Owner — the thin thread above,
// which also re-enables local-password login — and (2) add an Operator: an
// which also re-enables local session sign-in — and (2) add an Operator: an
// insert-only mint of an additional staff admin (provisionOperator) that
// deliberately never touches the global local_auth toggle. On a fresh machine (no
// Owner yet) the menu is skipped: bootstrapping the first Owner is the only sensible
@@ -156,7 +156,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
// The TUI runs on the alternate screen, which is torn down on exit and takes its
// display with it. Re-print a durable summary to the normal screen so the
// outcome — and any generated one-time password — survives in scrollback long
// outcome — and the one-time setup URL — survives in scrollback long
// enough for the operator to log in.
if res.provisioned {
if res.isOperator {
@@ -164,7 +164,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
// so the summary must not claim it did — only the Owner thread enables login.
fmt.Fprintf(stdout, "\nfelis breakGlass: Operator account %q provisioned.\n", res.username)
} else {
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local session sign-in is ENABLED.\n", res.username)
}
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
if res.setupTokenURL != "" {
@@ -318,8 +318,9 @@ func provisionOperator(ctx context.Context, s ownerStore, username, email string
}
// enableLocalAuth flips the runtime local_auth_enabled toggle on
// direct-to-Postgres. It is a load-bearing write of break-glass: without it
// handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a
// direct-to-Postgres. It is a load-bearing write of break-glass: without it every
// session-minting door (passkey / email-OTP / bind / op-login) returns 403
// local_auth_disabled and the freshly provisioned Owner cannot log in, so a
// successful provisionOwner with local auth off is not a usable thin thread.
func enableLocalAuth(ctx context.Context, s ownerStore) error {
// The setting is read back with json.Unmarshal into a bool, so the stored jsonb
@@ -349,7 +350,7 @@ type breakGlassOutcome struct {
}
// performBreakGlass executes a resolved break-glass operation: provision (or reset)
// the Owner, enable local-password login, then record a best-effort accountability
// the Owner, enable local session sign-in, then record a best-effort accountability
// audit row. The Owner is passwordless — the setup-token flow handles first-login
// setup. The audit write is best-effort: a logging failure is reported via auditErr
// but does NOT fail the recovery — break-glass must still work when the audit sink
+1 -1
View File
@@ -123,7 +123,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
}
panelURL := res.panelURL
if panelURL == "" {
panelURL = localPanelURL(setup.cfg.Server.RootDomain)
panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname)
}
if !res.provisioned && !res.connectConfigured {
+4 -4
View File
@@ -32,11 +32,11 @@ func setupPanelNodePort() int {
return port
}
func localPanelURL(rootDomain string) string {
func localPanelURL(rootDomain, adminHostname string) string {
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
}
host := defaultAdminHostname(rootDomain, "")
host := defaultAdminHostname(rootDomain, adminHostname)
if host == "" {
return ""
}
@@ -61,8 +61,8 @@ func localPanelOrigin() string {
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
}
func checkPanelAccess(rootDomain string) panelAccessResult {
base := localPanelURL(rootDomain)
func checkPanelAccess(rootDomain, adminHostname string) panelAccessResult {
base := localPanelURL(rootDomain, adminHostname)
if base == "" {
return panelAccessResult{err: fmt.Errorf("root domain is empty")}
}
+4 -3
View File
@@ -15,7 +15,8 @@ import (
// None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a
// turnkey integration, and "Reverse proxy" just records hostnames and hands the
// operator a copy-paste guide. The admin console is gated by the Owner's
// local-password session regardless; Cloudflare Access is an *additional* layer.
// local session (passwordless sign-in) regardless; Cloudflare Access is an
// *additional* layer.
type connectChooserModel struct {
rootDomain string
adminHost string
@@ -46,8 +47,8 @@ func (m *connectChooserModel) build() *huh.Form {
// A dim, untitled footnote — deliberately subordinate to the picker above
// so the screen reads as a menu, not an info page.
huh.NewNote().Description(
"⚠ Local / reverse proxy gate the admin console on your Owner password alone. "+
"Cloudflare Access adds an edge check in front."),
"⚠ Local / reverse proxy gate the admin console on your Owner sign-in alone "+
"(passkey / email code). Cloudflare Access adds an edge check in front."),
)))
}
+1 -1
View File
@@ -72,7 +72,7 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost,
// applyReverseProxy records the operator's chosen public hostnames and rolls the
// API so the panel serves them. No Access audience is set: the admin console is
// gated by the Owner's local-password session, and the operator's own reverse
// gated by the Owner's local session (passwordless sign-in), and the operator's own reverse
// proxy (Caddy/nginx/Traefik/…) terminates TLS in front of the NodePort origin.
func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error {
if adminHost == "" {
+3 -3
View File
@@ -56,10 +56,10 @@ func (m *menuModel) build() *huh.Form {
huh.NewOption("Back up a world now (Sync)", bgSyncBackup),
),
// A dim footnote spelling out the one behavioural difference that matters:
// Owner-reset re-enables local-password login, operator-add never touches the
// global auth toggle.
// Owner-reset re-enables local session sign-in, operator-add never touches
// the global auth toggle.
huh.NewNote().Description(
"Owner reset re-enables local-password login. Adding an Operator mints an "+
"Owner reset re-enables local session sign-in. Adding an Operator mints an "+
"additional staff admin and leaves the global auth toggle untouched."),
)))
}
+1 -1
View File
@@ -96,7 +96,7 @@ func TestProvisionCmdSelectsPathByOperation(t *testing.T) {
if msg.err != nil {
t.Fatalf("owner provision: %v", msg.err)
}
// performBreakGlass upserts the single Owner and enables local-password login.
// performBreakGlass upserts the single Owner and enables local session sign-in.
if len(f.upserts) != 1 || len(f.inserts) != 0 {
t.Fatalf("want 1 upsert and 0 inserts (performBreakGlass), got upserts=%d inserts=%d", len(f.upserts), len(f.inserts))
}
+2 -2
View File
@@ -103,7 +103,7 @@ func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminEx
// newOperatorModel builds the model for the Add-Operator break-glass operation. It
// always starts at admin authentication: adding an Operator presupposes an existing
// admin (that is why the menu only offers it when one exists), so there is no
// bootstrap branch and the password is always generated. The username is left empty
// bootstrap branch. The username is left empty
// on purpose — defaulting it to "owner" (as the Owner flow does) would make the
// happy path insert a duplicate and hit ErrConflict on every attempt.
func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *ownerModel {
@@ -285,7 +285,7 @@ func (m *ownerModel) provisionCmd() tea.Cmd {
// performAddOperator and performBreakGlass share a signature; the operation
// discriminator selects which one runs. The operator path is insert-only and
// never flips local auth (see performAddOperator); the Owner path upserts and
// enables local-password login.
// enables local session sign-in.
perform := performBreakGlass
if m.operation == bgAddOperator {
perform = performAddOperator
+4 -3
View File
@@ -16,6 +16,7 @@ import (
type preflightModel struct {
dbURL string
rootDomain string
adminHost string
sp spinner.Model
state pfState
@@ -55,11 +56,11 @@ type pfMigApplyMsg struct {
type pfPanelMsg struct{ err error }
func newPreflightModel(dbURL, rootDomain string) *preflightModel {
func newPreflightModel(dbURL, rootDomain, adminHostname string) *preflightModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, sp: sp, state: pfCheckDB}
return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, adminHost: adminHostname, sp: sp, state: pfCheckDB}
}
func (m *preflightModel) Init() tea.Cmd {
@@ -221,7 +222,7 @@ func (m *preflightModel) applyMigrations() tea.Cmd {
func (m *preflightModel) checkPanel() tea.Cmd {
return func() tea.Msg {
return pfPanelMsg{err: checkPanelAccess(m.rootDomain).err}
return pfPanelMsg{err: checkPanelAccess(m.rootDomain, m.adminHost).err}
}
}
+5 -5
View File
@@ -179,7 +179,7 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
}
} else {
rm.stage = stagePreflight
rm.screen = newPreflightModel(dbURL, rootDomain)
rm.screen = newPreflightModel(dbURL, rootDomain, adminHostname)
}
return rm
}
@@ -524,7 +524,7 @@ func (m *rootModel) applyConnectResult(msg connectResultMsg) {
m.result.connectConfigured = true
m.result.reverseProxyGuide = msg.guide
}
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain)
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain, m.adminHost)
}
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
@@ -555,7 +555,7 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
method = connectCloudflare
accessLabel = connectMethodLabel(connectCloudflare)
}
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain)
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain, m.adminHost)
return m.adopt(&summaryModel{
panelURL: m.result.panelURL,
accessLabel: accessLabel,
@@ -564,9 +564,9 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
})
}
func panelURLFor(method connectMethod, panelHostname, rootDomain string) string {
func panelURLFor(method connectMethod, panelHostname, rootDomain, adminHostname string) string {
if method != connectLocal && panelHostname != "" {
return "https://" + panelHostname
}
return localPanelURL(rootDomain)
return localPanelURL(rootDomain, adminHostname)
}
+2
View File
@@ -1031,6 +1031,8 @@ EOF
api-base-url=http://${api_ip}:8081
service-token=${SERVICE_TOKEN}
root-domain=${FELIS_ROOT_DOMAIN}
panel-hostname=console.${FELIS_ROOT_DOMAIN}
admin-hostname=op.console.${FELIS_ROOT_DOMAIN}
login-server=${LOGIN_SERVER}
lobby-server=${LOBBY_SERVER}
EOF
+1 -1
View File
@@ -56,7 +56,7 @@ Configuration (deployment inputs, never compiled in; env wins over a
| `FELIS_SERVICE_TOKEN` | internal service token (secret) | *(required for login)* |
| `FELIS_ROOT_DOMAIN` | deployment zone, builds `https://console.<zone>` | *(required for login)* |
| `FELIS_LOBBY_SERVER` | Velocity server name to transfer to | `lobby` |
| `FELIS_LOGIN_TIMEOUT_SECONDS` | login window (clamped 30–3600) | `300` |
| `FELIS_LOGIN_TIMEOUT_SECONDS` | login window (clamped 30–3600) | `600` |
| `FELIS_HEALTH_PORT` | readiness port | `8080` |
If the API config **or** the root domain is absent the login flow stays **OFF** and
+114 -62
View File
@@ -90,12 +90,12 @@ components:
in: cookie
name: felis_session
description: >-
Opaque local-password session cookie (external face). Minted by
POST /api/v1/auth/login when local auth is enabled, HttpOnly+Secure+
SameSite=Lax and host-only, so an op.console session never reaches the
player console. Only its sha-256 is persisted. SessionAuth prefers this
cookie and otherwise delegates to accessJWT, so the two models coexist on
one face.
Opaque session cookie (external face). Minted by the passwordless
session doors — passkey login, email-OTP, bind code, and op-login
finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console
session never reaches the player console. Only its sha-256 is
persisted. SessionAuth prefers this cookie and otherwise delegates to
accessJWT, so the two models coexist on one face.
responses:
NoContent:
@@ -234,7 +234,7 @@ components:
MyServerView:
type: object
description: One row of the caller's server list (internal/api/repo.go MyServerView).
required: [name, subdomain, owned, claimable]
required: [name, subdomain, owned, claimable, playersOnline, playersMax]
properties:
name: { type: string }
subdomain: { type: string }
@@ -243,6 +243,11 @@ components:
phase:
allOf: [{ $ref: '#/components/schemas/Phase' }]
description: Present only when known.
playersOnline:
type: integer
format: int32
description: Best-effort from live CRD status; 0 when the cluster is unreachable.
playersMax: { type: integer, format: int32 }
BackupView:
type: object
@@ -331,32 +336,30 @@ components:
UserView:
type: object
description: One row of the admin user list (internal/api/repo.go UserView).
required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at]
required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at]
properties:
id: { type: string }
username: { type: string }
email: { type: string }
role: { type: string, enum: [admin, user] }
role: { type: string, enum: [owner, admin, user] }
disabled: { type: boolean }
email_verified: { type: boolean }
server_count: { type: integer }
must_change_password: { type: boolean }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
UserDetail:
type: object
description: Full admin view of one user (internal/api/repo.go UserDetail).
required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at, linked_accounts]
required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at, linked_accounts]
properties:
id: { type: string }
username: { type: string }
email: { type: string }
role: { type: string, enum: [admin, user] }
role: { type: string, enum: [owner, admin, user] }
disabled: { type: boolean }
email_verified: { type: boolean }
server_count: { type: integer }
must_change_password: { type: boolean }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at:
@@ -550,27 +553,6 @@ paths:
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/by-host/{host}:
get:
tags: [servers-internal]
operationId: serverByHost
summary: Resolve a server by its connecting hostname (velocity host routing).
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
parameters:
- { name: host, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The matching server's status projection.
content:
application/json:
schema: { $ref: '#/components/schemas/ServerInfo' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
/api/v1/internal/servers/{name}/ready:
post:
tags: [servers-internal]
@@ -787,12 +769,13 @@ paths:
auth_source:
type: string
enum: [mojang, thirdparty]
default: mojang
description: >
Which Yggdrasil authenticated the in-game UUID (spec §10
dual-Yggdrasil). Optional; an omitted value defaults to the
Mojang-priority source. Captured here because only the in-game
side sees the authentication; it is copied onto the link at verify.
dual-Yggdrasil). Optional; when omitted it is derived from the
UUID's version nibble (felis-nano rewrites third-party profiles
to UUIDv3; Mojang profiles are v4), defaulting to mojang.
Captured here because only the in-game side sees the
authentication; it is copied onto the link at verify.
responses:
'201':
description: Code minted.
@@ -804,6 +787,12 @@ paths:
properties:
code: { type: string }
expires_at: { type: string, format: date-time }
panel_url:
type: string
description: >
Where to redeem the code (https://<panel hostname>). Present
only when a panel hostname is configured, so the in-game
message can print a clickable destination.
'400':
$ref: '#/components/responses/BadRequest'
'401':
@@ -817,10 +806,11 @@ paths:
description: >
Internal-only, read-only. After a new player scans the QR-encoded link code
and the web verify writes the durable account_links row, velocity polls this
for the UUID it minted against and admits the player on linked:true, binding
the in-game session to user_id. Keyed by the verified UUID (not the scanned
code), so it consumes nothing and is safe to poll repeatedly; an unlinked or
never-seen UUID returns linked:false, and user_id is present only when linked.
for the UUID it minted against and admits the player on linked:true. Keyed by
the verified UUID (not the scanned code), so it consumes nothing and is safe
to poll repeatedly; an unlinked or never-seen UUID returns linked:false. The
response is deliberately just the boolean — the plugin keys everything on the
UUID it already holds, so no identity detail crosses back.
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
@@ -828,7 +818,7 @@ paths:
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: Link-completion status; user_id is present only when linked.
description: Link-completion status.
content:
application/json:
schema:
@@ -836,7 +826,6 @@ paths:
required: [linked]
properties:
linked: { type: boolean }
user_id: { type: string }
'401':
$ref: '#/components/responses/Unauthorized'
@@ -970,9 +959,11 @@ paths:
operationId: opLoginPending
summary: List live pending op.console login requests, oldest first (spec §B).
description: >
Internal-only. Velocity polls it and pushes waiting requests to online admins,
who approve one with /felis web op approve <id>. No pending request is secret
to the operator crew.
Internal-only. Lists the requests awaiting an in-game vouch. Today no plugin
consumes it — the staff member reads the request id off the op.console page
and an admin approves it with /felis web op approve <id>; the route exists so
velocity can later push the waiting list to online admins. No pending request
is secret to the operator crew.
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
@@ -1640,6 +1631,62 @@ paths:
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/access/luckperms/{player}:
get:
tags: [access]
operationId: accessLuckPermsInfo
summary: Read a player's LuckPerms groups and permission nodes (spec §7). Owner/admin only.
description: >-
Translates to "lp user <player> permission info" over RCON and parses the
paginated, colour-coded reply (up to 10 pages) into structured entries.
Parent groups (granted group.<name> nodes without a world context) are
split out from plain permission nodes. The raw concatenated RCON output
is echoed back for anything the parser cannot represent.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: player, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Parsed LuckPerms state plus the raw command output.
content:
application/json:
schema:
type: object
required: [player, groups, permissions, output]
properties:
player: { type: string }
groups:
type: array
items: { type: string }
permissions:
type: array
items:
type: object
required: [node, value]
properties:
node: { type: string }
value: { type: boolean, description: "false = negated (§c) node" }
world: { type: string, description: "present only for world-scoped nodes" }
output: { type: string }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Server not running.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/status:
get:
tags: [servers]
@@ -2456,28 +2503,29 @@ paths:
application/json:
schema:
type: object
required: [user_id, email, role, is_admin, must_change_password]
required: [user_id, email, role, is_admin, is_owner, email_verified]
properties:
user_id: { type: string }
email: { type: string, format: email }
role:
type: string
enum: [user, admin]
enum: [user, admin, owner]
description: The principal's role, mirroring users.role.
is_admin:
type: boolean
description: >-
True only when role is admin AND the request arrived via the
admin Access path (Principal.IsAdmin()).
must_change_password:
True only when role is admin or owner AND the request arrived
via the admin Access path (Principal.IsAdmin()).
is_owner:
type: boolean
description: >-
True when a local-password staff account still owes its
first-login password change. Meaningful only on the
local-password path (false on the JWT path). The panel routes
such an account straight to the change-password card. Reachable
while set, alongside change-password and logout, because the
rest of the API is fenced off until the change completes.
True only for the Owner principal on the admin Access path
(Principal.IsOwner()); gates owner-only panel surfaces.
email_verified:
type: boolean
description: >-
Whether the account's email has been verified; the panel
nudges unverified accounts through the email-OTP flow.
'401':
$ref: '#/components/responses/Unauthorized'
@@ -2771,13 +2819,14 @@ paths:
application/json:
schema:
type: object
required: [username, role, password]
required: [username, role]
description: >-
Passwordless: the new account signs in via the session doors
(email-OTP / passkey / bind code); no credential is set here.
properties:
username: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [admin, user] }
password: { type: string, format: password }
must_change_password: { type: boolean, default: true }
responses:
'201':
description: User created.
@@ -3091,7 +3140,10 @@ paths:
summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only).
description: >-
The UUID must not already be bound to a different user (409). Same (user, uuid)
pair is idempotent (200). auth_source defaults to "mojang".
pair is idempotent (200). When auth_source is omitted it is derived from the
UUID's version nibble exactly as on the mint path (v3 → thirdparty, else
mojang), so a force-linked thirdparty account keeps its reclaim-guard
protection.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
+2 -2
View File
@@ -120,10 +120,10 @@ sequenceDiagram
Game->>Game: read verified online-mode UUID
Game->>LinkClient: requestCode(mc_uuid)
LinkClient->>APIInternal: POST /api/v1/internal/account/link/code {mc_uuid}
APIInternal->>APIInternal: validate UUID; default auth_source=mojang if absent; generate 8-symbol code
APIInternal->>APIInternal: validate UUID; derive auth_source from the UUID version nibble if absent (v3 → thirdparty, else mojang); generate 8-symbol code
APIInternal->>Repo: CreateLinkCode(code, mc_uuid, auth_source, expires_at)
Repo-->>APIInternal: inserted account_link_codes row
APIInternal-->>LinkClient: 201 {code, expires_at}
APIInternal-->>LinkClient: 201 {code, expires_at, panel_url?}
LinkClient-->>Game: LinkCode
Game-->>Player: show one-time code in chat
+27 -5
View File
@@ -100,6 +100,12 @@ type API struct {
// console (console.<root_domain>) the gate is inert.
AdminHostname string
// PanelHostname is the player console host (console.<root_domain>) from
// config. Used to render user-facing panel URLs (the /link code's panel_url
// hint); empty falls back to console.<RootDomain> (see panelURL), mirroring
// AdminHostname's fallback.
PanelHostname string
// WakeCooldown throttles repeated wakes per server (spec §9.1: cooldown hangs
// on the wake lever). Zero disables throttling.
WakeCooldown time.Duration
@@ -143,6 +149,21 @@ type API struct {
streamCap *streamLimiter
}
// panelURL returns the public player-console origin ("https://console.<root>"),
// preferring the configured PanelHostname and falling back to the conventional
// console.<RootDomain> label — the same convention hostIsAdminConsole applies
// to the operator host. Empty when neither is configured (a bare test API).
func (a *API) panelURL() string {
host := a.PanelHostname
if host == "" && a.RootDomain != "" {
host = "console." + a.RootDomain
}
if host == "" {
return ""
}
return "https://" + host
}
// now returns the current time using the injected clock.
func (a *API) now() time.Time {
if a.Now != nil {
@@ -237,7 +258,6 @@ func (a *API) internalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
{Method: "GET", Pattern: "/api/v1/servers/by-host/{host}", h: a.handleByHost},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
@@ -282,10 +302,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
// (Mojang-first) and rewrites third-party UUIDs into a per-source namespace
// before returning the canonical profile (handlers_hasjoined.go).
{Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined},
// Op-login (passwordless console login): an in-game op requests a login that
// the web owner/admin approves, then redeems for a session. Internal face
// carries the pending queue and the approve action (service-token auth, no
// Principal); the external face carries the start/status/finish the op drives.
// Op-login (passwordless op.console login): a staff member starts the login
// on the web, and an ONLINE in-game admin vouches for it via velocity's
// /felis web op approve. Internal face carries the pending queue and the
// approve action (service-token auth, no Principal); the public face carries
// the start/status/finish the staff member's browser drives.
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
@@ -362,6 +383,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList},
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission},
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup},
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/luckperms/{player}", h: a.handleAccessLuckPermsInfo},
{Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus},
// Identity self-read (spec §14 tiering): the panel reads this once at boot to
// learn its own tier and decide which navigation surfaces to render. App-tier —
+1 -40
View File
@@ -52,7 +52,7 @@ type fakeRepo struct {
linkAuthSource map[string]string
// world backups (spec §7, §22). A nil slice lists empty.
backups []fakeBackup
// local-password auth (spec §B). staff is keyed by username (the login key);
// session auth (spec §B, passwordless). staff is keyed by username (the login key);
// sessions by token_hash; settings by key. They mirror the PG contract so the
// hermetic tests exercise the same fail-closed semantics the integration impl
// honors.
@@ -1600,45 +1600,6 @@ func TestMeIdentity(t *testing.T) {
})
}
// ---- by-host ----
func TestByHost(t *testing.T) {
cl := newFakeCluster()
cl.bySub["survival"] = &ServerInfo{Name: "survival", Subdomain: "survival", Phase: "Running", Ready: true}
api := newTestAPI(newFakeRepo(), cl)
h := api.InternalHandler()
tok := map[string]string{"Authorization": "Bearer "} // okInternal ignores it
t.Run("foreign domain rejected", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/survival.evil.example.org", "", tok)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
})
t.Run("multi-label rejected", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/a.b."+testRoot, "", tok)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
})
t.Run("unknown server 404", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/creative."+testRoot, "", tok)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
})
t.Run("found", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/survival."+testRoot, "", tok)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var info ServerInfo
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil || info.Name != "survival" {
t.Fatalf("unexpected body %s err %v", w.Body.String(), err)
}
})
}
// ---- fleet (SysAdmin cockpit read) ----
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
+111
View File
@@ -403,6 +403,117 @@ func (a *API) handleAccessGroup(w http.ResponseWriter, r *http.Request) {
})
}
// lpPermissionView is one parsed LuckPerms permission entry returned by the
// luckperms read projector. World is surfaced only when the entry carries a
// world= context (the one context the panel renders); Value comes from the
// entry's color code (LuckPerms renders granted nodes green, negated red).
type lpPermissionView struct {
Node string `json:"node"`
Value bool `json:"value"`
World string `json:"world,omitempty"`
}
// maxLPInfoPages bounds how many "permission info" pages the read projector
// chases per request. LuckPerms paginates its reply, so one command shows only
// the first page; we follow the header's page count up to this cap.
// ponytail: 10 pages ≈ 150 entries — raise if a real user outgrows it.
const maxLPInfoPages = 10
// handleAccessLuckPermsInfo is the read projector for a player's LuckPerms
// state: it runs "lp user <player> permission info" over the same owner-gated
// RCON spine as every access mutation and returns a best-effort parse — parent
// groups split out from plain permission nodes — PLUS the raw reply, like the
// whitelist/players/banlist reads. Page 1 goes through issueAccessCommand (the
// gate); further pages are fetched best-effort directly, so a mid-fetch failure
// keeps what was already read instead of erroring a half-served response.
// No audit (a read).
func (a *API) handleAccessLuckPermsInfo(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
player := r.PathValue("player")
if !mcNameRe.MatchString(player) {
writeError(w, r, errInvalidPlayer)
return
}
out, ok := a.issueAccessCommand(w, r, name, "lp user "+player+" permission info")
if !ok {
return
}
raw := out
entries, pages := parseLuckPermsInfo(out)
for page := 2; page <= pages && page <= maxLPInfoPages; page++ {
more, err := a.Console.RunCommand(r.Context(), name,
fmt.Sprintf("lp user %s permission info %d", player, page))
if err != nil {
break // best-effort: keep the pages we have
}
raw += "\n" + more
e, _ := parseLuckPermsInfo(more)
entries = append(entries, e...)
}
// Split parent groups ("group.<name>", granted, no context) from plain
// permission nodes. A negated or world-scoped group.* entry stays in
// permissions — folding it into groups would lose the negation/scope.
groups := []string{}
permissions := []lpPermissionView{}
for _, e := range entries {
if g, isGroup := strings.CutPrefix(e.Node, "group."); isGroup && e.Value && e.World == "" && lpCtxRe.MatchString(g) {
groups = append(groups, g)
continue
}
permissions = append(permissions, e)
}
writeJSON(w, http.StatusOK, map[string]any{
"player": player, "groups": groups, "permissions": permissions, "output": raw,
})
}
var (
// lpEntryRe matches one "permission info" entry: the "> " marker, then any
// legacy color codes, then the node (lpNodeRe's charset). Anchoring on the
// marker rather than lines follows banEntryRe's rationale: RCON concatenates
// multi-message replies with a server-dependent separator, so a line split is
// unreliable. Group 1 keeps the color codes so the entry's value survives the
// later color strip (§a = granted, §c = negated).
lpEntryRe = regexp.MustCompile(`>\s*((?:§[0-9a-fk-or])*)([A-Za-z0-9_.*-]{1,64})`)
// lpPageRe reads the pagination header ("page 1 of 3") AFTER color stripping.
lpPageRe = regexp.MustCompile(`page\s+(\d+)\s+of\s+(\d+)`)
// lpColorRe strips legacy §-color codes.
lpColorRe = regexp.MustCompile(`§[0-9a-fk-or]`)
// lpWorldRe reads a world= context from an entry's color-stripped tail.
lpWorldRe = regexp.MustCompile(`world=([A-Za-z0-9_-]{1,48})`)
)
// parseLuckPermsInfo extracts permission entries and the total page count from
// one "lp user <player> permission info" reply. Best-effort and
// LuckPerms-specific (INTEGRATION-ONLY against a real server) — the caller
// always returns the raw reply alongside, so an unrecognised format loses
// nothing. An entry's value defaults to granted when no color code precedes the
// node (a color-stripping RCON transport); pages is 0 when no header parses.
func parseLuckPermsInfo(out string) (entries []lpPermissionView, pages int) {
matches := lpEntryRe.FindAllStringSubmatchIndex(out, -1)
for i, m := range matches {
colors := out[m[2]:m[3]]
node := out[m[4]:m[5]]
// The entry's tail (up to the next marker) carries its contexts.
tailEnd := len(out)
if i+1 < len(matches) {
tailEnd = matches[i+1][0]
}
tail := lpColorRe.ReplaceAllString(out[m[5]:tailEnd], "")
e := lpPermissionView{Node: node, Value: !strings.Contains(colors, "§c")}
if wm := lpWorldRe.FindStringSubmatch(tail); wm != nil {
e.World = wm[1]
}
entries = append(entries, e)
}
if pm := lpPageRe.FindStringSubmatch(lpColorRe.ReplaceAllString(out, "")); pm != nil {
pages, _ = strconv.Atoi(pm[2])
}
return entries, pages
}
// parseWhitelistOutput extracts player names from vanilla's "whitelist list"
// reply, whose format is "There are N whitelisted player(s): a, b, c" (and "There
// are no whitelisted players" / a trailing colon for the empty case). The parse
+39 -10
View File
@@ -51,6 +51,26 @@ func validAuthSource(s string) bool {
return s == authSourceMojang || s == authSourceThirdParty
}
// deriveAuthSource infers the auth source from the UUID's version nibble when
// the minting backend omitted auth_source. Felis-nano rewrites every
// third-party profile to a name-based UUIDv3 under its namespace before it ever
// reaches the proxy, while Mojang profiles keep their random v4 — so on a
// nano-fronted deployment the version nibble alone identifies the source, and
// no Java plugin has to learn the field. Anything unparseable keeps the
// historical Mojang-priority default.
func deriveAuthSource(mcUUID string) string {
hex := strings.ReplaceAll(mcUUID, "-", "")
if len(hex) != 32 {
return authSourceMojang
}
switch hex[12] {
case '3':
return authSourceThirdParty
default:
return authSourceMojang
}
}
// newLinkCode returns a cryptographically random, unambiguous link code.
func newLinkCode() (string, error) {
buf := make([]byte, linkCodeLen)
@@ -88,12 +108,13 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
// Default an omitted source to Mojang (spec §10 priority) but reject an
// unrecognised one — a typo'd source must not silently land as a stored value
// the panel will later mislabel.
// Default an omitted source from the UUID's version nibble (v3 = felis-nano
// third-party rewrite, v4 = Mojang; see deriveAuthSource) but reject an
// unrecognised explicit one — a typo'd source must not silently land as a
// stored value the panel will later mislabel.
authSource := req.AuthSource
if authSource == "" {
authSource = authSourceMojang
authSource = deriveAuthSource(req.MCUUID)
}
if !validAuthSource(authSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
@@ -110,10 +131,17 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
// panel_url tells the in-game side where the player redeems the code, so
// every plugin renders the same address from one source of truth instead of
// each baking in its own hostname. Omitted when no hostname is configured.
resp := map[string]any{
"code": code,
"expires_at": expiresAt.UTC(),
})
}
if u := a.panelURL(); u != "" {
resp["panel_url"] = u
}
writeJSON(w, http.StatusCreated, resp)
}
// handleLinkStatus reports whether an in-game UUID has finished linking yet — the
@@ -125,8 +153,9 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
// as a QR → player scans it on a phone already signed in to console.<root_domain>
// → that web session's verify (handleLinkVerify) writes the durable account_links
// row bound to THAT user → velocity polls HERE for the same UUID it minted against
// → on {linked:true} it admits the player, binding the in-game session to user_id
// with no reconnect — the whole point of scanning over typing.
// → on {linked:true} it admits the player with no reconnect — the whole point of
// scanning over typing. The response is deliberately just the boolean: the plugin
// keys everything on the UUID it already holds, so no identity detail crosses back.
//
// The poll is keyed by the verified mc_uuid velocity already holds, not by the
// scanned code, so it is a pure idempotent read of the durable link (UserByMCUUID):
@@ -147,7 +176,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
_, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
switch {
case errors.Is(err, ErrNotFound):
// Not linked yet. For the poller this is simply "keep waiting": velocity
@@ -159,7 +188,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "user_id": userID})
writeJSON(w, http.StatusOK, map[string]any{"linked": true})
}
// linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user
+24
View File
@@ -120,6 +120,30 @@ func TestCreateLinkCode(t *testing.T) {
}
}
})
t.Run("panel_url points at the web console", func(t *testing.T) {
// The mint response carries the redeem address so every plugin renders the
// same hostname from one source of truth (derived console.<root> here).
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
if got := acctBody(t, w)["panel_url"]; got != "https://console."+testRoot {
t.Errorf("panel_url = %v, want https://console.%s", got, testRoot)
}
})
t.Run("omitted auth_source with a v3 UUID derives thirdparty", func(t *testing.T) {
// A felis-nano rewrite is a name-based UUIDv3; the version nibble alone must
// classify it so no Java plugin has to learn the auth_source field.
const v3UUID = "33333333-3333-3333-8333-333333333333"
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+v3UUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
t.Errorf("derived authSource = %q, want %q", rec.authSource, authSourceThirdParty)
}
})
t.Run("explicit thirdparty is stored", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
+9 -9
View File
@@ -7,11 +7,11 @@ import (
)
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
// This is the passwordless counterpart of handleLogin and the returning-player
// counterpart of handleBindRedeem: an account that already proved control of an
// email (email_verified, migration 0010) logs back in with a one-time code mailed
// to that address — no password, no in-game Bind Code. The two halves are Public,
// pre-session routes: the caller has no principal yet, so identity is resolved from
// This is the returning-player counterpart of handleBindRedeem: an account that
// already proved control of an email (email_verified, migration 0010) logs back in
// with a one-time code mailed to that address — no password exists anywhere in the
// product, and no in-game Bind Code is needed the second time. The two halves are
// Public, pre-session routes: the caller has no principal yet, so identity is resolved from
// the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the
// code.
//
@@ -55,9 +55,9 @@ type loginEmailStartRequest struct {
}
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
// pre-session). It gates on local sessions being enabled — like handleLogin and
// handleBindRedeem, minting a code toward a felis_session while SessionAuth would
// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
// pre-session). It gates on local sessions being enabled — like handleBindRedeem
// and the op-login door, minting a code toward a felis_session while SessionAuth
// would reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
// address to an account, and (only if one exists) mints a code under otpPurposeLogin.
// An address with no verified account yields the SAME 202 as a successful send with
// no code minted: the response never distinguishes the two, and the reservation is
@@ -164,7 +164,7 @@ type loginEmailVerifyRequest struct {
// handleLoginEmailVerify redeems a login code into a session (Public, pre-session).
// It resolves the address to an account, verifies the code under otpPurposeLogin, and
// on success mints the same host-only felis_session as handleLogin. A missing account,
// on success mints the same host-only felis_session as handleBindRedeem. A missing account,
// a wrong code, AND an attempt-exhausted (locked) code all return the IDENTICAL 400
// invalid_code, so a code-less caller cannot tell an unknown address from a bad guess
// or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only
+4 -4
View File
@@ -56,7 +56,7 @@ func errEnvelope(t *testing.T, w *httptest.ResponseRecorder) (code, msg string)
// TestLoginEmailVertical walks the whole returning-player slice: a typed lowercase
// address resolves the mixed-case stored account, the code is mailed to the account's
// STORED casing (the address of record), and redeeming it mints the same host-only
// felis_session as the password door — single-use, audited on both halves by the
// felis_session as the other session doors — single-use, audited on both halves by the
// account's username. The redeem never rewrites users.email (login re-proves an
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
// untouched by definition.
@@ -113,7 +113,7 @@ func TestLoginEmailVertical(t *testing.T) {
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("verify body = %v, want user_id:u1 role:user", vb)
}
// The HttpOnly cookie is the whole point — same contract as handleLogin.
// The HttpOnly cookie is the whole point — same contract as every session door.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
@@ -208,8 +208,8 @@ func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
// TestLoginEmailGates covers the shared front doors of both halves: the fail-closed
// local-auth toggle, the cross-site-forgery Content-Type guard (these are Public,
// credential-minting routes — same rationale as handleLogin), and the input gates
// that must reject before any mint or lookup.
// credential-minting routes — same rationale as handleBindRedeem), and the input
// gates that must reject before any mint or lookup.
func TestLoginEmailGates(t *testing.T) {
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
+4 -23
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"strings"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming"
@@ -43,25 +42,6 @@ func (a *API) handleListServers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
}
// handleByHost resolves host=subdomain.{root_domain} to its server (spec §7
// GET /servers/by-host/{host}). The host is validated against the configured
// root domain — the only place the deployment zone enters the lookup.
func (a *API) handleByHost(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.PathValue("host"))
if err := naming.ValidateHostname(host, a.RootDomain); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_host", "invalid host: %v", err))
return
}
subdomain := strings.TrimSuffix(host, "."+a.RootDomain)
info, err := a.Cluster.GetBySubdomain(r.Context(), subdomain)
if err != nil {
a.writeLookupError(w, r, err)
return
}
writeJSON(w, http.StatusOK, info)
}
// handleReady accepts a backend's push that a server is up (spec §7
// /internal/servers/{name}/ready). The RCON probe is the authoritative gate, so
// this is advisory: it audits the signal and returns 204.
@@ -159,8 +139,9 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
return
}
// Global running-server cap (spec §9.1), shared with the external wake. velocity
// treats 503 at_capacity as "cluster full, hold the player", distinct from the
// 429 cooldown's "already waking, keep waiting".
// treats 503 at_capacity as "cluster full, tell the player to try later" and does
// NOT enqueue them (nothing is coming up, so waiting would only strand them),
// distinct from the 429 cooldown's "already waking, keep waiting".
ok, err := a.withinRunningCap(r.Context(), info)
if err != nil {
writeError(w, r, err)
@@ -278,7 +259,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
// the lobby GUI needs to render one server tile, composed from the lifecycle view
// (phase/ready/players from the CRD status) and the business ownership row
// (claimable = nobody owns it yet). It is the only internal response carrying
// claimable, so it has its own shape — the §11 list/by-host/status views never
// claimable, so it has its own shape — the §11 list/status views never
// expose ownership, and folding owner data into ServerInfo would force the
// lifecycle layer to consult Postgres.
//
+6 -5
View File
@@ -32,7 +32,7 @@ import (
// No app-level attempt cap is enforced here (unlike the email-OTP flow, whose 1e6
// keyspace demanded one): the code's ~1e12 keyspace, single use and short TTL make
// blind brute force non-viable, and rate-limiting is deferred to the edge exactly as
// for the public /auth/login. The idempotent returning-player branch (a UUID already
// for the other public session doors (email-OTP, op-login). The idempotent returning-player branch (a UUID already
// linked to a role=user player is fetched, not re-created) is a DELIBERATE standing
// "log in via the game" door, not merely first-time onboarding: control of the
// in-game identity is the root of trust, so re-minting a code always re-grants a
@@ -62,15 +62,16 @@ type bindRedeemRequest struct {
// handleBindRedeem redeems a Bind Code into a player account + session (Public). It is
// the account-less player's only door into console.<root_domain>: no prior principal,
// no Zero Trust in front (unlike op.console). Like handleLogin it is a cookie-minting
// public route, so it requires local sessions to be enabled and a JSON content type
// (the cross-site-forgery guard) and mints the same host-only felis_session cookie.
// no Zero Trust in front (unlike op.console). Like the email-OTP login door it is a
// cookie-minting public route, so it requires local sessions to be enabled and a JSON
// content type (the cross-site-forgery guard) and mints the same host-only
// felis_session cookie.
// The code is trimmed and uppercased so a player who typed it with stray spaces or in
// lowercase still matches, mirroring handleLinkVerify.
func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
// The minted session is a felis_session cookie, honored only when local sessions
// are enabled (SessionAuth). Minting one while they are off would hand back a dead
// cookie, so refuse loudly and consistently with handleLogin. This couples the
// cookie, so refuse loudly, consistently with the other session doors. This couples the
// player bootstrap to the same toggle that gates op.console local login; a future
// deployment wanting player cookies without local admin login would decouple them
// in SessionAuth — out of scope here (KNOWN coupling).
+1 -1
View File
@@ -227,7 +227,7 @@ func TestBindRedeemExpiredCode(t *testing.T) {
// TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that
// SessionAuth would not honor: with local sessions off it returns 403, never a dead
// cookie, mirroring handleLogin.
// cookie, mirroring the email-OTP login door.
func TestBindRedeemLocalAuthDisabled(t *testing.T) {
repo := newFakeRepo() // local_auth_enabled never set → fail closed
api := newTestAPI(repo, newFakeCluster())
+13 -10
View File
@@ -10,14 +10,15 @@ import (
// op.console STAFF login (spec §B op-login): the two-factor door for the most
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
// code), a staff web session is never minted from a single factor. The flow is a
// three-call state machine over op_login_requests (migration 0012), all Public
// three-call state machine over op_login_requests (migration 0016), all Public
// pre-session routes (the caller has no principal yet), plus two internal-face routes
// velocity drives on behalf of online admins:
// for the in-game side (approve is driven by velocity's /felis command; pending has
// no consumer yet — see handleOpLoginPending):
//
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list
// GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
//
// The two factors:
@@ -26,9 +27,9 @@ import (
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
// column keeps it from ever colliding with a console login_email or onboard code).
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
// request via velocity's /felis command (internal approve). Only a linked
// role=admin account may approve; velocity additionally gates the command on
// in-game op, so the API check is defence in depth over its own user table.
// request via velocity's /felis command (internal approve). The API's own user
// table is the sole authority: only a UUID linked to a role=admin account may
// approve (velocity's command runs for any player and relies on this check).
//
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
// code without an approval, or an approval without the code — yields a session.
@@ -317,8 +318,10 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
}
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
// face). Velocity polls it and pushes the waiting requests to online admins, who
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
// face). Today no plugin consumes it: the approver learns the request id out-of-band
// (the op.console start screen shows it to the person logging in) and runs
// /felis web op approve <id>. The route exists so velocity can later push the waiting
// list to online admins without an API change. Internal-only: velocity holds a service
// token and no pending request is secret to the operator crew.
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
@@ -340,8 +343,8 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is role=admin — defence in depth over velocity's own
// in-game op gate, checked against the API's authoritative user table.
// and refuses unless that account is role=admin — this check against the API's
// authoritative user table is the only gate; velocity's command itself is unprivileged.
type opLoginApproveRequest struct {
ApproverUUID string `json:"approver_uuid"`
}
+2 -2
View File
@@ -20,8 +20,8 @@ import (
// all collapse to one op_login_invalid envelope; an early-but-correct code is
// preserved (approval is read before the code is consumed), and a wrong code costs
// an attempt without burning the approval.
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
// API's own user table, defence in depth over velocity's in-game op gate.
// - Admin-only approval. Only a linked role=admin UUID may vouch; the API's own
// user table is the sole gate (velocity's command itself is unprivileged).
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
+14 -14
View File
@@ -12,11 +12,12 @@ func statusPath(mcUUID string) string {
// TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and
// proves its load-bearing invariant: the internal completion poll reports the link
// only after the WEB verify writes it, and reports it bound to the exact Principal
// that verified — never to a UUID the poll itself could name. velocity mints and
// polls on the internal face (it holds no web Principal); the durable bind is born
// on the external face from a logged-in user. That split is the whole security
// model of the scan, so the test drives both faces of one API.
// only after the WEB verify writes it. velocity mints and polls on the internal
// face (it holds no web Principal); the durable bind is born on the external face
// from a logged-in user. That split is the whole security model of the scan, so
// the test drives both faces of one API. The poll carries ONLY the boolean — the
// plugin keys everything on the UUID it already holds, so no identity detail
// (user_id) ever crosses back, in either state.
func TestQRLoginCompletionPollVertical(t *testing.T) {
const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
user := &Principal{UserID: "u-scan", Email: "[email protected]", Role: "user"}
@@ -54,9 +55,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Now the poll flips: velocity sees linked:true and the user_id it must bind the
// in-game session to — and that user_id is the verifier's, the only identity the
// poll could ever return, since the poll cannot mint a link of its own.
// Now the poll flips: velocity sees linked:true and admits the player. The
// response stays identity-free — linked is the entire contract.
w = do(ih, "GET", statusPath(mcUUID), "", nil)
if w.Code != http.StatusOK {
t.Fatalf("post-verify poll: code = %d, want 200 (%s)", w.Code, w.Body.String())
@@ -65,8 +65,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
if b["linked"] != true {
t.Fatalf("post-verify poll body = %v, want linked:true", b)
}
if got := b["user_id"]; got != user.UserID {
t.Fatalf("post-verify poll user_id = %v, want %q (the verifier's id)", got, user.UserID)
if _, ok := b["user_id"]; ok {
t.Fatalf("post-verify poll leaked user_id: %v", b)
}
}
@@ -101,8 +101,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
t.Fatalf("poll %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String())
}
b := acctBody(t, w)
if b["linked"] != true || b["user_id"] != "u-held" {
t.Fatalf("poll %d body = %v, want linked:true user_id:u-held", i, b)
if b["linked"] != true {
t.Fatalf("poll %d body = %v, want linked:true", i, b)
}
}
// The read must not have disturbed the durable link.
@@ -112,8 +112,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
}
// TestQRLoginStatusFaceSeparation enforces that the poll is internal-only. It
// reads who a UUID is linked to — a fact the public web face must not be able to
// fish out by UUID — so crossing onto the external face must 404, not answer.
// reads whether a UUID is linked — a fact the public web face must not be able
// to fish out by UUID — so crossing onto the external face must 404, not answer.
func TestQRLoginStatusFaceSeparation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
api := newTestAPI(newFakeRepo(), newFakeCluster())
+16
View File
@@ -203,6 +203,22 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
// Player counts are presentational and best-effort, mirroring handleFleet's
// owner join: the list exists for ownership/claim state, so a cluster hiccup
// must degrade to 0/0 counts, never 500 the whole list. The CRD status is the
// only source of live counts (spec §1) — Postgres never stores them.
if infos, err := a.Cluster.ListServers(r.Context()); err == nil {
byName := make(map[string]ServerInfo, len(infos))
for _, s := range infos {
byName[s.Name] = s
}
for i := range servers {
if info, ok := byName[servers[i].Name]; ok {
servers[i].PlayersOnline = info.PlayersOnline
servers[i].PlayersMax = info.PlayersMax
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
}
+9 -1
View File
@@ -437,7 +437,15 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
return
}
if body.AuthSource == "" {
body.AuthSource = "mojang"
// Same version-nibble inference as the mint path (handlers_account.go):
// defaulting to mojang here would leave a force-linked thirdparty UUID
// outside the reclaim guard.
body.AuthSource = deriveAuthSource(body.MCUUID)
}
if !validAuthSource(body.AuthSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"auth_source must be %q or %q", authSourceMojang, authSourceThirdParty))
return
}
if err := a.Repo.LinkAccount(r.Context(), userID, body.MCUUID, body.AuthSource); err != nil {
+5 -4
View File
@@ -818,10 +818,11 @@ func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool
// who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim
// exception (spec §B3). The EXISTS joins account_links to users on exactly three
// conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the
// linked user is an admin. It intentionally does not test password_hash: an Operator
// who signs in via SSO (Cloudflare Access, §14) carries role='admin' with a NULL hash
// and must be protected just the same — the hash is orthogonal to "is staff" and "logs
// in via the Login Server". Keyed by UUID, the only identity velocity holds.
// linked user is an admin. It intentionally does not test HOW the account signs in:
// an Operator may authenticate via SSO (Cloudflare Access, §14) or any local
// passwordless door and must be protected just the same — the sign-in method is
// orthogonal to "is staff" and "logs in via the Login Server". Keyed by UUID, the
// only identity velocity holds.
func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) {
var ok bool
err := p.db.QueryRowContext(ctx,
+11 -6
View File
@@ -18,13 +18,18 @@ type ServerRecord struct {
}
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
// may auto-start, or may claim.
// may auto-start, or may claim. PlayersOnline/PlayersMax are NOT stored in
// Postgres — handleMyServers joins them best-effort from the CRD status
// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0, never
// a 500.
type MyServerView struct {
Name string `json:"name"`
Subdomain string `json:"subdomain"`
Owned bool `json:"owned"`
Claimable bool `json:"claimable"`
Phase string `json:"phase,omitempty"`
PlayersOnline int32 `json:"playersOnline"`
PlayersMax int32 `json:"playersMax"`
}
// AuditEntry is one row written to audit_logs (spec §6). The actor is the Access
@@ -197,8 +202,8 @@ type Repo interface {
//
// - code missing/expired → ErrLinkCodeInvalid (does not consume it);
// - the uuid is not yet linked → create a role='user' player row with id
// newUserID (NULL password_hash, username derived from the uuid so it is unique
// and deterministic), write the account_links binding, consume the code, and
// newUserID (username derived from the uuid so it is unique and
// deterministic), write the account_links binding, consume the code, and
// return newUserID;
// - the uuid is already linked to a role='user' player → return THAT user
// (idempotent "log in via the game"), consuming the code;
@@ -456,9 +461,9 @@ type Repo interface {
// Mojang-priority reclaim must never bar them. The predicate is exactly three
// conjuncts: the UUID is linked (account_links), that link authenticated via
// 'thirdparty' (auth_source), and the linked user is an admin (role='admin').
// It deliberately does NOT require a local password hash: an Operator who signs
// in through SSO (Cloudflare Access, IdP-agnostic per §14) carries role='admin'
// with no password_hash, and must be protected all the same — a password hash is
// It deliberately does NOT ask HOW the staff account signs in: an Operator may
// authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local
// passwordless door, and must be protected all the same — the sign-in method is
// orthogonal to both "is staff" and "logs in via the Login Server". An unlinked
// UUID, a Mojang-sourced link, or a non-admin link all yield false, so the
// exception never broadens to ordinary thirdparty players (Mojang priority still
+1 -6
View File
@@ -23,6 +23,7 @@ var reserved = map[string]struct{}{
"lobby": {},
"admin": {},
"panel": {},
"console": {}, // the player web console (console.<root>); op.console carries a dot and can never collide
"api": {},
"felis": {},
"velocity": {},
@@ -112,12 +113,6 @@ func ValidateSystemServerName(name string) error {
return nil
}
// IsReserved reports whether label is on the reserved list.
func IsReserved(label string) bool {
_, ok := reserved[label]
return ok
}
// worldVolumeName mirrors operator.dataVolumeName: the per-server StatefulSet's
// volumeClaimTemplate is named "world", so a single-replica server's world PVC
// is "world-<name>-0". This is the one naming convention shared by the operator
+1
View File
@@ -26,6 +26,7 @@ func TestValidateServerName(t *testing.T) {
{"lobby", false}, // reserved
{"admin", false}, // reserved
{"api", false}, // reserved
{"console", false}, // reserved web console host
}
for _, c := range cases {
err := naming.ValidateServerName(c.name)
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Felis Control Panel</title>
<title>Felis Console</title>
</head>
<body>
<div id="root">Felis panel assets were not built into this binary.</div>
@@ -0,0 +1,28 @@
-- op.console staff sign-in (spec §B op-login): a staff account signs in at
-- op.console with an email-OTP (minted under purpose 'op_login', stored in
-- player_email_otp) PLUS an in-game admin vouching for the attempt via
-- /felis web op approve <request_id>. A row here is the vouch half of that
-- pair: it exists from the moment the OTP checks out until the approved
-- request is exchanged for a session (consumed_at) or expires.
--
-- Lifecycle (derived, no state column): pending while approved_at IS NULL,
-- approved once ApproveOpLogin stamps approved_at/approved_by, dead once
-- consumed_at is set or expires_at passes. Both the approve and the consume
-- UPDATE re-check the full liveness predicate, so a double approval or a
-- replayed finish is a no-op.
CREATE TABLE op_login_requests (
id text PRIMARY KEY, -- opaque handle shown to the staff member and typed in-game
user_id text NOT NULL REFERENCES users(id), -- the staff account signing in
email text NOT NULL, -- snapshot for the audit trail (users.email may change later)
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
consumed_at timestamptz, -- set exactly once by the finish path
approved_at timestamptz, -- set by the in-game admin's approval
approved_by text REFERENCES users(id) -- the approving admin's web account
);
-- ListPendingOpLogins serves the in-game admin's approval prompt: live rows
-- only (pending, unconsumed, unexpired), oldest first.
CREATE INDEX idx_op_login_requests_pending
ON op_login_requests (created_at)
WHERE consumed_at IS NULL AND approved_at IS NULL;
@@ -0,0 +1,11 @@
-- Global passwordless: retire the 0003 password columns.
-- The product no longer has a password anywhere — web sessions are minted only
-- by the passwordless doors (passkey, email-OTP, bind code, op-login vouch) and
-- `felis breakGlass` hands the Owner a one-time setup URL instead of a
-- credential. No code path reads or writes these columns any more, so keeping
-- them would preserve stale bcrypt material for an auth model that cannot use
-- it. Dropping the hashes is deliberate and irreversible: it guarantees no
-- legacy password can ever authenticate again.
ALTER TABLE users
DROP COLUMN password_hash,
DROP COLUMN must_change_password;
+32 -84
View File
@@ -7,7 +7,6 @@ import type {
CreateServerRequest,
FleetServer,
Identity,
LoginResult,
Phase,
ServerInfo,
WhitelistImage,
@@ -43,7 +42,6 @@ interface MockAccount {
role: Role;
email: string;
linked: boolean;
mustChangePassword: boolean;
emailVerified: boolean;
disabled?: boolean;
created_at?: string;
@@ -104,8 +102,8 @@ interface SessionContext extends RequestContext {
const SESSION_COOKIE = "felis_mock_session";
const ROOT_DOMAIN = "dev.felis.localhost";
const API_BASE = "/api/v1";
const MOCK_PASSWORD = "devpassword";
const MOCK_LINK_CODE = "LINK1234";
const MOCK_OTP_CODE = "123456";
const MC_UUID = "00000000-0000-4000-8000-000000000001";
const RESET_ROUTE = `${API_BASE}/__mock/reset`;
@@ -138,7 +136,7 @@ const LOGIN_HINT_STYLE = `
const LOGIN_HINT_SCRIPT = `
(() => {
const id = "felis-mock-login-hint";
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in</strong><div>Admin: <code>owner</code> / <code>${MOCK_PASSWORD}</code></div><div>User: <code>user</code> / <code>${MOCK_PASSWORD}</code> (not linked)</div><div>User: <code>linked</code> / <code>${MOCK_PASSWORD}</code> (linked)</div><div>First login: <code>setup</code> / <code>${MOCK_PASSWORD}</code></div><div>Link code: <code>${MOCK_LINK_CODE}</code></div></aside>';
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in (passwordless)</strong><div>Email OTP: any email / code <code>${MOCK_OTP_CODE}</code> (signs in as <code>owner</code>, admin)</div><div>Link code: <code>${MOCK_LINK_CODE}</code> (signs in as <code>linked</code>, user)</div><div>Passkey: any assertion is accepted (signs in as <code>owner</code>)</div></aside>';
const sync = () => {
const existing = document.getElementById(id);
if (location.pathname === "/login") {
@@ -198,10 +196,9 @@ function mockBackups(): BackupView[] {
function initialState(): MockState {
return {
accounts: {
owner: account("owner", "owner", true, false, false),
user: account("user", "user", false, false, false),
linked: account("linked", "user", true, false, true),
setup: account("setup", "admin", true, true, false),
owner: account("owner", "owner", true, false),
user: account("user", "user", false, false),
linked: account("linked", "user", true, true),
},
images: [
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
@@ -214,29 +211,29 @@ function initialState(): MockState {
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
players: 12,
maxPlayers: 20,
playersOnline: 12,
playersMax: 20,
autostartPolicy: "public",
}),
server("lobby", "Hub Lobby", "Running", "linked", {
players: 28,
maxPlayers: 60,
playersOnline: 28,
playersMax: 60,
autostartPolicy: "public",
}),
server("creative", "Creative Lab", "Stopped", "user", {
autostartPolicy: "public",
maxPlayers: 16,
playersMax: 16,
}),
server("modded", "Modded Testbed", "Starting", "owner", {
autostartPolicy: "allowlist",
maxPlayers: 12,
playersMax: 12,
}),
server("broken", "Broken Node", "Failed", "user", {
autostartPolicy: "ownerOnly",
maxPlayers: 8,
playersMax: 8,
}),
server("claim-me", "Claimable Node", "Stopped", null, {
maxPlayers: 10,
playersMax: 10,
}),
...generatedServers(),
],
@@ -259,7 +256,7 @@ function initialState(): MockState {
"dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer",
"xray_cheater", "fly_hacker",
],
// 12 online, matching the server's players:12 — past the search threshold (>8)
// 12 online, matching the server's playersOnline:12 — past the search threshold (>8)
// and a page (>10) so the roster's filter + paging are both exercisable, with a
// few non-whitelisted names to try kick / ban on.
online: [
@@ -398,8 +395,8 @@ function generatedServers(): MockServer[] {
const max = 10 + ((i * 7) % 50);
out.push(
server(`${theme}-${String(n).padStart(2, "0")}`, `${theme} #${n}`, phase, owners[i % owners.length], {
players: phase === "Running" ? 1 + ((i * 3) % max) : 0,
maxPlayers: max,
playersOnline: phase === "Running" ? 1 + ((i * 3) % max) : 0,
playersMax: max,
autostartPolicy: policies[i % policies.length],
}),
);
@@ -416,13 +413,10 @@ function mockStartupMessage(): string {
` API base: ${API_BASE}`,
` Root domain: ${ROOT_DOMAIN}`,
"",
" Accounts:",
` owner / ${MOCK_PASSWORD} admin, linked`,
` user / ${MOCK_PASSWORD} user, not linked`,
` linked / ${MOCK_PASSWORD} user, linked`,
` setup / ${MOCK_PASSWORD} admin, first-login password change`,
"",
` Link code: ${MOCK_LINK_CODE}`,
" Sign-in (passwordless):",
` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`,
` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`,
" Passkey: any assertion accepted → owner (admin, linked)",
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
"",
].join("\n");
@@ -432,14 +426,12 @@ function account(
id: AccountID,
role: Role,
linked: boolean,
mustChangePassword: boolean,
emailVerified: boolean,
): MockAccount {
return {
id,
role,
linked,
mustChangePassword,
emailVerified,
email: `${id}@mock.felis.local`,
};
@@ -458,8 +450,8 @@ function server(
displayName,
phase,
desiredState: phase === "Stopped" ? "Stopped" : "Running",
players: phase === "Running" ? 1 : 0,
maxPlayers: 20,
playersOnline: phase === "Running" ? 1 : 0,
playersMax: 20,
autostartPolicy: "ownerOnly",
owned: false,
claimable: false,
@@ -515,15 +507,6 @@ function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
function loginAccount(username: string, state: MockState): string | null {
const normalized = username.toLowerCase();
const acc = state.accounts[normalized];
if (acc && !acc.disabled) {
return normalized;
}
return null;
}
function identity(accountInfo: MockAccount): Identity {
return {
user_id: `mock-${accountInfo.id}`,
@@ -531,7 +514,6 @@ function identity(accountInfo: MockAccount): Identity {
role: accountInfo.role,
is_admin: isAdmin(accountInfo.role),
is_owner: isOwner(accountInfo.role),
must_change_password: accountInfo.mustChangePassword,
email_verified: accountInfo.emailVerified,
};
}
@@ -556,8 +538,8 @@ function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[
// fleetView projects the internal mock servers into the GET /fleet wire shape
// (the SysAdmin cockpit's read). It is the mock mirror of the Go fleetServerView:
// the CRD field names (playersOnline/playersMax, ready, endpoint*) — NOT the
// me/servers projection's players/maxPlayers — plus the owner joined as the email
// the CRD field names (playersOnline/playersMax, ready, endpoint*) plus the
// runtime `ready`/`endpoint*` fields, and the owner joined as the email
// (COALESCE(email, username) server-side). Endpoint and live player counts are
// gated on Running, exactly as the real cluster reports them.
function fleetView(state: MockState): FleetServer[] {
@@ -572,8 +554,8 @@ function fleetView(state: MockState): FleetServer[] {
autostartPolicy: s.autostartPolicy,
endpointMode: "domain",
endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined,
playersOnline: ready ? s.players ?? 0 : 0,
playersMax: s.maxPlayers ?? 0,
playersOnline: ready ? s.playersOnline ?? 0 : 0,
playersMax: s.playersMax ?? 0,
owner: s.owner ? state.accounts[s.owner].email : "",
};
});
@@ -592,7 +574,7 @@ function projectServer(serverInfo: MockServer, accountInfo: MockAccount): Server
function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase;
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.players = phase === "Running" ? Math.max(serverInfo.players ?? 0, 1) : 0;
serverInfo.playersOnline = phase === "Running" ? Math.max(serverInfo.playersOnline ?? 0, 1) : 0;
}
function policy(value: unknown): AutostartPolicy {
@@ -615,8 +597,8 @@ function createServer(
const created = server(name, req.displayName?.trim() || name, "Stopped", owner, {
subdomain,
players: 0,
maxPlayers: 20,
playersOnline: 0,
playersMax: 20,
autostartPolicy: policy(req.autostartPolicy),
});
state.servers.unshift(created);
@@ -630,23 +612,6 @@ function sendCreateError(res: ServerResponse, code: CreateError): void {
async function handlePublic(ctx: RequestContext): Promise<boolean> {
switch (route(ctx)) {
case "POST auth/login": {
const body = await readJSON<{ username?: string; password?: string }>(ctx.req);
const accountID = body.username ? loginAccount(body.username.trim(), ctx.state) : null;
if (!accountID || body.password !== MOCK_PASSWORD) {
sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials");
return true;
}
const accountInfo = ctx.state.accounts[accountID];
setSessionCookie(ctx.res, accountID);
const out: LoginResult = {
user_id: `mock-${accountInfo.id}`,
role: accountInfo.role,
must_change_password: accountInfo.mustChangePassword,
};
sendJSON(ctx.res, 200, out);
return true;
}
case "POST auth/bind": {
const body = await readJSON<{ code?: string }>(ctx.req);
const code = body.code?.trim().toUpperCase();
@@ -734,7 +699,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
}
case "POST auth/email/verify": {
const body = await readJSON<{ email?: string; code?: string }>(ctx.req);
if (!body.email || body.code !== "123456") {
if (!body.email || body.code !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
@@ -805,10 +770,6 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
}
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) });
return true;
case "POST auth/change-password":
ctx.account.mustChangePassword = false;
sendJSON(ctx.res, 200, { ok: true });
return true;
case "GET backups":
// Admin sees every archive; a user only worlds they formerly owned — mirrors
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
@@ -838,7 +799,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
}
case "POST account/email/verify": {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim() !== "123456") {
if (body.code?.trim() !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
@@ -931,7 +892,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
must_change_password: acc.mustChangePassword,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at || new Date().toISOString(),
} as UserView;
@@ -971,7 +931,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
role: body.role || "user",
email: body.email || `${username}@example.com`,
linked: false,
mustChangePassword: body.must_change_password ?? false,
emailVerified: true,
disabled: false,
created_at: new Date().toISOString(),
@@ -996,7 +955,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: false,
email_verified: true,
server_count: 0,
must_change_password: newAcc.mustChangePassword,
created_at: newAcc.created_at,
updated_at: newAcc.updated_at,
} as UserView);
@@ -1033,7 +991,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
must_change_password: acc.mustChangePassword,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at || new Date().toISOString(),
linked_accounts,
@@ -1069,7 +1026,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
must_change_password: acc.mustChangePassword,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at,
} as UserView);
@@ -1119,14 +1075,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
return true;
}
// POST /api/v1/users/{id}/reset-password
if (is("POST", ctx) && subAction === "reset-password") {
acc.mustChangePassword = true;
acc.updated_at = new Date().toISOString();
sendJSON(ctx.res, 200, { ok: true, email: acc.email || "" });
return true;
}
// GET /api/v1/users/{id}/quotas
if (is("GET", ctx) && subAction === "quotas") {
if (!acc.quota) {
@@ -1791,7 +1739,7 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean
return true;
}
if (is("GET", ctx) && sub === "players") {
const max = serverInfo.maxPlayers ?? 0;
const max = serverInfo.playersMax ?? 0;
sendJSON(ctx.res, 200, {
name: serverInfo.name,
online: access.online.length,
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Felis · Control Panel</title>
<title>Felis · Console</title>
<script>
try {
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+4 -6
View File
@@ -6,7 +6,6 @@ import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner";
import { Login } from "@/pages/Login";
import { ChangePassword } from "@/pages/ChangePassword";
import { Setup } from "@/pages/Setup";
import { Dashboard } from "@/pages/Dashboard";
import { ServersPage } from "@/pages/servers/ServersPage";
@@ -35,19 +34,18 @@ export default function App() {
<TierProvider>
<BrowserRouter>
<Routes>
{/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
{/* Pre-app sign-in surface (spec §B, passwordless). It sits OUTSIDE
RequireAuth — RequireAuth redirects here — and outside AppShell, so
they render their own centered chrome with no nav/tier dependency. */}
it renders its own centered chrome with no nav/tier dependency. */}
<Route path="/login" element={<Login />} />
<Route path="/change-password" element={<ChangePassword />} />
{/* Owner first-run onboarding. Like /login it sits OUTSIDE RequireAuth:
the visitor arrives from the `felis setup` link with no session, and
redeeming the one-time token is what mints one. */}
<Route path="/setup" element={<Setup />} />
{/* Everything else requires a session. RequireAuth gates the whole app:
no/expired session → /login, forced first-login change →
/change-password, transient /me failure → still renders (graded ZT). */}
no/expired session → /login, transient /me failure → still renders
(graded ZT). */}
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
{/* User-Side — app-tier */}
+11 -93
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Plus, Loader2, Copy, Check } from "lucide-react";
import { Plus, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -27,39 +27,24 @@ interface Props {
onCreated: (id: string) => void;
}
function generateRandomPassword(length = 16): string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$";
let password = "";
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
// Passwordless create (spec §B): the account is minted with no credential at all.
// The new user signs in with an in-game /link bind code (or email-OTP / passkey
// once their address is verified), so there is nothing to hand over here — on
// success we just jump to the new user's detail page.
export function CreateUserDialog({ onCreated }: Props) {
const { t } = useTranslation("admin");
const [open, setOpen] = useState(false);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState<"user" | "admin">("user");
const [mustChange, setMustChange] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Success state fields
const [createdUser, setCreatedUser] = useState<any | null>(null);
const [generatedPassword, setGeneratedPassword] = useState("");
const [copied, setCopied] = useState(false);
function reset() {
setUsername("");
setEmail("");
setRole("user");
setMustChange(true);
setErr(null);
setCreatedUser(null);
setGeneratedPassword("");
setCopied(false);
}
async function handleSubmit(e: React.FormEvent) {
@@ -72,21 +57,19 @@ export function CreateUserDialog({ onCreated }: Props) {
return;
}
const genPassword = generateRandomPassword();
setSubmitting(true);
try {
const u = await api.createUser({
username: username.trim(),
email: email.trim() || undefined,
role,
password: genPassword,
must_change_password: mustChange,
});
setGeneratedPassword(genPassword);
setCreatedUser(u);
setOpen(false);
reset();
onCreated(u.id);
} catch (e: any) {
if (e && e.code === "already_exists") {
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
setErr(t("users_create_validation_username_taken"));
} else {
setErr(humanizeError(e));
}
@@ -95,27 +78,6 @@ export function CreateUserDialog({ onCreated }: Props) {
}
}
const handleCopy = async () => {
if (!createdUser) return;
const text = `Username: ${createdUser.username}\nPassword: ${generatedPassword}`;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
// ignore
}
};
const handleDone = () => {
const id = createdUser?.id;
setOpen(false);
reset();
if (id) {
onCreated(id);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger asChild>
@@ -126,40 +88,10 @@ export function CreateUserDialog({ onCreated }: Props) {
</DialogTrigger>
<DialogContent className="sm:max-w-md" hideClose={submitting}>
<DialogHeader>
<DialogTitle>{createdUser ? t("users_create_success_title") || "创建成功" : t("users_create_title")}</DialogTitle>
<DialogDescription>
{createdUser
? t("users_create_success_desc") || "请务必复制并妥善保管该用户的初始凭据,关闭后密码将不再显示。"
: t("users_create_desc")}
</DialogDescription>
<DialogTitle>{t("users_create_title")}</DialogTitle>
<DialogDescription>{t("users_create_desc")}</DialogDescription>
</DialogHeader>
{createdUser ? (
<div className="space-y-4">
<div className="rounded-md border border-border/50 bg-muted/20 p-4 space-y-3">
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
<div className="font-mono text-sm font-semibold select-all">{createdUser.username}</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_password")}</Label>
<div className="font-mono text-sm font-semibold text-emerald-600 dark:text-emerald-400 select-all">
{generatedPassword}
</div>
</div>
</div>
<DialogFooter className="flex flex-row justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCopy} className="gap-1.5">
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
{copied ? t("common:copied") || "已复制" : t("common:copy") || "复制凭据"}
</Button>
<Button type="button" onClick={handleDone}>
{t("common:done") || "完成"}
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="space-y-1.5">
@@ -205,19 +137,6 @@ export function CreateUserDialog({ onCreated }: Props) {
</Select>
</div>
{/* Must change password toggle */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={mustChange}
onChange={(e) => setMustChange(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm text-foreground">
{t("users_create_must_change")}
</span>
</label>
{err && <MessageLine kind="error" message={err} />}
<DialogFooter>
@@ -231,7 +150,6 @@ export function CreateUserDialog({ onCreated }: Props) {
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
+2 -2
View File
@@ -237,10 +237,10 @@ export function EditServerDialog({
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="es-cpu">CPU 限制</Label>
<Label htmlFor="es-cpu">{t("edit_server_cpu")}</Label>
<Input
id="es-cpu"
placeholder='例如 1, 2, 500m'
placeholder={t("edit_server_cpu_placeholder")}
value={form.cpu}
onChange={(e) => set("cpu", e.target.value)}
/>
+1 -3
View File
@@ -8,7 +8,6 @@ import { useTier } from "@/lib/tier";
//
// loading → a full-screen spinner (never flash login during boot /me)
// unauthenticated → /login (a genuine 401: no/expired session)
// mustChangePassword → /change-password (forced first-login change)
// otherwise → render the app (<Outlet/>)
//
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
@@ -16,7 +15,7 @@ import { useTier } from "@/lib/tier";
// app still renders User-Side, exactly as before local auth existed. Only a true
// 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() {
const { loading, unauthenticated, mustChangePassword } = useTier();
const { loading, unauthenticated } = useTier();
const { t } = useTranslation("common");
if (loading) {
@@ -28,6 +27,5 @@ export function RequireAuth() {
);
}
if (unauthenticated) return <Navigate to="/login" replace />;
if (mustChangePassword) return <Navigate to="/change-password" replace />;
return <Outlet />;
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
<div className="flex items-center gap-1.5 shrink-0 mr-1">
<span>
{running
? `${server.players ?? 0}${server.maxPlayers ? `/${server.maxPlayers}` : ""}`
? `${server.playersOnline ?? 0}${server.playersMax ? `/${server.playersMax}` : ""}`
: "—"}
</span>
<Users className="h-3.5 w-3.5" />
+31 -5
View File
@@ -2,7 +2,7 @@
"title": "Account",
"subtitle": "Identity and Minecraft linking.",
"session": "Session",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by local password sign-in or the platform's identity proxy (Zero-Trust / Access).",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by a passkey / email sign-in or the platform's identity proxy (Zero-Trust / Access).",
"sign_out": "Sign out",
"signing_out": "Signing out…",
"minecraft_link": "Minecraft link",
@@ -21,7 +21,6 @@
"email_verification": "Email Verification",
"email_desc": "Verify your email address to secure your account.",
"email_verified": "Verified",
"email_unverified": "Unverified",
"send_code": "Send Code",
"sending_code": "Sending…",
"email_step1": "Enter Email Address",
@@ -31,16 +30,43 @@
"email_verify_btn": "Verify",
"email_verifying": "Verifying…",
"email_otp_sent": "Verification code sent.",
"otp_code_placeholder": "6-digit code",
"change_email": "Change email",
"continue_btn": "Continue",
"passkeys": "Passkeys",
"passkeys_desc": "Passkeys let you log in securely using your fingerprint, face, or screen lock PIN.",
"no_passkeys": "No registered passkeys.",
"loading_passkeys": "Loading passkeys…",
"add_passkey": "Add Passkey",
"passkey_name": "Device Nickname",
"passkey_name_placeholder": "e.g., My Phone, YubiKey",
"registering_passkey": "Registering…",
"delete_passkey": "Delete",
"deleting_passkey": "Deleting…",
"created_at": "Registered at: ",
"last_used": "Last used: ",
"never": "Never"
"never": "Never",
"migration": "Account migration",
"migration_desc": "Move everything a retired account owns onto this one. Migration starts in-game and finishes here.",
"account_id": "Account ID",
"migration_checking": "Checking migration status…",
"migration_none_prefix": "No migration in progress. To move this account's servers to another account, run ",
"migration_none_suffix": " in-game on the account being retired.",
"migration_confirm_title": "Confirm it's you",
"migration_confirm_desc": "Migration retires this account, so it needs a step-up check first.",
"migration_confirming": "Confirming…",
"migration_confirm_passkey_btn": "Confirm with passkey",
"migration_confirm_otp_btn": "Send a code to my email",
"migration_issue_title": "Name the destination account",
"migration_issue_desc": "Paste the Account ID shown on the destination account's own page here, then issue a one-time transfer code.",
"migration_target_placeholder": "Destination Account ID",
"migration_issuing": "Issuing…",
"migration_issue_btn": "Issue code",
"migration_code_title": "Transfer code (shown once)",
"migration_code_desc": "Sign in as the destination account and redeem this code there before it expires",
"migration_code_pending": "A transfer code has been issued. Redeem it from the destination account before it expires",
"migration_redeem_title": "Redeem a transfer code",
"migration_redeem_desc": "Received a code from an account being retired? Redeem it here to take over its servers.",
"migration_redeem_placeholder": "Transfer code",
"migration_redeeming": "Redeeming…",
"migration_redeem_btn": "Redeem",
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account."
}
+11 -32
View File
@@ -1,12 +1,8 @@
{
"title": "Admin",
"subtitle": "Server & content administration",
"signed_in_as": " · signed in as {{email}}",
"servers": "Servers",
"servers_desc": "Create platform servers from the structured form and manage the ones you operate.",
"images": "Images",
"images_desc": "The platform image whitelist — the value space the create form draws from.",
"server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.",
"images_title": "Images",
"images_subtitle": "The platform image whitelist. Only enabled images can back a new server. You can add external images or delete unwanted images from the whitelist.",
"add_image_title": "Add External Image",
@@ -26,21 +22,14 @@
"context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
"base_image_label": "Base Image",
"base_image_placeholder": "e.g. library/postgres:15",
"build_history_title": "Build History",
"view_logs_btn": "Logs",
"cancel_build_btn": "Cancel",
"no_builds_title": "No Builds Found",
"no_builds_hint": "You can trigger your first image build task using the form on the top right.",
"build_log_title": "Build Log Terminal",
"log_streaming": "Streaming...",
"log_finished": "Finished",
"no_images_title": "No images whitelisted",
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
"enabled": "enabled",
"disabled": "disabled",
"footer_kubectl": "kubectl / CRD operations",
"footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ",
"footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power.",
"table_ref": "Image Reference",
"table_source": "Source",
"table_status": "Status",
@@ -49,6 +38,8 @@
"table_requester": "Requester",
"table_created_at": "Created At",
"table_duration": "Duration",
"build_duration_seconds": "{{s}}s",
"build_duration_minutes": "{{m}}m {{s}}s",
"filter_all": "All",
"filter_enabled": "Enabled",
"filter_disabled": "Disabled",
@@ -74,9 +65,7 @@
"reject_reason": "Rejection Reason",
"updates_title": "Maintenance Window",
"updates_subtitle": "Configure the platform-wide maintenance window. A Scheduled auto-update component may only be applied by Felis within this window; outside it, updates are notify-only.",
"updates_current_title": "Current Setting",
"updates_current_unset": "No maintenance window set. Scheduled updates will degrade to notify-only and will not be applied automatically.",
"updates_current_set": "Felis may apply auto-updates between the following period:",
"updates_start_label": "Start Time",
"updates_end_label": "End Time",
"updates_set_title": "Configure Maintenance Window",
@@ -107,14 +96,10 @@
"users_subtitle": "Manage platform user accounts, quotas, and sessions.",
"users_create_btn": "Create User",
"users_create_title": "Create New User",
"users_create_desc": "Create a new platform account. The user will receive the initial password and will be prompted to change it on first login if the toggle is enabled.",
"users_create_success_title": "User Created Successfully",
"users_create_success_desc": "Please copy and save the initial credentials. Once you close this dialog, the initial password cannot be viewed again!",
"users_create_desc": "Create a new platform account. Accounts are passwordless — the user signs in with an in-game /link bind code, or with email verification / a passkey once bound.",
"users_create_username_placeholder": "e.g. alice",
"users_create_validation_username": "Username is required.",
"users_create_validation_username_taken": "This username is already taken.",
"users_create_validation_password": "Password must be at least 8 characters.",
"users_create_must_change": "Require password change on first login",
"users_search_placeholder": "Search username or email...",
"users_search_btn": "Search",
"users_filter_role_all": "All Roles",
@@ -130,7 +115,6 @@
"users_col_status": "Status",
"users_col_created": "Created",
"users_view_detail": "Details",
"users_total_count": "{{count}} total users",
"users_empty_title": "No Users Found",
"users_empty_hint": "No users match the current filters.",
"users_back_to_list": "Back to user list",
@@ -138,7 +122,6 @@
"users_field_username": "Username",
"users_field_email": "Email",
"users_field_role": "Role",
"users_field_password": "Initial Password",
"users_save_btn": "Save Changes",
"users_save_ok": "Changes saved successfully.",
"users_linked_accounts": "Linked Minecraft Accounts",
@@ -174,25 +157,21 @@
"users_danger_enable": "Enable User",
"users_danger_enable_desc": "Allow this user to log in again.",
"users_danger_enable_btn": "Enable",
"users_danger_reset_pw": "Reset Password",
"users_danger_reset_pw_desc": "A random password will be generated and the user will be forced to change it on next login. All active sessions are revoked immediately.",
"users_danger_reset_pw_desc_email": "A random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All active sessions are revoked immediately.",
"users_danger_reset_pw_btn": "Reset Password",
"users_danger_reset_pw_confirm": "Reset Password",
"users_pw_reset_ok": "Password reset. The new password was sent to {{email}}.",
"users_pw_reset_ok_no_email": "Password reset. Since this user has no email address, it was logged server-side.",
"users_danger_disable_dlg_title": "Disable User",
"users_danger_disable_dlg_desc": "This user will be unable to log in. All active sessions will be revoked immediately.",
"users_danger_enable_dlg_title": "Enable User",
"users_danger_enable_dlg_desc": "This user will be able to log in again.",
"users_danger_reset_pw_dlg_title": "Reset Password",
"users_danger_reset_pw_dlg_desc_email": "A random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All existing sessions will be revoked.",
"users_danger_reset_pw_dlg_desc_no_email": "A random password will be generated. Since this user has no email address, it will be logged server-side. The user will be forced to change it on next login. All existing sessions will be revoked.",
"users_danger_delete_dlg_title": "Delete User",
"users_danger_delete_dlg_desc": "This action is permanent. The user's owned servers will be released, all sessions revoked, and the account permanently disabled. This cannot be undone through the panel.",
"users_danger_delete": "Delete User",
"users_danger_delete_desc": "Soft-delete this user. Owned servers are released, all sessions are revoked, and the account is permanently disabled. This action cannot be undone through the panel.",
"users_danger_delete_btn": "Delete User",
"users_danger_delete_confirm": "This action is permanent. The user's servers will be released and their sessions revoked. Are you absolutely sure?",
"users_danger_delete_yes": "Yes, Delete Permanently"
"users_danger_delete_yes": "Yes, Delete Permanently",
"add_image_desc": "Register an external Docker image reference on the whitelist for later server creation.",
"images_search_placeholder": "Search image name or source...",
"search_no_results": "No matches",
"search_no_results_hint": "Try a different search term or filter.",
"submissions_search_placeholder": "Search modpack name or submitter...",
"trigger_build_desc": "Enter the build parameters to launch a Kaniko pipeline job in an isolated namespace.",
"builds_search_placeholder": "Search build ID, image reference, or status..."
}
+12 -20
View File
@@ -1,18 +1,12 @@
{
"login_title": "Sign in to Felis",
"login_subtitle": "Operator console",
"username": "Username or Email",
"password": "Password",
"sign_in": "Sign in",
"login_subtitle": "Player console",
"login_subtitle_op": "Operator console",
"signing_in": "Signing in…",
"tab_password": "Password",
"tab_bind": "Bind Code",
"tab_email": "Email OTP",
"other_login_methods": "Other sign-in options",
"or_divider": "or",
"tab_email_btn": "Sign in with Email OTP",
"tab_bind_btn": "Sign in with Bind Code",
"back_to_password": "Back to password login",
"tab_op_btn": "Operator sign-in",
"back_to_login": "Back to sign-in options",
"email_address": "Email Address",
"email_placeholder": "Enter your registered email",
"otp_code": "Verification Code",
@@ -23,20 +17,18 @@
"otp_btn": "Verify & Sign In",
"resend_in": "s",
"passkey_btn": "Sign in with Passkey",
"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 /login in-game to generate a one-time bind code.",
"bind_hint": "Type /link in-game to generate a one-time bind code.",
"bind_btn": "Verify & Sign In",
"binding": "Verifying…",
"change_password_title": "Set a new password",
"change_password_subtitle_forced": "Your account was issued a one-time password. Choose a new one to continue.",
"change_password_subtitle_voluntary": "Update your console password.",
"current_password": "Current password",
"new_password": "New password",
"confirm_new_password": "Confirm new password",
"password_mismatch": "Passwords don't match.",
"password_min_length": "At least {{min}} characters.",
"change_password_btn": "Change password",
"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.",
"op_start_btn": "Request operator sign-in",
"op_approve_hint": "A code has been emailed to you. Ask an online operator to approve this request in-game:",
"op_waiting": "Waiting for in-game approval…",
"op_approved": "Approved — enter the code from your email.",
"op_restart": "Start over",
"saving": "Saving…",
"setup_title": "Set up your account",
"setup_welcome": "Welcome, {{name}}",
@@ -5,8 +5,6 @@
"not_yours_title": "No permission to access backups",
"not_yours_body": "Only the owner or an admin can view and restore this server's backups.",
"latest_title": "Latest backup",
"latest_note": "Default restore target. You can also select and restore an older backup from the history below.",
"history_title": "Backup history",
"history_note": "Historical backups can be used for restore before they expire. They are automatically cleaned up when they expire.",
"reason_inactive": "Idle archive",
"reason_manual": "Manual backup",
@@ -29,7 +27,6 @@
"expired_cannot_restore": "This backup has expired and can no longer be restored.",
"col_created": "Backup Time",
"col_size": "Size",
"col_reason": "Type / Reason",
"col_expires": "Expires",
"col_owner": "Former Owner",
"col_actions": "Actions"
+1 -4
View File
@@ -15,10 +15,7 @@
"loading_config": "Loading config…",
"brand_name": "Felis",
"brand_tagline": "K8s-native Minecraft orchestration",
"page_title": "Felis · Control Panel",
"lang_en": "EN",
"lang_zh": "中文",
"lang_toggle_hint": "Switch to {{lang}}",
"page_title": "Felis · Console",
"toggle_theme": "Toggle theme",
"pagination_prev": "Previous",
"pagination_next": "Next",
@@ -1,20 +1,16 @@
{
"title": "Dashboard",
"subtitle": "Your fleet at a glance.",
"no_servers_title": "No servers yet",
"no_servers_hint": "Create your first server or claim an unowned one.",
"go_to_my_servers": "Go to My servers",
"stat_servers": "Servers",
"stat_running": "Running",
"stat_players_online": "Players online",
"fleet": "Fleet",
"manage": "Manage",
"loading_scene": "Loading scene…",
"preparing_scene": "Preparing scene…",
"fleet_load": "Fleet Load & Health",
"active_load": "Player Load Rate",
"status_distribution": "Node Status Distribution",
"account_status": "Account Status",
"account_linked_title": "Account Linked",
"account_linked_desc": "Your identity is bound to a Minecraft UUID. You have full server ownership and wake permissions.",
"account_unlinked_title": "Account Unlinked",
+3 -5
View File
@@ -1,10 +1,8 @@
{
"local_auth_disabled": "Password sign-in is turned off here — reach this console through your organization's secure access.",
"invalid_credentials": "Incorrect username or password.",
"weak_password": "Pick a password between 8 and 72 characters.",
"password_unchanged": "Your new password must differ from the current one.",
"local_auth_disabled": "Direct sign-in is turned off here — reach this console through your organization's secure access.",
"staff_account": "This is a staff account — use Operator sign-in instead.",
"not_linked": "Link your Minecraft account before claiming (Account → Link).",
"invalid_code": "That link code is invalid or expired — run /link again in-game for a fresh code.",
"invalid_code": "That code is invalid or expired — request a fresh one and try again.",
"already_linked": "That Minecraft account is already linked to another user.",
"quota_exceeded": "You have reached your server quota.",
"already_claimed": "Someone else just claimed this server.",
-5
View File
@@ -1,11 +1,6 @@
{
"title": "SysAdmin",
"subtitle": "Platform observability cockpit — a window, not a lever.",
"cluster_wide_fleet": "Cluster-wide fleet",
"open_fleet_table": "Open fleet table →",
"footer_pre": "Control-plane scaling, RBAC, Secrets and cluster lifecycle are ",
"footer_kubectl": "kubectl / CRD operations",
"footer_post": " and are not reachable from here. SysAdmin-Side is observability only — the four-power separation (build / runtime / operator / app) is preserved.",
"fleet_title": "Fleet",
"fleet_subtitle": "Every server on the platform — phase, owner, players online; start, stop and open a console inline.",
"fleet_live": "Live",
+8 -35
View File
@@ -5,11 +5,8 @@
"filter_status_all": "All statuses",
"search_no_match": "No matching servers found.",
"search_clear_btn": "Clear filters",
"servers_count": "{{count}} servers",
"servers_count_filtered": "Showing {{shown}} / {{total}} servers",
"no_servers_linked": "No servers linked to you",
"no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.",
"my_servers_footer": "Servers and game types are provisioned and managed by the platform. You claim a node to operate it; raw cluster config is never exposed here.",
"phase_running": "Running",
"phase_starting": "Starting",
"phase_stopping": "Stopping",
@@ -35,7 +32,6 @@
"console_offline_title": "Console is offline",
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
"my_servers_breadcrumb": "My servers",
"console_card_title": "Console",
"command_placeholder": "Type a command… e.g. list",
"log_connecting": "Connecting…",
"log_live": "Live",
@@ -74,7 +70,6 @@
"access_whitelist_empty": "No players on the whitelist yet.",
"access_whitelist_empty_hint": "Add a player above to let them join.",
"access_whitelist_remove": "Remove {{player}} from the whitelist",
"access_whitelist_remove_q": "Remove?",
"access_remove": "Remove",
"access_whitelist_load_error": "Couldn't load the whitelist.",
"access_whitelist_added": "Added {{player}} to the whitelist.",
@@ -89,14 +84,11 @@
"access_online_raw": "View raw server reply",
"access_updated_at": "Updated {{time}}",
"access_kick_btn": "Kick",
"access_kick_q": "Kick?",
"access_ban_q": "Ban & block rejoin?",
"access_kicked": "Kicked {{player}}.",
"access_ban_title": "Bans",
"access_ban_desc": "Banning kicks a player and blocks them from rejoining. Expand to view the current ban list and pardon in one tap, or enter a full player ID to ban directly.",
"access_ban_btn": "Ban",
"access_pardon_btn": "Pardon",
"access_pardon_q": "Pardon & allow rejoin?",
"access_ban_confirm": "Ban {{player}}? They'll be kicked and blocked from rejoining.",
"access_ban_confirm_yes": "Ban",
"access_ban_empty": "No banned players.",
@@ -129,41 +121,20 @@
"create_server_policy_allowlist": "Allowlist — listed players wake it",
"create_server_cancel": "Cancel",
"create_server_submit": "Create",
"create_server_creating": "Creating…",
"no_servers_managed": "No servers under your management yet",
"no_servers_managed_hint": "Use \"New server\" to provision one from the vetted spec.",
"server_admin_footer": "Server creation goes through the structured form only — the platform maps your choices onto a vetted Kubernetes spec. Raw cluster config (host networking, host paths, arbitrary images, privileged pods) is never expressible here.",
"edit_server_title": "Edit Server Config",
"edit_server_desc": "Configure display name, autostart policy, image, memory, and CPU",
"edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.",
"edit_server_unchanged": "Leave unchanged",
"edit_server_immutable": "Unchanged (Immutable)",
"edit_server_submit": "Save Config",
"edit_server_updating": "Saving…",
"luckperms_dialog_title": "LuckPerms Permissions",
"luckperms_dialog_desc": "Set or unset LuckPerms permission nodes and parent groups on the running server (requires the LuckPerms plugin to be active).",
"luckperms_tab_group": "Groups",
"luckperms_tab_permission": "Permissions",
"luckperms_player_name": "Player Username",
"luckperms_group_name": "Group Name",
"luckperms_node": "Permission Node",
"luckperms_action": "Action",
"luckperms_action_add": "Add to Group (add)",
"luckperms_action_remove": "Remove from Group (remove)",
"luckperms_action_set": "Set Permission (set)",
"luckperms_action_unset": "Unset Permission (unset)",
"luckperms_value": "Value",
"luckperms_value_grant": "Grant (True)",
"luckperms_value_deny": "Deny (False)",
"luckperms_world": "World Context (Optional)",
"luckperms_confirm": "Apply Changes",
"luckperms_executing": "Executing command…",
"luckperms_success": "LuckPerms command executed successfully:",
"luckperms_error_invalid_player": "Invalid player name (1–16 chars: letters, digits, underscore)",
"luckperms_error_invalid_node": "Invalid permission node (allowed: letters, digits, ., -, _, *, 1-64 chars)",
"luckperms_error_invalid_group": "Invalid group name (allowed: letters, digits, -, _, 1-48 chars)",
"luckperms_error_invalid_world": "Invalid world context (allowed: letters, digits, -, _, 1-48 chars)",
"luckperms_title": "LuckPerms Permissions",
"luckperms_desc": "Manage player permission nodes and parent groups on this server (requires the LuckPerms plugin to be active).",
"luckperms_back_to_console": "Back to console",
"luckperms_select_player_prompt": "Please select an online player from the list, or enter a username to query.",
@@ -175,18 +146,20 @@
"luckperms_value_column": "Value",
"luckperms_world_column": "World",
"luckperms_actions_column": "Actions",
"luckperms_query_btn": "Query Player",
"luckperms_custom_group_placeholder": "Enter custom group name...",
"luckperms_batch_players": "Player Usernames List",
"luckperms_batch_players_placeholder": "Enter player names, support multiple (separated by commas or spaces)",
"luckperms_recent_actions": "Recent Actions History",
"luckperms_no_recent_actions": "No recent actions.",
"luckperms_revert": "Revert",
"luckperms_reverting": "Reverting...",
"luckperms_revert_success": "Action reverted successfully!",
"luckperms_quick_presets": "Common Presets",
"luckperms_presets": "Presets",
"luckperms_batch_status": "Batch Progress",
"luckperms_success_count": "Success: {{count}}",
"luckperms_failed_count": "Failed: {{count}}"
"luckperms_no_parent_groups": "No parent groups assigned",
"luckperms_global": "global",
"luckperms_no_perms": "No explicit permission nodes assigned",
"luckperms_rcon_output": "RCON Console Output",
"luckperms_clear_history": "Clear history",
"edit_server_cpu": "CPU Limit",
"edit_server_cpu_placeholder": "e.g. 1, 2, 500m",
"owned_filter_mine": "me"
}
@@ -7,12 +7,9 @@
"display_name_placeholder": "e.g., Pixelmon Adventure Pack",
"file_label": "Build Context (.tar.gz)",
"file_drag_hint": "Drag and drop a .tar.gz file here, or click to browse",
"file_selected": "Selected file: {{name}} ({{size}})",
"submit_btn": "Submit",
"submitting_create": "Creating submission...",
"submitting_upload": "Uploading build context...",
"submit_success": "Modpack submitted successfully!",
"list_card_title": "Submission History",
"filter_all": "All Statuses",
"status_pending_review": "Pending Review",
"status_approved": "Approved",
@@ -21,7 +18,6 @@
"table_status": "Status",
"table_created_at": "Submitted At",
"table_reviewed_by": "Reviewed By",
"table_image_ref": "Image Reference",
"table_reject_reason": "Reject Reason",
"no_submissions_title": "No Submissions Found",
"no_submissions_hint": "You haven't submitted any modpacks yet.",
@@ -29,5 +25,9 @@
"error_file_type": "Please upload a valid .tar.gz file.",
"error_file_size": "File exceeds the allowed size limit.",
"error_name_required": "Display name is required.",
"error_file_required": "Build context file is required."
"error_file_required": "Build context file is required.",
"field_context_ref": "Context Reference",
"field_image_ref": "Image Reference",
"clear_btn": "Clear",
"file_hint": "Supports .tar.gz (max 1GB)"
}
+34 -8
View File
@@ -2,7 +2,7 @@
"title": "账户",
"subtitle": "身份验证与 Minecraft 关联。",
"session": "会话",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由本地密码登录还是平台身份代理(Zero-Trust / Access)签发。",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由 Passkey / 邮箱登录还是平台身份代理(Zero-Trust / Access)签发。",
"sign_out": "退出登录",
"signing_out": "退出中…",
"minecraft_link": "Minecraft 关联",
@@ -10,18 +10,17 @@
"linked_title": "Minecraft 账户已关联。",
"linked_desc": "关联后可认领及管理服务器——所有权相关操作(认领、启动、停止)已解锁。",
"uuid_label": "UUID",
"step1_title": "在游戏中获取验证码",
"step1_title": "在游戏中获取绑定码",
"step1_desc_prefix": "加入任意服务器,在聊天框输入 ",
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性验证码(约 10 分钟内有效)。",
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性绑定码(约 10 分钟内有效)。",
"step2_title": "在此输入",
"link_code": "关联码",
"link_code": "绑定码",
"link_code_placeholder": "ABCD2345",
"verify_btn": "关联",
"verifying": "验证中…",
"email_verification": "邮箱验证",
"email_desc": "验证你的电子邮箱以确保账号安全。",
"email_verified": "已验证",
"email_unverified": "未验证",
"send_code": "获取验证码",
"sending_code": "发送中…",
"email_step1": "输入电子邮箱",
@@ -31,16 +30,43 @@
"email_verify_btn": "验证",
"email_verifying": "验证中…",
"email_otp_sent": "验证码已发送。",
"otp_code_placeholder": "6 位验证码",
"change_email": "修改邮箱",
"continue_btn": "继续",
"passkeys": "Passkey 注册管理",
"passkeys_desc": "Passkey 允许你使用指纹、面容或设备 PIN 码安全登录面板。",
"no_passkeys": "未绑定任何 Passkey。",
"loading_passkeys": "加载 Passkey 列表中…",
"add_passkey": "注册新 Passkey",
"passkey_name": "设备昵称",
"passkey_name_placeholder": "例如:我的手机, YubiKey",
"registering_passkey": "注册中…",
"delete_passkey": "删除",
"deleting_passkey": "删除中…",
"created_at": "注册时间:",
"last_used": "上次使用:",
"never": "从未"
"never": "从未",
"migration": "账户迁移",
"migration_desc": "将被弃用账户名下的所有服务器转移到本账户。迁移在游戏内发起,在此完成。",
"account_id": "账户 ID",
"migration_checking": "正在检查迁移状态…",
"migration_none_prefix": "当前没有进行中的迁移。若要将本账户的服务器转移到其他账户,请用被弃用的账户在游戏内输入 ",
"migration_none_suffix": " 。",
"migration_confirm_title": "确认身份",
"migration_confirm_desc": "迁移会停用本账户,因此需要先完成一次身份核验。",
"migration_confirming": "确认中…",
"migration_confirm_passkey_btn": "使用 Passkey 确认",
"migration_confirm_otp_btn": "发送验证码到我的邮箱",
"migration_issue_title": "指定目标账户",
"migration_issue_desc": "将目标账户本页面显示的「账户 ID」粘贴到此处,然后签发一次性转移码。",
"migration_target_placeholder": "目标账户 ID",
"migration_issuing": "签发中…",
"migration_issue_btn": "签发转移码",
"migration_code_title": "转移码(仅显示一次)",
"migration_code_desc": "请在过期前登录目标账户并在其页面兑换此码",
"migration_code_pending": "转移码已签发。请在过期前用目标账户完成兑换",
"migration_redeem_title": "兑换转移码",
"migration_redeem_desc": "收到了被弃用账户的转移码?在此兑换即可接管其服务器。",
"migration_redeem_placeholder": "转移码",
"migration_redeeming": "兑换中…",
"migration_redeem_btn": "兑换",
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。"
}
+11 -32
View File
@@ -1,12 +1,8 @@
{
"title": "管理",
"subtitle": "服务器与内容管理",
"signed_in_as": " · 当前登录:{{email}}",
"servers": "服务器",
"servers_desc": "通过结构化表单创建并管理平台服务器。",
"images": "镜像",
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
"images_title": "镜像",
"images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。您可以添加外部镜像,或将不需要的镜像从白名单中删除。",
"add_image_title": "添加外部镜像",
@@ -26,21 +22,14 @@
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
"base_image_label": "基础镜像",
"base_image_placeholder": "例如: library/postgres:15",
"build_history_title": "构建历史",
"view_logs_btn": "日志",
"cancel_build_btn": "取消",
"no_builds_title": "暂无构建任务",
"no_builds_hint": "您可以使用右上角表单触发第一个镜像构建任务。",
"build_log_title": "构建日志终端",
"log_streaming": "实时输出中",
"log_finished": "已结束",
"no_images_title": "无白名单镜像",
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
"enabled": "已启用",
"disabled": "已禁用",
"footer_kubectl": "kubectl / CRD",
"footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
"footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。",
"table_ref": "镜像名称",
"table_source": "来源",
"table_status": "状态",
@@ -49,6 +38,8 @@
"table_requester": "发起人",
"table_created_at": "创建时间",
"table_duration": "耗时",
"build_duration_seconds": "{{s}}秒",
"build_duration_minutes": "{{m}}分{{s}}秒",
"filter_all": "全部",
"filter_enabled": "已启用",
"filter_disabled": "已禁用",
@@ -74,9 +65,7 @@
"reject_reason": "驳回理由",
"updates_title": "维护窗口",
"updates_subtitle": "配置全局系统维护窗口。在此窗口内,Felis 可以自动应用系统更新;在窗口外,更新将降级为仅通知,不会自动执行。",
"updates_current_title": "当前设置",
"updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。",
"updates_current_set": "Felis 可在以下时间段内自动执行更新:",
"updates_start_label": "开始时间",
"updates_end_label": "结束时间",
"updates_set_title": "配置维护窗口",
@@ -107,14 +96,10 @@
"users_subtitle": "管理平台用户账号、配额和会话。",
"users_create_btn": "创建用户",
"users_create_title": "创建新用户",
"users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。",
"users_create_success_title": "用户创建成功",
"users_create_success_desc": "请复制并妥善保管该用户的初始凭据。关闭此对话框后,此初始密码将无法再次查看!",
"users_create_desc": "创建一个新的平台账号。账号无密码——用户通过游戏内 /link 绑定码登录,绑定后也可使用邮箱验证码 / Passkey 登录。",
"users_create_username_placeholder": "例如: alice",
"users_create_validation_username": "用户名为必填项。",
"users_create_validation_username_taken": "该用户名已被使用。",
"users_create_validation_password": "密码至少需要 8 个字符。",
"users_create_must_change": "要求首次登录修改密码",
"users_search_placeholder": "搜索用户名或邮箱...",
"users_search_btn": "搜索",
"users_filter_role_all": "全部角色",
@@ -130,7 +115,6 @@
"users_col_status": "状态",
"users_col_created": "创建时间",
"users_view_detail": "详情",
"users_total_count": "共 {{count}} 个用户",
"users_empty_title": "未找到用户",
"users_empty_hint": "没有匹配当前筛选条件的用户。",
"users_back_to_list": "返回用户列表",
@@ -138,7 +122,6 @@
"users_field_username": "用户名",
"users_field_email": "邮箱",
"users_field_role": "角色",
"users_field_password": "初始密码",
"users_save_btn": "保存更改",
"users_save_ok": "更改保存成功。",
"users_linked_accounts": "已关联的 Minecraft 账号",
@@ -174,25 +157,21 @@
"users_danger_enable": "启用用户",
"users_danger_enable_desc": "允许此用户重新登录。",
"users_danger_enable_btn": "启用",
"users_danger_reset_pw": "重置密码",
"users_danger_reset_pw_desc": "将生成随机密码,用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
"users_danger_reset_pw_desc_email": "将生成随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
"users_danger_reset_pw_btn": "重置密码",
"users_danger_reset_pw_confirm": "确认重置",
"users_pw_reset_ok": "密码已重置,新密码已发送至 {{email}}。",
"users_pw_reset_ok_no_email": "密码已重置。该用户未设置邮箱,新密码已记录在服务端日志中。",
"users_danger_disable_dlg_title": "禁用用户",
"users_danger_disable_dlg_desc": "此用户将无法登录。所有活跃会话将被立即撤销。",
"users_danger_enable_dlg_title": "启用用户",
"users_danger_enable_dlg_desc": "此用户将可以重新登录。",
"users_danger_reset_pw_dlg_title": "重置密码",
"users_danger_reset_pw_dlg_desc_email": "将生成随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
"users_danger_reset_pw_dlg_desc_no_email": "将生成随机密码。由于此用户未设置邮箱地址,密码将记录在服务端日志中。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
"users_danger_delete_dlg_title": "删除用户",
"users_danger_delete_dlg_desc": "此操作不可逆。该用户拥有的所有服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
"users_danger_delete": "删除用户",
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
"users_danger_delete_btn": "删除用户",
"users_danger_delete_confirm": "此操作不可逆。该用户的服务器将被释放,会话将被撤销。确定要执行吗?",
"users_danger_delete_yes": "是的,永久删除"
"users_danger_delete_yes": "是的,永久删除",
"add_image_desc": "将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。",
"images_search_placeholder": "搜索镜像名称或来源...",
"search_no_results": "无匹配结果",
"search_no_results_hint": "尝试更换搜索词或筛选条件。",
"submissions_search_placeholder": "搜索模组包名称或提交人...",
"trigger_build_desc": "输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。",
"builds_search_placeholder": "搜索构建 ID、镜像引用或状态..."
}
+12 -20
View File
@@ -1,18 +1,12 @@
{
"login_title": "登录 Felis",
"login_subtitle": "运维控制台",
"username": "用户名或邮箱",
"password": "密码",
"sign_in": "登录",
"login_subtitle": "玩家控制台",
"login_subtitle_op": "运维控制台",
"signing_in": "登录中…",
"tab_password": "账号密码",
"tab_bind": "游戏绑定码",
"tab_email": "邮箱验证码",
"other_login_methods": "其他登录方式",
"or_divider": "或",
"tab_email_btn": "使用邮箱验证码登录",
"tab_bind_btn": "使用游戏绑定码登录",
"back_to_password": "返回密码登录",
"tab_op_btn": "管理员登录",
"back_to_login": "返回其他登录方式",
"email_address": "邮箱地址",
"email_placeholder": "请输入绑定的邮箱",
"otp_code": "验证码",
@@ -23,20 +17,18 @@
"otp_btn": "验证并登录",
"resend_in": "秒后重试",
"passkey_btn": "使用 Passkey 登录",
"passkey_email_hint": "使用 Passkey 请在上方输入绑定邮箱,或留空直接免密登录。",
"bind_code": "绑定码",
"bind_code_placeholder": "例如:ABCD2345",
"bind_hint": "在游戏内输入 /login 即可获取一次性绑定码",
"bind_hint": "在游戏内输入 /link 即可获取一次性绑定码",
"bind_btn": "验证并登录",
"binding": "验证中…",
"change_password_title": "设置新密码",
"change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。",
"change_password_subtitle_voluntary": "修改控制台登录密码。",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_new_password": "确认新密码",
"password_mismatch": "两次输入的密码不一致。",
"password_min_length": "密码至少 {{min}} 个字符。",
"change_password_btn": "修改密码",
"op_hint": "仅限管理员:验证码将发送至您的邮箱,且需要一位在线管理员在游戏内批准此次登录。",
"op_start_btn": "发起管理员登录",
"op_approve_hint": "验证码已发送至您的邮箱。请让一位在线管理员在游戏内批准此次请求:",
"op_waiting": "等待游戏内批准…",
"op_approved": "已批准——请输入邮件中的验证码。",
"op_restart": "重新开始",
"saving": "保存中…",
"setup_title": "初始化你的账户",
"setup_welcome": "欢迎,{{name}}",
@@ -5,8 +5,6 @@
"not_yours_title": "无权访问备份",
"not_yours_body": "只有所有者或管理员才能查看并恢复该服务器的备份。",
"latest_title": "最新备份",
"latest_note": "默认的恢复目标。你也可以从下方的历史备份中选择更早的备份进行恢复。",
"history_title": "历史备份",
"history_note": "历史备份在过期前均可用于恢复。到期后系统会自动清理,无需手动删除或管理。",
"reason_inactive": "闲置自动回收",
"reason_manual": "手动备份",
@@ -29,7 +27,6 @@
"expired_cannot_restore": "此备份已过期,无法恢复。",
"col_created": "创建时间",
"col_size": "大小",
"col_reason": "类型 / 原因",
"col_expires": "过期时间",
"col_owner": "原所有者",
"col_actions": "操作"
+1 -4
View File
@@ -15,10 +15,7 @@
"loading_config": "正在加载配置…",
"brand_name": "Felis",
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
"page_title": "Felis · 控制面板",
"lang_en": "EN",
"lang_zh": "中文",
"lang_toggle_hint": "切换至 {{lang}}",
"page_title": "Felis · 控制台",
"toggle_theme": "切换主题",
"pagination_prev": "上一页",
"pagination_next": "下一页",
@@ -1,20 +1,16 @@
{
"title": "仪表盘",
"subtitle": "服务器运行状态一览。",
"no_servers_title": "暂无服务器",
"no_servers_hint": "创建一个新服务器或认领一台现有服务器即可开始。",
"go_to_my_servers": "前往我的服务器",
"stat_servers": "服务器",
"stat_running": "运行中",
"stat_players_online": "在线玩家",
"fleet": "服务器概览",
"manage": "管理",
"loading_scene": "正在加载 3D 场景…",
"preparing_scene": "正在准备场景…",
"fleet_load": "负载与健康度",
"active_load": "玩家在线负载",
"status_distribution": "节点状态分布",
"account_status": "账号绑定状态",
"account_linked_title": "已关联游戏身份",
"account_linked_desc": "您的 Web 身份已成功绑定至 Minecraft 角色。拥有完整的所有权认领及启动权限。",
"account_unlinked_title": "未关联游戏角色",
+3 -5
View File
@@ -1,10 +1,8 @@
{
"local_auth_disabled": "当前部署已禁用本地密码登录,请通过组织的安全入口访问控制台。",
"invalid_credentials": "用户名或密码错误。",
"weak_password": "密码长度需在 8 到 72 个字符之间。",
"password_unchanged": "新密码不可与当前密码相同。",
"local_auth_disabled": "当前部署已禁用本地登录,请通过组织的安全入口访问控制台。",
"staff_account": "该账户为管理员账户——请使用管理员登录。",
"not_linked": "请先关联 Minecraft 账户(账户页 → 关联)。",
"invalid_code": "关联码无效或已过期——请在游戏中重新输入 /link 获取新码。",
"invalid_code": "代码无效或已过期——请重新获取后再试。",
"already_linked": "该 Minecraft 账户已关联至其他用户。",
"quota_exceeded": "服务器数量已达配额上限。",
"already_claimed": "该服务器已被他人抢先认领。",
-5
View File
@@ -1,11 +1,6 @@
{
"title": "系统管理",
"subtitle": "平台可观测性看板——观察视角,非管理入口。",
"cluster_wide_fleet": "全平台服务器",
"open_fleet_table": "查看全平台服务器列表 →",
"footer_pre": "控制面扩缩、RBAC、Secrets 及集群生命周期属于 ",
"footer_kubectl": "kubectl / CRD",
"footer_post": " 范畴,无法从此处操作。系统管理面仅提供可观测性——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。",
"fleet_title": "全平台服务器",
"fleet_subtitle": "平台所有服务器——运行状态、所有者、在线人数,可直接启停与进入控制台。",
"fleet_live": "实时刷新",
+8 -35
View File
@@ -5,11 +5,8 @@
"filter_status_all": "全部状态",
"search_no_match": "没有匹配的服务器。",
"search_clear_btn": "清除筛选",
"servers_count": "共 {{count}} 台",
"servers_count_filtered": "显示 {{shown}} / {{total}} 台",
"no_servers_linked": "暂无关联的服务器",
"no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。",
"my_servers_footer": "服务器及游戏类型由平台统一管理。认领后即可操作节点,原始集群配置不会暴露在此。",
"phase_running": "运行中",
"phase_starting": "启动中",
"phase_stopping": "停止中",
@@ -35,7 +32,6 @@
"console_offline_title": "控制台未连接",
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
"my_servers_breadcrumb": "我的服务器",
"console_card_title": "控制台",
"command_placeholder": "输入命令…如 list",
"log_connecting": "连接中…",
"log_live": "实时",
@@ -74,7 +70,6 @@
"access_whitelist_empty": "白名单暂无玩家。",
"access_whitelist_empty_hint": "在上方添加玩家即可放行进服。",
"access_whitelist_remove": "将 {{player}} 移出白名单",
"access_whitelist_remove_q": "移除?",
"access_remove": "移除",
"access_whitelist_load_error": "无法加载白名单。",
"access_whitelist_added": "已将 {{player}} 加入白名单。",
@@ -89,14 +84,11 @@
"access_online_raw": "查看服务器原始返回",
"access_updated_at": "更新于 {{time}}",
"access_kick_btn": "踢出",
"access_kick_q": "踢出?",
"access_ban_q": "封禁并禁止再进?",
"access_kicked": "已踢出 {{player}}。",
"access_ban_title": "封禁",
"access_ban_desc": "封禁会将玩家踢出并禁止再次进入。展开可查看当前封禁名单并一键解封,也可输入完整玩家 ID 直接封禁。",
"access_ban_btn": "封禁",
"access_pardon_btn": "解封",
"access_pardon_q": "解封并允许再进?",
"access_ban_confirm": "确认封禁 {{player}}?此玩家将被踢出并无法再进入。",
"access_ban_confirm_yes": "确认封禁",
"access_ban_empty": "暂无封禁玩家。",
@@ -129,41 +121,20 @@
"create_server_policy_allowlist": "白名单——仅名单内玩家可启动",
"create_server_cancel": "取消",
"create_server_submit": "创建",
"create_server_creating": "创建中…",
"no_servers_managed": "你还没有管理的服务器",
"no_servers_managed_hint": "使用「新建服务器」在线分配。",
"server_admin_footer": "服务器仅通过此结构化表单创建——平台将选择映射为审核后的 Kubernetes spec。宿主机网络、宿主机路径、任意镜像、特权 Pod 等原始集群配置在此不可表达。",
"edit_server_title": "编辑服务器配置",
"edit_server_desc": "配置显示名、自启策略、镜像、内存与CPU",
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
"edit_server_unchanged": "保持不变",
"edit_server_immutable": "保持不变 (不可修改)",
"edit_server_submit": "保存配置",
"edit_server_updating": "正在保存…",
"luckperms_dialog_title": "LuckPerms 权限管理",
"luckperms_dialog_desc": "在运行中的服务器上设置或取消玩家的权限节点与用户组(要求 LuckPerms 插件处于运行状态)。",
"luckperms_tab_group": "用户组管理",
"luckperms_tab_permission": "细粒度权限",
"luckperms_player_name": "玩家用户名",
"luckperms_group_name": "用户组名称",
"luckperms_node": "权限节点",
"luckperms_action": "操作类型",
"luckperms_action_add": "添加至用户组 (add)",
"luckperms_action_remove": "从用户组移除 (remove)",
"luckperms_action_set": "设置权限节点 (set)",
"luckperms_action_unset": "取消权限节点 (unset)",
"luckperms_value": "权限值 (Value)",
"luckperms_value_grant": "允许 (True)",
"luckperms_value_deny": "拒绝 (False)",
"luckperms_world": "世界范围 context (可选)",
"luckperms_confirm": "执行变更",
"luckperms_executing": "正在执行命令…",
"luckperms_success": "LuckPerms 命令成功执行:",
"luckperms_error_invalid_player": "玩家用户名格式错误 (支持1-16位英文字母、数字和下划线)",
"luckperms_error_invalid_node": "权限节点格式错误 (支持1-64位字母、数字、点号、横线、下划线及通配符*)",
"luckperms_error_invalid_group": "用户组名称格式错误 (支持1-48位字母、数字、横线和下划线)",
"luckperms_error_invalid_world": "世界范围格式错误 (支持1-48位字母、数字、横线和下划线)",
"luckperms_title": "LuckPerms 权限管理",
"luckperms_desc": "精细化管理服务器上玩家的权限节点与用户组(需要 LuckPerms 插件处于运行状态)。",
"luckperms_back_to_console": "返回控制台",
"luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询",
@@ -175,18 +146,20 @@
"luckperms_value_column": "状态值",
"luckperms_world_column": "生效世界",
"luckperms_actions_column": "操作",
"luckperms_query_btn": "查询玩家",
"luckperms_custom_group_placeholder": "输入自定义组名...",
"luckperms_batch_players": "玩家用户名列表",
"luckperms_batch_players_placeholder": "输入玩家用户名,支持输入多个(用英文逗号或空格分隔)",
"luckperms_recent_actions": "最近操作历史",
"luckperms_no_recent_actions": "暂无最近操作历史。",
"luckperms_revert": "撤销",
"luckperms_reverting": "正在撤销...",
"luckperms_revert_success": "已成功撤销该操作!",
"luckperms_quick_presets": "常用快速预设",
"luckperms_presets": "预设",
"luckperms_batch_status": "批量执行进度",
"luckperms_success_count": "成功: {{count}}",
"luckperms_failed_count": "失败: {{count}}"
"luckperms_no_parent_groups": "未分配任何父组",
"luckperms_global": "全局",
"luckperms_no_perms": "尚未分配任何权限节点",
"luckperms_rcon_output": "RCON 控制台输出",
"luckperms_clear_history": "清除历史记录",
"edit_server_cpu": "CPU 限制",
"edit_server_cpu_placeholder": "例如 1, 2, 500m",
"owned_filter_mine": "我"
}
@@ -7,12 +7,9 @@
"display_name_placeholder": "例如:Pixelmon 冒险包",
"file_label": "构建上下文 (.tar.gz)",
"file_drag_hint": "拖拽 .tar.gz 文件到此处,或点击浏览文件",
"file_selected": "已选择文件: {{name}} ({{size}})",
"submit_btn": "提交",
"submitting_create": "正在创建提交...",
"submitting_upload": "正在上传构建上下文...",
"submit_success": "模组包提交成功!",
"list_card_title": "提交历史",
"filter_all": "全部状态",
"status_pending_review": "等待审核",
"status_approved": "审核通过",
@@ -21,7 +18,6 @@
"table_status": "状态",
"table_created_at": "提交时间",
"table_reviewed_by": "审核人",
"table_image_ref": "镜像引用",
"table_reject_reason": "拒绝原因",
"no_submissions_title": "暂无提交记录",
"no_submissions_hint": "您还没有提交过任何模组包。",
@@ -29,5 +25,9 @@
"error_file_type": "请上传有效的 .tar.gz 压缩文件。",
"error_file_size": "文件超过了允许的大小限制。",
"error_name_required": "必须填写显示名称。",
"error_file_required": "必须上传构建上下文文件。"
"error_file_required": "必须上传构建上下文文件。",
"field_context_ref": "构建上下文引用",
"field_image_ref": "目标镜像引用",
"clear_btn": "清除",
"file_hint": "支持 .tar.gz 格式 (最大 1GB)"
}
+101 -60
View File
@@ -62,57 +62,12 @@ describe("api.me wire shape", () => {
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("surfaces must_change_password from GET /me verbatim", async () => {
// handleMe always emits must_change_password; the forced-change gate routes on
// it, so the snake_case key must survive the untyped boundary unchanged.
const body = {
user_id: "u4",
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: true,
};
vi.stubGlobal("fetch", fakeFetch(body));
const id = await api.me();
expect(id.must_change_password).toBe(true);
});
});
describe("local-password auth wire shapes", () => {
describe("session auth wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("login POSTs {username, password} and returns must_change_password", async () => {
// EXACTLY handlers_auth.go handleLogin's request body and response.
const fetchSpy = fakeFetch({
user_id: "u1",
role: "admin",
must_change_password: true,
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.login("owner", "s3cret");
expect(res.must_change_password).toBe(true);
expect(res.user_id).toBe("u1");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/login");
expect((opts as RequestInit).method).toBe("POST");
expect((opts as RequestInit).credentials).toBe("include");
// The Go login route now REQUIRES Content-Type: application/json (it 415s any
// other type to kill the cross-site form-POST forgery vector). This pins the
// panel half of that contract: a refactor that drops the header silently breaks
// login, and only this assertion would catch it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
username: "owner",
password: "s3cret",
});
});
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
const fetchSpy = fakeFetch({ ok: true });
vi.stubGlobal("fetch", fetchSpy);
@@ -150,31 +105,117 @@ describe("local-password auth wire shapes", () => {
});
});
it("changePassword POSTs {current_password, new_password}", async () => {
const fetchSpy = fakeFetch({ ok: true });
it("maps the auth error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
expect(humanizeError({ code: "staff_account" })).toMatch(/operator/i);
});
// Op-login (the staff door): start hands back the approval handle the panel shows
// as `/felis web op approve <id>`; status is polled; finish spends the mailed code.
// EXACTLY handlers_op_login.go's request/response keys.
it("opLoginStart POSTs {email} and surfaces {request_id, expires_at}", async () => {
const fetchSpy = fakeFetch({
request_id: "req-1",
expires_at: "2026-07-19T00:10:00Z",
});
vi.stubGlobal("fetch", fetchSpy);
await api.changePassword("old-pw", "brand-new-pw");
const res = await api.opLoginStart("o[email protected]");
expect(res.request_id).toBe("req-1");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/change-password");
expect(String(url)).toBe("/auth/op-login/start");
expect((opts as RequestInit).method).toBe("POST");
// Same JSON content-type contract as login — the change-password route guards on
// it too (defense-in-depth), so the panel must keep sending it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
current_password: "old-pw",
new_password: "brand-new-pw",
email: "o[email protected]",
});
});
it("maps the auth error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
it("opLoginStatus GETs /auth/op-login/status/{id} and surfaces approved", async () => {
const fetchSpy = fakeFetch({ approved: true });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.opLoginStatus("req-1");
expect(res.approved).toBe(true);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/op-login/status/req-1");
expect((opts as RequestInit).method).toBe("GET");
});
it("opLoginFinish POSTs {request_id, code}", async () => {
const fetchSpy = fakeFetch({ user_id: "u9", role: "admin" });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.opLoginFinish("req-1", "123456");
expect(res.user_id).toBe("u9");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/op-login/finish");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
request_id: "req-1",
code: "123456",
});
});
});
// Pin the §B3 migration wire shapes (handlers_account_migrate.go). The status union
// ({active:false} | {active:true, state, ...}) and the issue/redeem bodies cross the
// untyped fetch().json() boundary, so a key drift leaves the Account migration card
// inert while typecheck/build stay green.
describe("account migration wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("migrateStatus GETs /account/migrate and surfaces the state-machine fields", async () => {
const fetchSpy = fakeFetch({
active: true,
state: "confirmed",
confirm_factor: "email_otp",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateStatus();
expect(res.active).toBe(true);
expect(res.state).toBe("confirmed");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("migrateIssueCode POSTs {target_user_id} and surfaces the one-time code", async () => {
const fetchSpy = fakeFetch({
code: "MIGR-1234",
expires_at: "2026-07-19T00:10:00Z",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateIssueCode("u2");
expect(res.code).toBe("MIGR-1234");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate/issue-code");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
target_user_id: "u2",
});
});
it("migrateRedeem POSTs {code} and surfaces the moved servers", async () => {
const fetchSpy = fakeFetch({ migrated: true, servers_moved: 2, servers: ["a", "b"] });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateRedeem("MIGR-1234");
expect(res.servers_moved).toBe(2);
expect(res.servers).toEqual(["a", "b"]);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate/redeem");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
code: "MIGR-1234",
});
});
});
+63 -26
View File
@@ -12,7 +12,6 @@ import type {
KickResult,
LinkResult,
LinkStatus,
LoginResult,
BindResult,
PatchUserRequest,
PlayersResult,
@@ -108,13 +107,10 @@ export interface SetupState {
}
export const api = {
// Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
// side effect — the panel never sees it — and returns only what to route on next
// (must_change_password forces the change card before any other surface). The
// username/password pair is the ONLY local credential; Passkey/PWA are Phase
// B2/C. login may 403 `local_auth_disabled` on a Zero-Trust-only deployment.
login: (username: string, password: string) =>
request<LoginResult>("POST", "/auth/login", { username, password }),
// Session doors (spec §B). The product is passwordless: a session is minted only
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
// Zero-Trust-only deployment.
// logout is idempotent server-side (clears the session row + cookie); calling it
// without a session still resolves 200. After it, refreshing /me yields 401, which
@@ -142,14 +138,21 @@ export const api = {
authPasskeyDiscoverableFinish: (login_id: string, assertion: any) =>
request<any>("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }),
// changePassword is callable during the first-login lockdown (the route is
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an
// unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
// every OTHER session. The caller's own session is kept, so no re-login is needed.
changePassword: (current_password: string, new_password: string) =>
request<{ ok: boolean }>("POST", "/auth/change-password", {
current_password,
new_password,
// Op-login (spec §B): the staff door. start mails an OTP to a staff address and
// returns a request handle; an online admin vouches in-game with
// `/felis web op approve <request_id>`; the panel polls status until approved,
// then finish redeems {request_id, code} into a session. start answers 202 with a
// request_id for ANY well-formed address (anti-enumeration), so the UI just waits.
opLoginStart: (email: string) =>
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
opLoginStatus: (id: string) =>
request<{ approved: boolean }>("GET", `/auth/op-login/status/${encodeURIComponent(id)}`),
opLoginFinish: (request_id: string, code: string) =>
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
request_id,
code,
}),
// Setup bootstrap (spec §B). redeem consumes the one-time token from the setup URL
@@ -369,6 +372,46 @@ export const api = {
passkeyDelete: (id: string) =>
request<void>("DELETE", `/account/passkey/credentials/${id}`),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
// otherwise) → issue-code (source names the target account and reads the one-time
// code) → redeem (the TARGET account spends the code; the source's servers move to
// it and the source is retired).
migrateStatus: () =>
request<{
active: boolean;
state?: string;
target_user_id?: string;
confirm_factor?: string;
code_expires_at?: string;
}>("GET", "/account/migrate"),
migrateConfirmOTPStart: () =>
request<{ sent: boolean; expires_at: string }>("POST", "/account/migrate/confirm/otp/start"),
migrateConfirmOTPVerify: (code: string) =>
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/otp/verify", { code }),
migrateConfirmPasskeyBegin: () =>
request<any>("POST", "/account/migrate/confirm/passkey/begin"),
migrateConfirmPasskeyFinish: (assertion: any) =>
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/passkey/finish", {
assertion,
}),
migrateIssueCode: (target_user_id: string) =>
request<{ code: string; expires_at: string }>("POST", "/account/migrate/issue-code", {
target_user_id,
}),
migrateRedeem: (code: string) =>
request<{ migrated: boolean; servers_moved: number; servers: string[] }>(
"POST",
"/account/migrate/redeem",
{ code },
),
listSubmissions: () =>
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
@@ -429,9 +472,6 @@ export const api = {
disableUser: (id: string, disabled: boolean) =>
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
resetUserPassword: (id: string) =>
request<{ ok: boolean; email: string }>("POST", `/users/${id}/reset-password`),
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
setUserQuotas: (id: string, quotas: QuotaInput) =>
@@ -496,15 +536,12 @@ export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>;
switch (err.code) {
// Local-password auth (spec §B1).
// Session doors (spec §B): every passwordless door 403s this when local
// sessions are disabled on a Zero-Trust-only deployment.
case "local_auth_disabled":
return t("local_auth_disabled");
case "invalid_credentials":
return t("invalid_credentials");
case "weak_password":
return t("weak_password");
case "password_unchanged":
return t("password_unchanged");
case "staff_account":
return t("staff_account");
case "not_linked":
return t("not_linked");
case "invalid_code":
+3 -13
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { deriveAuth, isUnauthorized } from "./auth";
import type { Identity } from "./types";
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login,
// who is forced through the change-password card, and — critically — who is KEPT in
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
// and — critically — who is KEPT in
// the app despite a /me failure. The one distinction that must never blur is a true
// 401 (no session → login) versus any other failure (transient → stay functional),
// because mistaking the latter for the former would log out a healthy Zero-Trust
@@ -14,7 +14,6 @@ const admin: Identity = {
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: false,
is_owner: false,
};
@@ -41,7 +40,6 @@ describe("deriveAuth", () => {
expect(s.loading).toBe(true);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
});
it("a settled 401 with no identity is unauthenticated (→ /login)", () => {
@@ -61,21 +59,13 @@ describe("deriveAuth", () => {
const s = deriveAuth(admin, null, false);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(true);
expect(s.mustChangePassword).toBe(false);
});
it("surfaces must_change_password from the identity", () => {
const s = deriveAuth({ ...admin, must_change_password: true }, null, false);
expect(s.mustChangePassword).toBe(true);
expect(s.unauthenticated).toBe(false);
});
it("fails closed on a malformed identity missing is_admin / must_change_password", () => {
it("fails closed on a malformed identity missing is_admin", () => {
// Mirrors the wire-shape trap: absent fields are undefined, not thrown access.
const partial = { user_id: "u", email: "e", role: "user" } as unknown as Identity;
const s = deriveAuth(partial, null, false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
expect(s.unauthenticated).toBe(false);
});
});
+2 -5
View File
@@ -1,7 +1,7 @@
import type { ApiError, Identity } from "./types";
// Pure auth-state derivation, kept out of tier.tsx so it can be pinned without a
// React renderer (mirrors lib/nav.ts). The whole local-password gate turns on one
// React renderer (mirrors lib/nav.ts). The whole session gate turns on one
// distinction the rest of the app routes on: a /me that returns 401 means "there
// is genuinely no session — show the login page", whereas ANY OTHER /me failure
// (network, 5xx, timeout) must NOT log the user out. The latter preserves the
@@ -20,8 +20,6 @@ export interface AuthState {
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
* transient or 5xx failure leaves this false so the app keeps rendering. */
unauthenticated: boolean;
/** True when the loaded identity still owes a forced first-login change. */
mustChangePassword: boolean;
}
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
@@ -39,7 +37,7 @@ export function isUnauthorized(error: unknown): boolean {
/** deriveAuth folds one /me outcome (identity OR error, plus the in-flight flag)
* into the state the router reads. Every boolean is computed with `=== true` / an
* explicit 401 check so an absent or malformed field fails to the safe side:
* non-admin, still-authenticated, no forced change. */
* non-admin, still-authenticated. */
export function deriveAuth(
identity: Identity | null,
error: unknown,
@@ -50,6 +48,5 @@ export function deriveAuth(
loading,
isAdmin: identity?.is_admin === true,
unauthenticated: !loading && identity === null && isUnauthorized(error),
mustChangePassword: identity?.must_change_password === true,
};
}
+6
View File
@@ -6,6 +6,10 @@
export interface RuntimeConfig {
apiBase: string;
rootDomain: string;
/** Player-console hostname (console.<root>), absent when unconfigured. */
panelHostname?: string;
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */
adminHostname?: string;
}
const FALLBACK: RuntimeConfig = {
@@ -26,6 +30,8 @@ export async function loadConfig(): Promise<RuntimeConfig> {
cached = {
apiBase: raw.apiBase ?? FALLBACK.apiBase,
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
panelHostname: raw.panelHostname,
adminHostname: raw.adminHostname,
};
} catch {
cached = FALLBACK;
+2 -4
View File
@@ -26,9 +26,8 @@ import { deriveAuth, type AuthState } from "./auth";
// simply don't see admin surfaces. (The backend 403s admin data calls
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
//
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login;
// `mustChangePassword` forces the change-password card; `refresh()` re-reads /me
// after a login / change / logout so the gate re-evaluates without a reload.
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
//
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
@@ -47,7 +46,6 @@ const TierContext = createContext<TierState>({
isAdmin: false,
isOwner: false,
unauthenticated: false,
mustChangePassword: false,
refresh: async () => {},
});
+8 -25
View File
@@ -25,8 +25,8 @@ export interface ServerInfo {
displayName?: string;
phase: Phase;
desiredState?: "Running" | "Stopped";
players?: number;
maxPlayers?: number;
playersOnline?: number;
playersMax?: number;
autostartPolicy?: AutostartPolicy;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable?: boolean;
@@ -74,7 +74,7 @@ export interface AccessResult {
}
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
* ONLY source of WHO is online — ServerInfo.players carries the count alone.
* ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
@@ -101,10 +101,9 @@ export interface KickResult {
* projection plus the owner joined read-only from Postgres for display.
*
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
* shape — `playersOnline`/`playersMax` (not players/maxPlayers), plus `ready` and
* the `endpoint*` runtime fields — whereas ServerInfo is the /me/servers
* projection. Sharing one interface would silently read `undefined` across the
* fetch().json() boundary for every renamed field. */
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
* required — whereas ServerInfo is the /me/servers projection with them optional.
* Sharing one interface would blur which fields each face actually guarantees. */
export interface FleetServer {
name: string;
subdomain: string;
@@ -213,24 +212,9 @@ export interface Identity {
/** Server-computed Principal.IsOwner() — true only for the platform-level
* owner account (one above admin). Owners get user management; admins don't. */
is_owner: boolean;
/** Local-password path only: the account owes a forced first-login password
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
* absent field is `undefined` (correctly "no change owed"), never a thrown access. */
must_change_password: boolean;
email_verified?: boolean;
}
/** LoginResult mirrors POST /api/v1/auth/login (handlers_auth.go handleLogin). The
* session cookie is set as a side effect (HttpOnly, so the panel never sees it);
* the body carries only what the panel routes on next — chiefly whether to force the
* change-password card before any other surface. */
export interface LoginResult {
user_id: string;
role: "user" | "admin" | "owner";
must_change_password: boolean;
}
export interface BindResult {
user_id: string;
linked: boolean;
@@ -288,7 +272,6 @@ export interface UserView {
disabled: boolean;
email_verified: boolean;
server_count: number;
must_change_password: boolean;
created_at: string;
updated_at: string;
}
@@ -304,12 +287,12 @@ export interface UserDetail extends UserView {
linked_accounts: LinkedAccount[];
}
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
* the new account signs in via email-OTP / passkey / bind code, never a password. */
export interface CreateUserRequest {
username: string;
email?: string;
role: "admin" | "user";
password: string;
must_change_password: boolean;
}
export interface PatchUserRequest {
+258 -7
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type FormEvent } from "react";
import { CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { ArrowRightLeft, CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -178,8 +178,9 @@ export function Account() {
}
}
// Sign-out ends a local-password session: clear it server-side, then refresh /me.
// For a local session that read now 401s → the tier model flips to
// Sign-out ends a local session (passkey / email-OTP / bind-code / op-login):
// clear it server-side, then refresh /me. For a local session that read now 401s
// → the tier model flips to
// `unauthenticated` and RequireAuth bounces this page to /login, so no explicit
// navigation is needed. (On a Zero-Trust proxied session there is no local cookie
// to drop and /me still succeeds — sign-out is a no-op, which is the honest
@@ -304,7 +305,7 @@ export function Account() {
onClick={() => setEmailSent(false)}
className="h-auto p-0 font-normal"
>
修改邮箱
{t("change_email")}
</Button>
</div>
)}
@@ -387,10 +388,10 @@ export function Account() {
onClick={cancelRegistration}
disabled={registeringPasskey}
>
取消
{t("common:cancel")}
</Button>
<Button type="submit" disabled={registeringPasskey || !passkeyNickname.trim()}>
{registeringPasskey ? t("registering_passkey") : "继续"}
{registeringPasskey ? t("registering_passkey") : t("continue_btn")}
</Button>
</DialogFooter>
</form>
@@ -400,7 +401,7 @@ export function Account() {
<CardContent className="text-sm space-y-4">
<p className="text-muted-foreground">{t("passkeys_desc")}</p>
{passkeys.loading && !passkeys.data ? (
<Loading label="加载 Passkey 列表中..." />
<Loading label={t("loading_passkeys")} />
) : passkeys.error ? (
<ErrorState error={passkeys.error} onRetry={passkeys.reload} />
) : !passkeys.data?.credentials || passkeys.data.credentials.length === 0 ? (
@@ -441,6 +442,11 @@ export function Account() {
</CardContent>
</Card>
<MigrationCard
userId={identity?.user_id}
hasPasskey={(passkeys.data?.credentials?.length ?? 0) > 0}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -554,6 +560,251 @@ function LinkForm({
);
}
/** MigrationCard is the web half of §B3 account migration (scenario A "inherit").
* The flow is born in-game (/felis migrate proves the player) and driven here:
* status → step-up confirm (passkey when one is enrolled — the server 409s the
* OTP door in that case — else email-OTP) → issue-code (the source names the
* target account and reads a one-time code) → redeem (the TARGET account spends
* the code; the source's servers move over and the source is retired). Both
* roles render on every account: the redeem form is always offered, and the
* account id is always shown so a target can hand it to the source. */
function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: boolean }) {
const { t } = useTranslation("account");
const mig = useAsync(() => api.migrateStatus(), []);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [otpSent, setOtpSent] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [targetId, setTargetId] = useState("");
const [issued, setIssued] = useState<{ code: string; expires_at: string } | null>(null);
const [redeemCode, setRedeemCode] = useState("");
const [redeemed, setRedeemed] = useState<{ servers_moved: number; servers: string[] } | null>(null);
async function run(fn: () => Promise<void>) {
if (busy) return;
setBusy(true);
setErr(null);
try {
await fn();
} catch (e) {
setErr(humanizeError(e));
} finally {
setBusy(false);
}
}
// Step-up passkey confirm. Unlike the register/login begins (which strip the
// envelope server-side), the migrate begin returns go-webauthn's raw
// {"publicKey": {...}} document, so we descend into .publicKey here.
function confirmWithPasskey() {
void run(async () => {
const options = await api.migrateConfirmPasskeyBegin();
const pk = options.publicKey;
const publicKey: PublicKeyCredentialRequestOptions = {
...pk,
challenge: base64urlToBytes(pk.challenge),
allowCredentials: pk.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential;
if (!credential) throw new Error("Failed to get credential");
const response = credential.response as AuthenticatorAssertionResponse;
await api.migrateConfirmPasskeyFinish({
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
});
await mig.reload();
});
}
const state = mig.data?.active ? mig.data.state : undefined;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ArrowRightLeft className="h-4 w-4 text-primary" /> {t("migration")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">{t("migration_desc")}</p>
{userId && (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs uppercase tracking-wide">{t("account_id")}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground select-all">
{userId}
</code>
</div>
)}
{mig.loading && !mig.data ? (
<Loading label={t("migration_checking")} />
) : mig.error ? (
<ErrorState error={mig.error} onRetry={mig.reload} />
) : (
<>
{!mig.data?.active && !redeemed && (
<p className="text-muted-foreground">
{t("migration_none_prefix")}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
/felis migrate
</code>
{t("migration_none_suffix")}
</p>
)}
{state === "initiated" && (
<div className="space-y-2">
<p className="font-medium text-foreground">{t("migration_confirm_title")}</p>
<p className="text-muted-foreground">{t("migration_confirm_desc")}</p>
{hasPasskey ? (
<Button size="sm" onClick={confirmWithPasskey} disabled={busy}>
<Fingerprint className="mr-2 h-4 w-4" />
{busy ? t("migration_confirming") : t("migration_confirm_passkey_btn")}
</Button>
) : !otpSent ? (
<Button
size="sm"
disabled={busy}
onClick={() =>
void run(async () => {
await api.migrateConfirmOTPStart();
setOtpSent(true);
})
}
>
<Mail className="mr-2 h-4 w-4" />
{busy ? t("sending_code") : t("migration_confirm_otp_btn")}
</Button>
) : (
<form
className="flex gap-2 max-w-md"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api.migrateConfirmOTPVerify(otpCode.trim());
await mig.reload();
});
}}
>
<Input
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
placeholder={t("otp_code_placeholder")}
maxLength={6}
disabled={busy}
className="max-w-[12rem] font-mono text-center tracking-[0.2em]"
/>
<Button type="submit" size="sm" disabled={busy || otpCode.trim().length !== 6}>
{busy ? t("migration_confirming") : t("email_verify_btn")}
</Button>
</form>
)}
</div>
)}
{state === "confirmed" && !issued && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
setIssued(await api.migrateIssueCode(targetId.trim()));
await mig.reload();
});
}}
>
<p className="font-medium text-foreground">{t("migration_issue_title")}</p>
<p className="text-muted-foreground">{t("migration_issue_desc")}</p>
<div className="flex gap-2 max-w-md">
<Input
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder={t("migration_target_placeholder")}
disabled={busy}
className="font-mono"
/>
<Button type="submit" size="sm" disabled={busy || !targetId.trim()}>
{busy ? t("migration_issuing") : t("migration_issue_btn")}
</Button>
</div>
</form>
)}
{issued && (
<div className="space-y-2">
<p className="font-medium text-foreground">{t("migration_code_title")}</p>
<code className="block w-fit rounded bg-muted px-3 py-2 font-mono text-base tracking-[0.2em] text-foreground select-all">
{issued.code}
</code>
<p className="text-xs text-muted-foreground">
{t("migration_code_desc")} ({new Date(issued.expires_at).toLocaleString()})
</p>
</div>
)}
{state === "code_issued" && !issued && (
<p className="text-muted-foreground">
{t("migration_code_pending")}{" "}
{mig.data?.code_expires_at &&
`(${new Date(mig.data.code_expires_at).toLocaleString()})`}
</p>
)}
{redeemed ? (
<div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t("migration_redeemed", { count: redeemed.servers_moved })}
</div>
) : (
!mig.data?.active && (
<form
className="space-y-2 border-t pt-4"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
setRedeemed(await api.migrateRedeem(redeemCode.trim()));
});
}}
>
<p className="font-medium text-foreground">{t("migration_redeem_title")}</p>
<p className="text-muted-foreground">{t("migration_redeem_desc")}</p>
<div className="flex gap-2 max-w-md">
<Input
value={redeemCode}
onChange={(e) => setRedeemCode(e.target.value)}
placeholder={t("migration_redeem_placeholder")}
disabled={busy}
className="font-mono"
/>
<Button type="submit" size="sm" disabled={busy || !redeemCode.trim()}>
{busy ? t("migration_redeeming") : t("migration_redeem_btn")}
</Button>
</div>
</form>
)
)}
{err && <p className="text-sm text-destructive">{err}</p>}
</>
)}
</CardContent>
</Card>
);
}
function StepBadge({ n }: { n: number }) {
return (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
-135
View File
@@ -1,135 +0,0 @@
import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { api, humanizeError } from "@/lib/api";
// Minimum new-password length. The server is the source of truth (8–72 BYTES, the
// bcrypt limit); this is only a pre-submit courtesy so the obvious case fails
// instantly rather than round-tripping to a `weak_password` error.
const MIN_PASSWORD = 8;
// ChangePassword is the forced first-login change AND the voluntary change surface
// (spec §B1). It lives OUTSIDE RequireAuth on purpose: RequireAuth redirects a
// must-change principal *to* this page, so nesting it under that gate would loop.
// It therefore re-checks auth itself — a 401 principal is sent to /login.
//
// On success the server keeps the caller's own session (revoking only the others),
// so no re-login is needed: we refresh /me — which now reports must_change_password
// false — and continue into the app.
export function ChangePassword() {
const { loading, unauthenticated, mustChangePassword, refresh } = useTier();
const navigate = useNavigate();
const { t } = useTranslation("auth");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (loading) {
return (
<AuthLayout title={t("common:brand_name")}>
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common:loading")}
</div>
</AuthLayout>
);
}
if (unauthenticated) return <Navigate to="/login" replace />;
const mismatch = confirm.length > 0 && next !== confirm;
const tooShort = next.length > 0 && next.length < MIN_PASSWORD;
const canSubmit =
!submitting &&
current.length > 0 &&
next.length >= MIN_PASSWORD &&
next === confirm;
async function submit(e: FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
await api.changePassword(current, next);
await refresh();
navigate("/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
return (
<AuthLayout
title={t("change_password_title")}
subtitle={
mustChangePassword
? t("change_password_subtitle_forced")
: t("change_password_subtitle_voluntary")
}
>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current">{t("current_password")}</Label>
<Input
id="current"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
autoFocus
aria-invalid={error ? true : undefined}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("new_password")}</Label>
<Input
id="new-password"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
aria-invalid={tooShort ? true : undefined}
/>
{tooShort && (
<p className="text-xs text-muted-foreground">
{t("password_min_length", { min: MIN_PASSWORD })}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">{t("confirm_new_password")}</Label>
<Input
id="confirm-password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
aria-invalid={mismatch ? true : undefined}
/>
{mismatch && (
<p className="text-xs text-destructive">{t("password_mismatch")}</p>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={!canSubmit}>
{submitting ? t("saving") : t("change_password_btn")}
</Button>
</form>
</CardContent>
</Card>
</AuthLayout>
);
}
+2 -2
View File
@@ -52,7 +52,7 @@ export function Dashboard() {
return {
total: list.length,
running: by("Running"),
players: list.reduce((n, s) => n + (s.players ?? 0), 0),
players: list.reduce((n, s) => n + (s.playersOnline ?? 0), 0),
};
}, [servers]);
@@ -115,7 +115,7 @@ function FleetView({
else if (s.phase === "Failed") failed++;
else unknown++;
maxPlayers += s.maxPlayers ?? 0;
maxPlayers += s.playersMax ?? 0;
});
return {
+306 -183
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2, KeyRound, Mail, Fingerprint } from "lucide-react";
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
@@ -9,29 +9,36 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
// Login is the local-password sign-in (spec §B1). It is the ONLY local credential
// surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success
// the API sets an HttpOnly session cookie (invisible here); we then refresh the tier
// context so the gate re-evaluates, and route to the forced change-password card
// when the account still owes its first-login change, else to the dashboard.
// Login is the passwordless sign-in (spec §B). Passkey and email-OTP are the
// primary doors; a first-time player arrives with an in-game Bind Code (/link);
// staff use the vouched op-login door (email code + in-game approval). No password
// exists anywhere in the product. On success the API sets an HttpOnly session
// cookie (invisible here); we then refresh the tier context so the gate
// re-evaluates and land on the dashboard.
//
// Reaching this page already-authenticated (e.g. typing /login while signed in)
// short-circuits to the right destination rather than showing the form.
// short-circuits to the dashboard rather than showing the form.
export function Login() {
const { loading, identity, mustChangePassword, refresh } = useTier();
const { loading, identity, refresh } = useTier();
const navigate = useNavigate();
const { t } = useTranslation("auth");
const [activeTab, setActiveTab] = useState<"password" | "bind" | "email">("password");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [bindCode, setBindCode] = useState("");
const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main");
const [email, setEmail] = useState("");
const [otpCode, setOtpCode] = useState("");
const [otpSent, setOtpSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const [bindCode, setBindCode] = useState("");
// Op-login (staff door): start → wait for the in-game vouch → finish with the
// mailed code. request_id doubles as the handle an online admin approves.
const [opEmail, setOpEmail] = useState("");
const [opRequestId, setOpRequestId] = useState<string | null>(null);
const [opApproved, setOpApproved] = useState(false);
const [opCode, setOpCode] = useState("");
const [isOpHost, setIsOpHost] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -44,6 +51,32 @@ export function Login() {
return () => clearTimeout(timer);
}, [countdown]);
// Tier-aware copy: on the op.console hostname the staff door is the default tab
// (the player doors refuse staff accounts anyway).
useEffect(() => {
void loadConfig().then((cfg) => {
if (cfg.adminHostname && window.location.hostname === cfg.adminHostname) {
setIsOpHost(true);
setActiveTab("op");
}
});
}, []);
// Poll the op-login request until an in-game approval lands. Errors are
// swallowed on purpose: a transient failure just means we ask again.
useEffect(() => {
if (!opRequestId || opApproved) return;
const timer = setInterval(async () => {
try {
const s = await api.opLoginStatus(opRequestId);
if (s.approved) setOpApproved(true);
} catch {
// keep polling
}
}, 3000);
return () => clearInterval(timer);
}, [opRequestId, opApproved]);
// 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) {
@@ -56,26 +89,8 @@ export function Login() {
</AuthLayout>
);
}
if (identity && mustChangePassword) return <Navigate to="/change-password" replace />;
if (identity) return <Navigate to="/" replace />;
async function handlePasswordSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !password || submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await api.login(username.trim(), password);
// Re-read /me so the context reflects the new session before we leave this
// page; the route we land on is gated on that fresh state.
await refresh();
navigate(res.must_change_password ? "/change-password" : "/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
async function handleBindSubmit(e: FormEvent) {
e.preventDefault();
const code = bindCode.trim();
@@ -127,7 +142,7 @@ export function Login() {
setSubmitting(true);
setError(null);
const identifier = username.trim();
const identifier = email.trim();
try {
let assertion: any;
if (!identifier) {
@@ -165,9 +180,9 @@ export function Login() {
await api.authPasskeyDiscoverableFinish(options.login_id, assertion);
} else {
// Username-first (Email-first) passkey login
// Email-first passkey login
if (!identifier.includes("@")) {
throw new Error("使用 Passkey 登录请在上方输入框中输入您绑定的邮箱,或留空直接进行免密登录。");
throw new Error(t("passkey_email_hint"));
}
const options = await api.authPasskeyLoginBegin(identifier);
@@ -213,156 +228,50 @@ export function Login() {
}
}
async function handleOpStart(e: FormEvent) {
e.preventDefault();
if (!opEmail.trim() || submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await api.opLoginStart(opEmail.trim());
setOpRequestId(res.request_id);
} catch (err) {
setError(humanizeError(err));
} finally {
setSubmitting(false);
}
}
async function handleOpFinish(e: FormEvent) {
e.preventDefault();
if (!opRequestId || !opCode.trim() || submitting) return;
setSubmitting(true);
setError(null);
try {
await api.opLoginFinish(opRequestId, opCode.trim());
await refresh();
navigate("/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
function switchTab(tab: "main" | "bind" | "op") {
setError(null);
setActiveTab(tab);
}
return (
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
<AuthLayout
title={t("login_title")}
subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")}
>
<Card>
<CardContent className="pt-6">
{activeTab === "password" && (
{activeTab === "main" && (
<div className="space-y-4">
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
aria-invalid={error ? true : undefined}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-invalid={error ? true : undefined}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !username.trim() || !password}
>
{submitting ? t("signing_in") : t("sign_in")}
</Button>
</form>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-muted" />
</div>
<div className="relative flex justify-center text-[10px] uppercase">
<span className="bg-card px-2 text-muted-foreground font-semibold tracking-wider">
{t("or_divider")}
</span>
</div>
</div>
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={handlePasskeyLoginClick}
disabled={submitting}
>
<Fingerprint className="h-4 w-4 text-primary" />
{t("passkey_btn")}
</Button>
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => {
setError(null);
setActiveTab("email");
}}
disabled={submitting}
>
<Mail className="h-4 w-4 text-muted-foreground" />
{t("tab_email_btn")}
</Button>
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => {
setError(null);
setActiveTab("bind");
}}
disabled={submitting}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
{t("tab_bind_btn")}
</Button>
</div>
</div>
)}
{activeTab === "bind" && (
<form onSubmit={handleBindSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bindCode">{t("bind_code")}</Label>
<Input
id="bindCode"
placeholder={t("bind_code_placeholder")}
value={bindCode}
onChange={(e) => setBindCode(e.target.value)}
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
<p className="text-[11px] text-muted-foreground/80 mt-1 leading-normal">
{t("bind_hint")}
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !bindCode.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("binding")}
</>
) : (
<>
<KeyRound className="mr-2 h-4 w-4" />
{t("bind_btn")}
</>
)}
</Button>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_password")}</span>
</button>
</div>
</form>
)}
{activeTab === "email" && (
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t("email_address")}</Label>
@@ -373,10 +282,11 @@ export function Login() {
placeholder={t("email_placeholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoComplete="email webauthn"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting || otpSent}
aria-invalid={error ? true : undefined}
className="flex-1"
@@ -447,18 +357,231 @@ export function Login() {
)}
</Button>
)}
</form>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-muted" />
</div>
<div className="relative flex justify-center text-[10px] uppercase">
<span className="bg-card px-2 text-muted-foreground font-semibold tracking-wider">
{t("or_divider")}
</span>
</div>
</div>
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={handlePasskeyLoginClick}
disabled={submitting}
>
<Fingerprint className="h-4 w-4 text-primary" />
{t("passkey_btn")}
</Button>
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => switchTab("bind")}
disabled={submitting}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
{t("tab_bind_btn")}
</Button>
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => switchTab("op")}
disabled={submitting}
>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
{t("tab_op_btn")}
</Button>
</div>
</div>
)}
{activeTab === "bind" && (
<form onSubmit={handleBindSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bindCode">{t("bind_code")}</Label>
<Input
id="bindCode"
placeholder={t("bind_code_placeholder")}
value={bindCode}
onChange={(e) => setBindCode(e.target.value)}
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
<p className="text-[11px] text-muted-foreground/80 mt-1 leading-normal">
{t("bind_hint")}
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !bindCode.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("binding")}
</>
) : (
<>
<KeyRound className="mr-2 h-4 w-4" />
{t("bind_btn")}
</>
)}
</Button>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => switchTab("main")}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_login")}</span>
</button>
</div>
</form>
)}
{activeTab === "op" && !opRequestId && (
<form onSubmit={handleOpStart} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="opEmail">{t("email_address")}</Label>
<Input
id="opEmail"
type="email"
placeholder={t("email_placeholder")}
value={opEmail}
onChange={(e) => setOpEmail(e.target.value)}
autoComplete="email"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
<p className="text-[11px] text-muted-foreground/80 mt-1 leading-normal">
{t("op_hint")}
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !opEmail.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("sending_otp")}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("op_start_btn")}
</>
)}
</Button>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => switchTab("main")}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_login")}</span>
</button>
</div>
</form>
)}
{activeTab === "op" && opRequestId && (
<form onSubmit={handleOpFinish} className="space-y-4">
<div className="rounded-md border bg-muted/40 p-3 space-y-2">
<p className="text-[11px] text-muted-foreground leading-normal">
{t("op_approve_hint")}
</p>
<p className="font-mono text-xs break-all select-all">
/felis web op approve {opRequestId}
</p>
</div>
{opApproved ? (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 leading-normal">
{t("op_approved")}
</p>
) : (
<p className="inline-flex items-center gap-2 text-[11px] text-muted-foreground leading-normal">
<Loader2 className="h-3 w-3 animate-spin" />
{t("op_waiting")}
</p>
)}
<div className="space-y-2">
<Label htmlFor="opCode">{t("otp_code")}</Label>
<Input
id="opCode"
placeholder={t("otp_placeholder")}
value={opCode}
onChange={(e) => setOpCode(e.target.value)}
autoComplete="one-time-code"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !opCode.trim() || !opApproved}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("signing_in")}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("otp_btn")}
</>
)}
</Button>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
setOpRequestId(null);
setOpApproved(false);
setOpCode("");
switchTab("op");
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_password")}</span>
<span>{t("op_restart")}</span>
</button>
</div>
</form>
+4 -4
View File
@@ -379,12 +379,12 @@ export function MySubmissionsPage() {
<div className="px-10 py-3 bg-muted/20 border-t border-b border-border/40 text-xs text-muted-foreground space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold text-foreground mb-1">构建上下文引用 (Context Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("field_context_ref")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
</div>
{sub.image_ref && (
<div>
<p className="font-semibold text-foreground mb-1">目标镜像引用 (Image Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("field_image_ref")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
</div>
)}
@@ -510,7 +510,7 @@ export function MySubmissionsPage() {
}}
>
<X className="mr-1 h-3 w-3" />
清除
{t("clear_btn")}
</Button>
)}
</div>
@@ -518,7 +518,7 @@ export function MySubmissionsPage() {
<>
<Upload className="h-8 w-8 text-muted-foreground/80 mb-2" />
<p className="text-xs font-medium text-foreground">{t("file_drag_hint")}</p>
<p className="text-[10px] text-muted-foreground/70 mt-1">支持 .tar.gz 格式 (最大 1GB)</p>
<p className="text-[10px] text-muted-foreground/70 mt-1">{t("file_hint")}</p>
</>
)}
</div>
+5 -5
View File
@@ -441,7 +441,7 @@ export function ServerLuckPerms() {
</Badge>
))
) : (
<p className="text-xs text-muted-foreground/60 italic py-1">No parent groups assigned</p>
<p className="text-xs text-muted-foreground/60 italic py-1">{t("luckperms_no_parent_groups")}</p>
)}
</div>
@@ -550,7 +550,7 @@ export function ServerLuckPerms() {
{p.world}
</Badge>
) : (
<span className="text-muted-foreground italic">global</span>
<span className="text-muted-foreground italic">{t("luckperms_global")}</span>
)}
</td>
<td className="px-4 py-2.5 text-center">
@@ -569,7 +569,7 @@ export function ServerLuckPerms() {
) : (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic">
No explicit permission nodes assigned
{t("luckperms_no_perms")}
</td>
</tr>
)}
@@ -681,7 +681,7 @@ export function ServerLuckPerms() {
type="button"
onClick={clearHistory}
className="text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
title="Clear history"
title={t("luckperms_clear_history")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -746,7 +746,7 @@ export function ServerLuckPerms() {
<details className="group/details">
<summary className="cursor-pointer select-none text-[10px] text-muted-foreground/70 hover:text-foreground font-mono transition-colors list-none flex items-center gap-1">
<span className="transition-transform group-open/details:rotate-90">▶</span>
RCON Console Output
{t("luckperms_rcon_output")}
</summary>
<pre className="mt-1.5 p-2 rounded bg-muted/60 border border-border/80 font-mono text-[10px] text-foreground/80 overflow-x-auto whitespace-pre-wrap break-all max-h-24">
{h.output}
+4 -4
View File
@@ -140,7 +140,7 @@ export function ImageAdmin() {
<DialogHeader>
<DialogTitle>{t("add_image_title")}</DialogTitle>
<DialogDescription>
将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。
{t("add_image_desc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAdd} className="space-y-4">
@@ -184,7 +184,7 @@ export function ImageAdmin() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder="搜索镜像名称或来源..." />
<SearchInput value={search} onChange={setSearch} placeholder={t("images_search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
type="button"
@@ -242,8 +242,8 @@ export function ImageAdmin() {
) : filteredImages.length === 0 ? (
<div className="p-4 border-b-0">
<EmptyState
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_images_title")}
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_images_hint")}
title={search.trim() || statusFilter !== "all" ? t("search_no_results") : t("no_images_title")}
hint={search.trim() || statusFilter !== "all" ? t("search_no_results_hint") : t("no_images_hint")}
/>
</div>
) : (
+15 -14
View File
@@ -40,25 +40,26 @@ const STATUS_BADGE_STYLE: Record<BuildStatus, string> = {
cancelled: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20",
};
function formatDuration(createdAt: string, finishedAt?: string, isZh?: boolean): string {
function formatDuration(
createdAt: string,
finishedAt: string | undefined,
t: (key: string, opts?: Record<string, unknown>) => string
): string {
const start = new Date(createdAt).getTime();
if (!Number.isFinite(start)) return "";
const end = finishedAt ? new Date(finishedAt).getTime() : Date.now();
if (!Number.isFinite(end) || end < start) return "";
const diffSec = Math.round((end - start) / 1000);
if (diffSec < 60) {
return isZh ? `${diffSec}秒` : `${diffSec}s`;
return t("build_duration_seconds", { s: diffSec });
}
const m = Math.floor(diffSec / 60);
const s = diffSec % 60;
return isZh ? `${m}分${s}秒` : `${m}m ${s}s`;
return t("build_duration_minutes", { m: Math.floor(diffSec / 60), s: diffSec % 60 });
}
export function ImageBuildPage() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
const now = Date.now();
const isZh = locale.startsWith("zh");
const config = useConfig();
const { identity } = useTier();
@@ -289,7 +290,7 @@ export function ImageBuildPage() {
<DialogHeader>
<DialogTitle>{t("trigger_build_title")}</DialogTitle>
<DialogDescription>
输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。
{t("trigger_build_desc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleTrigger} className="space-y-4">
@@ -329,7 +330,7 @@ export function ImageBuildPage() {
sub.status === "approved" && "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
sub.status === "rejected" && "bg-rose-500/10 text-rose-500 border-rose-500/20"
)}>
{sub.status === "pending_review" ? (isZh ? "待审核" : "Pending") : sub.status === "approved" ? (isZh ? "已同意" : "Approved") : (isZh ? "已驳回" : "Rejected")}
{sub.status === "pending_review" ? t("status_pending_review") : sub.status === "approved" ? t("status_approved") : t("status_rejected")}
</span>
</SelectItem>
))
@@ -346,7 +347,7 @@ export function ImageBuildPage() {
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5 animate-bounce" />
<div className="space-y-1">
<p className="font-bold text-amber-400">
{t("build_import_submission_warning_title", { status: selectedSub.status === "pending_review" ? (isZh ? "待审核" : "Pending Review") : (isZh ? "已驳回" : "Rejected") })}
{t("build_import_submission_warning_title", { status: selectedSub.status === "pending_review" ? t("status_pending_review") : t("status_rejected") })}
</p>
<p className="text-[10px] text-muted-foreground leading-normal">
{t("build_import_submission_warning_desc")}
@@ -434,7 +435,7 @@ export function ImageBuildPage() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder="搜索构建 ID、镜像引用或状态..." />
<SearchInput value={search} onChange={setSearch} placeholder={t("builds_search_placeholder")} />
</div>
{/* List Content */}
@@ -443,8 +444,8 @@ export function ImageBuildPage() {
) : filteredBuilds.length === 0 ? (
<div className="p-4">
<EmptyState
title={search.trim() ? "无匹配构建任务" : t("no_builds_title")}
hint={search.trim() ? "尝试更换搜索词" : t("no_builds_hint")}
title={search.trim() ? t("search_no_results") : t("no_builds_title")}
hint={search.trim() ? t("search_no_results_hint") : t("no_builds_hint")}
/>
</div>
) : (
@@ -479,7 +480,7 @@ export function ImageBuildPage() {
<div className="flex flex-col min-w-0">
<span
className="font-mono font-medium text-foreground select-all block max-w-xl truncate"
title={`镜像引用: ${b.image_ref}${b.base_image ? `\n基础镜像: ${b.base_image}` : ""}${b.context_ref ? `\n构建上下文: ${b.context_ref}` : ""}`}
title={`${t("image_ref_label")}: ${b.image_ref}${b.base_image ? `\n${t("base_image_label")}: ${b.base_image}` : ""}${b.context_ref ? `\n${t("context_ref_label")}: ${b.context_ref}` : ""}`}
>
{b.image_ref}
</span>
@@ -519,7 +520,7 @@ export function ImageBuildPage() {
{/* Column 6: Duration */}
<td className="px-4 py-3 align-middle text-center font-mono text-muted-foreground whitespace-nowrap">
{formatDuration(b.created_at, b.finished_at, isZh) || "—"}
{formatDuration(b.created_at, b.finished_at, t) || "—"}
</td>
{/* Column 7: Action */}
+6 -6
View File
@@ -157,7 +157,7 @@ export function SubmissionsPage() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder="搜索模组包名称或提交人..." />
<SearchInput value={search} onChange={setSearch} placeholder={t("submissions_search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
type="button"
@@ -230,8 +230,8 @@ export function SubmissionsPage() {
) : filteredSubmissions.length === 0 ? (
<div className="p-4 border-b-0">
<EmptyState
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_submissions_title")}
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_submissions_hint")}
title={search.trim() || statusFilter !== "all" ? t("search_no_results") : t("no_submissions_title")}
hint={search.trim() || statusFilter !== "all" ? t("search_no_results_hint") : t("no_submissions_hint")}
/>
</div>
) : (
@@ -337,12 +337,12 @@ export function SubmissionsPage() {
<div className="px-10 py-3 bg-muted/20 border-t border-b border-border/40 text-xs text-muted-foreground space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold text-foreground mb-1">构建上下文引用 (Context Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("context_ref_label")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
</div>
{sub.image_ref && (
<div>
<p className="font-semibold text-foreground mb-1">目标镜像引用 (Image Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("image_ref_label")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
</div>
)}
@@ -350,7 +350,7 @@ export function SubmissionsPage() {
{sub.build_id && (
<div>
<p className="font-semibold text-foreground">关联构建任务 (Build ID)</p>
<p className="font-semibold text-foreground">{t("table_build_id")}</p>
<code className="font-mono bg-background border rounded px-1.5 py-0.5">{sub.build_id}</code>
</div>
)}
+3 -60
View File
@@ -7,7 +7,6 @@ import {
UserRound,
Mail,
Calendar,
Key,
Clock,
Trash2,
Power,
@@ -151,7 +150,7 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
onSaved();
} catch (e: any) {
if (e && e.code === "already_exists") {
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
setErr(t("users_create_validation_username_taken"));
} else {
setErr(humanizeError(e));
}
@@ -640,7 +639,7 @@ function DangerZone({
navigate: (path: string) => void;
}) {
const { t } = useTranslation("admin");
const [dlg, setDlg] = useState<"disable" | "resetPw" | "delete" | null>(null);
const [dlg, setDlg] = useState<"disable" | "delete" | null>(null);
return (
<Card className="border-destructive/30">
@@ -658,18 +657,6 @@ function DangerZone({
onAction={() => setDlg("disable")}
/>
{/* Reset password */}
<DangerRow
icon={Key}
title={t("users_danger_reset_pw")}
desc={user.email
? t("users_danger_reset_pw_desc_email", { email: user.email })
: t("users_danger_reset_pw_desc")}
btnLabel={t("users_danger_reset_pw_btn")}
btnVariant="destructive"
onAction={() => setDlg("resetPw")}
/>
{/* Delete user */}
<DangerRow
icon={Trash2}
@@ -722,7 +709,7 @@ function DangerDialogs({
onChanged,
navigate,
}: {
dlg: "disable" | "resetPw" | "delete" | null;
dlg: "disable" | "delete" | null;
setDlg: (v: null) => void;
user: UserDetail;
onChanged: () => void;
@@ -731,12 +718,10 @@ function DangerDialogs({
const { t } = useTranslation("admin");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
function close() {
setDlg(null);
setErr(null);
setOk(null);
setLoading(false);
}
@@ -753,23 +738,6 @@ function DangerDialogs({
}
}
async function handleResetPassword() {
setLoading(true);
setErr(null);
try {
const r = await api.resetUserPassword(user.id);
if (r.email) {
setOk(t("users_pw_reset_ok", { email: r.email }));
} else {
setOk(t("users_pw_reset_ok_no_email"));
}
setLoading(false);
} catch (e) {
setErr(humanizeError(e));
setLoading(false);
}
}
async function handleDelete() {
setLoading(true);
setErr(null);
@@ -801,31 +769,6 @@ function DangerDialogs({
</DialogContent>
</Dialog>
{/* Reset password dialog */}
<Dialog open={dlg === "resetPw"} onOpenChange={(v) => { if (!v) close(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-primary" />
{t("users_danger_reset_pw_dlg_title")}
</DialogTitle>
<DialogDescription>
{user.email
? t("users_danger_reset_pw_dlg_desc_email", { email: user.email })
: t("users_danger_reset_pw_dlg_desc_no_email")}
</DialogDescription>
</DialogHeader>
{err && <p className="text-sm text-destructive">{err}</p>}
{ok && (
<p className="rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
<CheckCircle2 className="inline h-4 w-4 mr-1" />
{ok}
</p>
)}
<ConfirmFooter onCancel={close} onConfirm={handleResetPassword} loading={loading} disabled={loading || ok !== null} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_reset_pw_confirm")} />
</DialogContent>
</Dialog>
{/* Delete user dialog */}
<Dialog open={dlg === "delete"} onOpenChange={(v) => { if (!v) close(); }}>
<DialogContent className="sm:max-w-sm">
+2 -2
View File
@@ -135,8 +135,8 @@ export function ServersPage() {
ready: s.phase === "Running",
desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy,
playersOnline: s.players ?? 0,
playersMax: s.maxPlayers ?? 0,
playersOnline: s.playersOnline ?? 0,
playersMax: s.playersMax ?? 0,
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
claimable: s.claimable,
owned: s.owned,
+1 -1
View File
@@ -4,7 +4,7 @@ These are the in-cluster and edge plugins for Felis. Every module **except the
lobby** ships the in-game first leg of the §10 account-link flow: a player who is already online
(so Mojang has verified their UUID) runs `/link`; the plugin asks felis-api to
mint a one-time code for that UUID and shows it in chat. The player then enters
the code on the web panel → **Account** page (the second leg), which binds the
the code on the web console → **Account** page (the second leg), which binds the
code to their logged-in account. The web side is already built.
The **Velocity** module additionally carries the §11 domain-autostart routing
@@ -28,7 +28,7 @@ import java.util.concurrent.Executors;
* FelisFabricMod is the Fabric (dedicated-server) leg of the §10 account-link
* flow. A server-side {@code /link} command takes the player's already-verified
* UUID, asks felis-api for a one-time code, and shows it in chat; the player then
* redeems it on the web panel. The HTTP call is pushed onto a daemon I/O thread
* redeems it on the web console. The HTTP call is pushed onto a daemon I/O thread
* and the reply is hopped back onto the server thread, so a slow felis-api never
* stalls the tick loop. Failures collapse to a generic chat line with details
* confined to the server log.
@@ -65,7 +65,7 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
@@ -75,18 +75,22 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
server.execute(() -> {
player.sendSystemMessage(Component.literal(
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
player.sendSystemMessage(Component.literal(code.panelUrl() != null
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
});
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
@@ -70,7 +70,7 @@ public final class FelisForgeMod {
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
@@ -80,18 +80,22 @@ public final class FelisForgeMod {
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
server.execute(() -> {
player.sendSystemMessage(Component.literal(
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
player.sendSystemMessage(Component.literal(code.panelUrl() != null
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
});
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
@@ -72,7 +72,7 @@ import java.util.logging.Logger;
* (env wins, else a {@code felis-link.properties} template in the plugin data dir) via
* the shared {@link LinkConfigLoader}. {@code FELIS_ROOT_DOMAIN} builds the console
* link; {@code FELIS_LOBBY_SERVER} (default {@code lobby}) is the transfer target;
* {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 300) bounds the login window. If the
* {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 600) bounds the login window. If the
* link config or the root domain is absent the login flow stays OFF and the plugin
* runs readiness-only — the same "load un-crippled" fail-safe the other Felis plugins
* use — so a bare image still boots and serves readiness; production must supply the
@@ -88,10 +88,11 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
private static final int DEFAULT_PORT = 8080;
// Poll cadence and window. 20 ticks ≈ 1s at Limbo's tick rate; polling once a
// second is responsive without hammering felis-api. The default window (5 min)
// matches the Bind Code TTL — no point holding a player past code expiry.
// second is responsive without hammering felis-api. The default window (10 min)
// matches the Bind Code TTL (linkCodeTTL in internal/api) — no point holding a
// player past code expiry, and no point cutting them off while it is still valid.
private static final long POLL_PERIOD_TICKS = 20L;
private static final long DEFAULT_TIMEOUT_SECONDS = 300L;
private static final long DEFAULT_TIMEOUT_SECONDS = 600L;
private static final long MIN_TIMEOUT_SECONDS = 30L;
private static final long MAX_TIMEOUT_SECONDS = 3600L;
@@ -261,17 +262,21 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
return; // player left during the async mint
}
// Prefer the panel URL the server minted with the code (it is the same
// single source of truth felis-api holds); the env-built consoleUrl is the
// fallback for an older API that does not emit panel_url yet.
String url = code.panelUrl() != null ? code.panelUrl() : consoleUrl;
try {
player.openBook(loginBook(code));
player.openBook(loginBook(code, url));
} catch (RuntimeException e) {
// A client that refuses the book (rare) still gets the chat instructions
// below, so a book failure is not fatal to the flow.
LOG.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage());
}
player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code());
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + consoleUrl
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + url
+ " §e完成登录(勿用微信/QQ内置浏览器)。");
player.sendMessage("§7Open " + consoleUrl + " in your system browser (not WeChat/QQ) to finish.");
player.sendMessage("§7Open " + url + " in your system browser (not WeChat/QQ) to finish.");
long deadline = System.currentTimeMillis() + timeoutMillis;
int taskId = getServer().getScheduler().runTaskTimerAsync(
@@ -341,13 +346,13 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
// ---- rendering / wire ----
private Book loginBook(LinkCode code) {
private Book loginBook(LinkCode code, String url) {
Component page = Component.text("Felis 登录 / Login\n\n")
.append(Component.text("绑定码 / Code:\n"))
.append(Component.text(code.code() + "\n\n").color(NamedTextColor.GOLD))
.append(Component.text("▶ 点此打开登录页\n▶ Open login page\n")
.color(NamedTextColor.AQUA)
.clickEvent(ClickEvent.openUrl(consoleUrl)))
.clickEvent(ClickEvent.openUrl(url)))
.append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).")
.color(NamedTextColor.GRAY));
return Book.book(
@@ -74,7 +74,7 @@ public final class FelisNeoForgeMod {
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
@@ -84,18 +84,22 @@ public final class FelisNeoForgeMod {
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
server.execute(() -> {
player.sendSystemMessage(Component.literal(
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
player.sendSystemMessage(Component.literal(code.panelUrl() != null
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
});
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
@@ -48,14 +48,13 @@ import java.util.List;
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &amp;
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
* of a running owned server and the "Wake" of a stopped owned one), then closes the
* menu. A refusal comes back as an {@code Error} frame and is shown to the player —
* the only place claim/quota/policy failures surface — and readiness arrives as
* {@code TransferReady} just before the proxy Connects them.
* menu. A claim refusal comes back as an {@code Error} frame and is shown to the
* player here; wake-path refusals (policy gate, capacity) are chat messages the
* proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady}
* just before the proxy Connects them.
*/
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
private static final Component MENU_TITLE =
Component.text("Felis Servers", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
private static final int MAX_TILES = 54; // a double chest, the GUI ceiling
/** Server names to show as tiles, in display order; loaded from config. */
@@ -90,19 +89,21 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
}
private void openMenu(Player player) {
boolean zh = zh(player);
if (servers.isEmpty()) {
player.sendMessage(Component.text(
"No servers are configured yet — ask an operator to set up felis-paper.",
zh ? "还没有配置任何服务器——请管理员先配置 felis-paper。"
: "No servers are configured yet — ask an operator to set up felis-paper.",
NamedTextColor.YELLOW));
return;
}
int shown = Math.min(servers.size(), MAX_TILES);
List<String> view = new ArrayList<>(servers.subList(0, shown));
MenuHolder holder = new MenuHolder(view);
Inventory inv = Bukkit.createInventory(holder, invSize(shown), MENU_TITLE);
Inventory inv = Bukkit.createInventory(holder, invSize(shown), menuTitle(zh));
holder.setInventory(inv);
for (int i = 0; i < shown; i++) {
inv.setItem(i, loadingTile(view.get(i)));
inv.setItem(i, loadingTile(view.get(i), zh));
}
player.openInventory(inv);
// Ask the proxy for live status of every tile; answers repaint them.
@@ -180,7 +181,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
case ControlFrame.ERROR:
// The proxy already sanitizes transport faults; this is the only place
// a claim/quota/policy refusal becomes visible to the player.
player.sendMessage(Component.text("⚠ " + errorText(frame), NamedTextColor.RED));
player.sendMessage(Component.text("⚠ " + errorText(frame, zh(player)), NamedTextColor.RED));
break;
case ControlFrame.TRANSFER_READY:
// The proxy performs the actual Connect; just make sure a stale menu is
@@ -203,7 +204,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return; // a server we are not showing
}
holder.put(frame.server(), frame);
top.setItem(slot, tile(frame));
top.setItem(slot, tile(frame, zh(player)));
}
private void closeIfMenu(Player player) {
@@ -214,21 +215,26 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
// ---- rendering ----
private ItemStack tile(ControlFrame f) {
private static Component menuTitle(boolean zh) {
return Component.text(zh ? "Felis 服务器" : "Felis Servers", NamedTextColor.AQUA)
.decoration(TextDecoration.ITALIC, false);
}
private ItemStack tile(ControlFrame f, boolean zh) {
Material material;
String action;
NamedTextColor color;
if (f.claimable()) {
material = Material.GOLD_BLOCK;
action = "Claim & Start";
action = zh ? "认领并启动" : "Claim & Start";
color = NamedTextColor.GOLD;
} else if (f.ready()) {
material = Material.LIME_CONCRETE;
action = "Join";
action = zh ? "加入" : "Join";
color = NamedTextColor.GREEN;
} else {
material = Material.RED_CONCRETE;
action = "Wake";
action = zh ? "唤醒" : "Wake";
color = NamedTextColor.RED;
}
ItemStack item = new ItemStack(material);
@@ -236,18 +242,18 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
meta.displayName(Component.text(action + " · " + f.server(), color)
.decoration(TextDecoration.ITALIC, false));
List<Component> lore = new ArrayList<>();
lore.add(line("Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
lore.add(line("Players", f.playersOnline() + "/" + f.playersMax()));
lore.add(line(zh ? "状态" : "Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
lore.add(line(zh ? "在线" : "Players", f.playersOnline() + "/" + f.playersMax()));
meta.lore(lore);
item.setItemMeta(meta);
return item;
}
private ItemStack loadingTile(String server) {
private ItemStack loadingTile(String server, boolean zh) {
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
meta.lore(List.of(Component.text("Loading…", NamedTextColor.DARK_GRAY)
meta.lore(List.of(Component.text(zh ? "加载中…" : "Loading…", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)));
item.setItemMeta(meta);
return item;
@@ -259,27 +265,35 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
.decoration(TextDecoration.ITALIC, false);
}
private static String errorText(ControlFrame f) {
private static String errorText(ControlFrame f, boolean zh) {
String code = f.code();
if (code != null) {
switch (code) {
case "not_linked":
return "Link your account first — run /link, then finish on the web panel.";
return zh ? "请先绑定账号——运行 /link,然后在网页控制台完成绑定。"
: "Link your account first — run /link, then finish on the web console.";
case "quota_exceeded":
return "You've reached your server quota.";
return zh ? "你已达到服务器配额上限。"
: "You've reached your server quota.";
case "already_claimed":
return "That server was just claimed by someone else.";
return zh ? "该服务器已被认领。"
: "That server is already claimed.";
default:
break;
}
}
return f.message() != null && !f.message().isEmpty()
? f.message()
: (code != null ? code : "Request failed — please try again.");
: (code != null ? code : (zh ? "请求失败,请重试。" : "Request failed — please try again."));
}
// ---- helpers ----
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
private static boolean zh(Player player) {
return "zh".equalsIgnoreCase(player.locale().getLanguage());
}
private void sendUpstream(Player player, ControlFrame frame) {
player.sendPluginMessage(this, Control.CHANNEL, Control.encode(frame));
}
@@ -14,22 +14,19 @@ import java.util.UUID;
/**
* FelisApiClient is the proxy's read/drive client for the felis-api internal face
* (spec §7, §9). Where {@link LinkClient} mints account-link codes, this client
* drives domain-autostart routing: it lists the registrable servers, resolves a
* connecting virtual host to its server, polls a server's lifecycle status, pulls
* the wake lever, and reports real player joins. It shares the {@link LinkConfig}
* drives domain-autostart routing: it lists the registrable servers, polls a
* server's lifecycle status, pulls the wake lever, and reports real player
* joins. It shares the {@link LinkConfig}
* (same internal base URL + service token) and the same zero-dependency JDK HTTP
* stack, so it compiles straight into each loader jar with nothing to shade.
*
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
* and surfaces a non-success status as a {@link LinkException} carrying the HTTP
* status, so the proxy can branch on it without parsing human text. The two that
* matter for routing:
* <ul>
* <li>{@code wake} → 403 means the autostartPolicy gate refused this UUID (do
* not enqueue the player); 429 means a wake is already cooling down
* ("already waking, keep waiting"), not a failure.</li>
* <li>{@code serverByHost} → 404 means the host maps to no server.</li>
* </ul>
* status, so the proxy can branch on it without parsing human text. The one that
* matters most for routing: {@code wake} → 403 means the autostartPolicy gate
* refused this UUID (do not enqueue the player); 429 means a wake is already
* cooling down ("already waking, keep waiting"); 503 means the cluster is at
* capacity (tell the player to try later — nothing is coming up).
*/
public final class FelisApiClient {
private final LinkConfig config;
@@ -57,16 +54,6 @@ public final class FelisApiClient {
return out;
}
/**
* serverByHost resolves {@code subdomain.<root_domain>} to its server view
* (GET /servers/by-host/{host}). A 404 surfaces as a LinkException with
* statusCode 404 so the caller can distinguish "unknown host" from a transport
* fault.
*/
public ServerView serverByHost(String host) throws LinkException {
return ServerView.fromJson(getObject("/api/v1/servers/by-host/" + Objects.requireNonNull(host, "host"), 200));
}
/** serverStatus reads one server's current lifecycle view (internal status). */
public ServerView serverStatus(String name) throws LinkException {
return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200));
@@ -75,8 +62,9 @@ public final class FelisApiClient {
/**
* wake pulls the domain-autostart lever for {@code name} on behalf of the
* joining player (spec §9.1, §14). The reply (202) carries the current phase
* and ready flag so the caller can decide whether to wait. A 403 (policy gate)
* or 429 (cooldown) arrives as a LinkException the caller branches on.
* and ready flag so the caller can decide whether to wait. A 403 (policy gate),
* 429 (cooldown), or 503 {@code at_capacity} (running cap) arrives as a
* LinkException the caller branches on.
*/
public ServerView wake(String name, UUID mcUuid) throws LinkException {
Objects.requireNonNull(name, "name");
@@ -142,7 +130,7 @@ public final class FelisApiClient {
* completion leg of the in-game login flow (spec §B3). After the player redeems
* the Bind Code on {@code console.<root_domain>} the login limbo polls this until
* it flips true, then admits/transfers the player. {@code GET
* /api/v1/internal/account/link/status/{mc_uuid}} → {@code {"linked":bool,...}};
* /api/v1/internal/account/link/status/{mc_uuid}} → {@code {"linked":bool}};
* read-only and keyed by the verified UUID, so it consumes nothing and is safe to
* poll repeatedly. Anything but {@code linked:true} (including a missing field) is
* reported as not-yet-linked — the caller keeps waiting rather than admitting on
@@ -22,7 +22,8 @@ import java.util.UUID;
* <li>header {@code Authorization: Bearer <serviceToken>} (constant-time
* compared server-side; an empty token fails closed)</li>
* <li>request body {@code {"mc_uuid":"<uuid>"}}</li>
* <li>success: HTTP 201 with {@code {"code","expires_at"}}</li>
* <li>success: HTTP 201 with {@code {"code","expires_at","panel_url"?}}
* ({@code panel_url} present only when a panel hostname is configured)</li>
* <li>failure: the {@code {"error":{"code","message"}}} envelope</li>
* </ul>
*
@@ -94,7 +95,11 @@ public final class LinkClient {
"success body missing 'code'");
}
Object exp = obj.get("expires_at");
return new LinkCode((String) code, exp instanceof String ? (String) exp : null);
Object panelUrl = obj.get("panel_url");
return new LinkCode((String) code,
exp instanceof String ? (String) exp : null,
panelUrl instanceof String && !((String) panelUrl).isEmpty()
? (String) panelUrl : null);
}
private LinkException parseError(int status, String text) {
@@ -13,10 +13,12 @@ import java.util.Objects;
public final class LinkCode {
private final String code;
private final String expiresAt;
private final String panelUrl;
public LinkCode(String code, String expiresAt) {
public LinkCode(String code, String expiresAt, String panelUrl) {
this.code = Objects.requireNonNull(code, "code");
this.expiresAt = expiresAt;
this.panelUrl = panelUrl;
}
public String code() {
@@ -27,4 +29,12 @@ public final class LinkCode {
public String expiresAt() {
return expiresAt;
}
/**
* panelUrl is the ready-to-open web-panel URL the server minted alongside the
* code, or null when no panel hostname is configured server-side.
*/
public String panelUrl() {
return panelUrl;
}
}
@@ -5,8 +5,7 @@ import java.util.Map;
/**
* ServerView is the proxy-side mirror of the felis-api lifecycle view of one
* MinecraftServer (the {@code ServerInfo} the internal face emits for
* {@code GET /servers}, {@code GET /servers/by-host/{host}} and the internal
* status/wake replies). It is an immutable, dependency-free value object so the
* {@code GET /servers} and the internal status/wake replies). It is an immutable, dependency-free value object so the
* shared link core stays zero-dependency and source-shareable across all four
* loaders.
*
@@ -193,10 +193,12 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
// errors carry user-safe text (the same {code,message} the external API returns),
// but a transport failure (statusCode 0) carries internal IO detail — host names,
// refused ports — that must not reach a player's screen, so it is generalized.
// The lobby localizes known codes itself; this fallback message may reach the
// screen raw, so it carries both languages in one line (the no-locale pattern).
private static ControlFrame errorFrame(LinkException e, String server) {
String code = e.errorCode() != null ? e.errorCode() : "error";
String message = e.statusCode() == 0
? "felis is temporarily unavailable — please try again."
? "Felis 暂时不可用,请稍后再试 / Felis is temporarily unavailable — please try again."
: e.getMessage();
return ControlFrame.error(code, message, server);
}
@@ -19,7 +19,8 @@ import java.util.Properties;
* URL + service token (and its first-run template), so {@code /link} keeps working
* exactly as before; these extra keys are read from the same properties file (or
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOGIN_SERVER} /
* {@code FELIS_LOBBY_SERVER}).
* {@code FELIS_LOBBY_SERVER} / {@code FELIS_PANEL_HOSTNAME} /
* {@code FELIS_ADMIN_HOSTNAME}).
*
* <p>The routing extras are optional at load time and the routing layer degrades rather
* than crashing: a missing {@code root-domain} disables routing (with a clear log
@@ -33,9 +34,13 @@ final class FelisVelocityConfig {
static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
static final String ENV_LOGIN = "FELIS_LOGIN_SERVER";
static final String ENV_LOBBY = "FELIS_LOBBY_SERVER";
static final String ENV_PANEL_HOSTNAME = "FELIS_PANEL_HOSTNAME";
static final String ENV_ADMIN_HOSTNAME = "FELIS_ADMIN_HOSTNAME";
private static final String KEY_ROOT_DOMAIN = "root-domain";
private static final String KEY_LOGIN = "login-server";
private static final String KEY_LOBBY = "lobby-server";
private static final String KEY_PANEL_HOSTNAME = "panel-hostname";
private static final String KEY_ADMIN_HOSTNAME = "admin-hostname";
private static final String DEFAULT_LOGIN = "login";
private static final String DEFAULT_LOBBY = "lobby";
@@ -43,13 +48,18 @@ final class FelisVelocityConfig {
private final String rootDomain; // null → routing disabled
private final String loginServer;
private final String lobbyServer;
private final String panelHostname; // null → fall back to console.<root>
private final String adminHostname; // null → fall back to op.console.<root>
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain,
String loginServer, String lobbyServer) {
String loginServer, String lobbyServer,
String panelHostname, String adminHostname) {
this.linkConfig = linkConfig;
this.rootDomain = rootDomain;
this.loginServer = loginServer;
this.lobbyServer = lobbyServer;
this.panelHostname = panelHostname;
this.adminHostname = adminHostname;
}
static FelisVelocityConfig load(Path file) throws IOException {
@@ -63,13 +73,17 @@ final class FelisVelocityConfig {
String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN)));
String login = trimToNull(firstNonBlank(System.getenv(ENV_LOGIN), props.getProperty(KEY_LOGIN)));
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
String panel = trimToNull(firstNonBlank(System.getenv(ENV_PANEL_HOSTNAME), props.getProperty(KEY_PANEL_HOSTNAME)));
String admin = trimToNull(firstNonBlank(System.getenv(ENV_ADMIN_HOSTNAME), props.getProperty(KEY_ADMIN_HOSTNAME)));
login = login == null ? DEFAULT_LOGIN : login;
lobby = lobby == null ? DEFAULT_LOBBY : lobby;
if (login.equalsIgnoreCase(lobby)) {
throw new IOException("login-server and lobby-server must be different");
}
return new FelisVelocityConfig(
link, root == null ? null : root.toLowerCase(Locale.ROOT), login, lobby);
link, root == null ? null : root.toLowerCase(Locale.ROOT), login, lobby,
panel == null ? null : panel.toLowerCase(Locale.ROOT),
admin == null ? null : admin.toLowerCase(Locale.ROOT));
}
LinkConfig linkConfig() {
@@ -95,6 +109,28 @@ final class FelisVelocityConfig {
return lobbyServer;
}
/**
* panelHostname is the player web-panel host, falling back to
* {@code console.<root-domain>}; null when neither is configured.
*/
String panelHostname() {
if (panelHostname != null) {
return panelHostname;
}
return rootDomain == null ? null : "console." + rootDomain;
}
/**
* adminHostname is the staff op.console host, falling back to
* {@code op.console.<root-domain>}; null when neither is configured.
*/
String adminHostname() {
if (adminHostname != null) {
return adminHostname;
}
return rootDomain == null ? null : "op.console." + rootDomain;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.trim().isEmpty()) {
return a;
@@ -28,6 +28,7 @@ import org.slf4j.Logger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -180,20 +181,34 @@ public final class FelisVelocityPlugin {
}
private void requestAndReply(Player player) {
player.sendMessage(Component.text("Requesting a link code…", NamedTextColor.GRAY));
boolean zh = zh(player);
player.sendMessage(Component.text(
zh ? "正在获取绑定码……" : "Requesting a link code…", NamedTextColor.GRAY));
async(() -> {
try {
LinkCode code = linkClient.requestCode(player.getUniqueId());
player.sendMessage(Component.text("Your link code: ", NamedTextColor.GREEN)
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
player.sendMessage(Component.text(
"Enter it on the web panel → Account to finish linking (valid a few minutes).",
zh ? "你的绑定码:" : "Your link code: ", NamedTextColor.GREEN)
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
String panelUrl = code.panelUrl();
if (panelUrl != null) {
player.sendMessage(Component.text(
zh ? "在这里输入它完成绑定(几分钟内有效):"
: "Enter it here to finish linking (valid a few minutes):",
NamedTextColor.GRAY));
player.sendMessage(Component.text(" " + panelUrl, NamedTextColor.WHITE));
} else {
player.sendMessage(Component.text(
zh ? "在网页控制台 → 账户 中输入它完成绑定(几分钟内有效)。"
: "Enter it on the web console → Account to finish linking (valid a few minutes).",
NamedTextColor.GRAY));
}
} catch (LinkException e) {
logger.warn("link code request failed for {} (status={}, code={}): {}",
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't get a link code right now. Please try again in a moment.",
zh ? "现在无法获取绑定码,请稍后再试。"
: "Couldn't get a link code right now. Please try again in a moment.",
NamedTextColor.RED));
}
});
@@ -210,6 +225,7 @@ public final class FelisVelocityPlugin {
// /felis server the felis servers this proxy knows
// /felis go <server> wake a server and move me in when it's ready
// /felis claim take ownership of the server I'm on
// /felis migrate open a migration of my servers to another account
// /felis web where the web consoles live
// /felis web op approve <code> vouch for a pending op.console staff login (§B)
//
@@ -301,15 +317,18 @@ public final class FelisVelocityPlugin {
// login limbo (or not yet on any backend): they have not passed the front door.
// Fails closed on an unknown position.
private boolean ensureOutOfLimbo(Player player) {
boolean zh = zh(player);
Optional<ServerConnection> current = player.getCurrentServer();
if (current.isEmpty()) {
player.sendMessage(Component.text(
"Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
zh ? "请稍候——完成连接后再使用 /felis。"
: "Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
return false;
}
if (config.loginServer().equalsIgnoreCase(current.get().getServerInfo().getName())) {
player.sendMessage(Component.text(
"Finish signing in first — /felis isn't available from the login area.",
zh ? "请先完成登录——登录区内无法使用 /felis。"
: "Finish signing in first — /felis isn't available from the login area.",
NamedTextColor.YELLOW));
return false;
}
@@ -328,53 +347,69 @@ public final class FelisVelocityPlugin {
if (!gateInfo(source)) {
return;
}
boolean zh = zh(source);
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
if (!routingActive) {
source.sendMessage(Component.text(
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
zh ? " routing: 已禁用" + (onlineMode ? "(未设置 root-domain)" : "(离线模式)")
: " routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
NamedTextColor.YELLOW));
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
source.sendMessage(Component.text(
zh ? " /felis help 查看命令" : " /felis help for commands", NamedTextColor.GRAY));
return;
}
source.sendMessage(field("root-domain", config.rootDomain()));
source.sendMessage(field("login", config.loginServer()));
source.sendMessage(field("lobby", config.lobbyServer()));
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
source.sendMessage(Component.text(
zh ? " /felis help 查看命令" : " /felis help for commands", NamedTextColor.GRAY));
}
private void sendHelp(CommandSource source) {
source.sendMessage(Component.text("Felis commands", NamedTextColor.AQUA));
helpLine(source, "/felis", "proxy and routing status");
helpLine(source, "/felis server", "the felis servers this proxy knows");
helpLine(source, "/felis go <server>", "start a server and move you in when it's ready");
helpLine(source, "/felis claim", "take ownership of the server you're on");
helpLine(source, "/felis migrate", "move your servers to another account");
helpLine(source, "/felis web", "where the web consoles live");
helpLine(source, "/felis web op approve <code>", "approve a pending operator sign-in");
boolean zh = zh(source);
source.sendMessage(Component.text(zh ? "Felis 命令" : "Felis commands", NamedTextColor.AQUA));
helpLine(source, "/felis",
zh ? "代理与路由状态" : "proxy and routing status");
helpLine(source, "/felis server",
zh ? "此代理已知的 felis 服务器" : "the felis servers this proxy knows");
helpLine(source, "/felis go <server>",
zh ? "启动服务器并在就绪后把你传送过去" : "start a server and move you in when it's ready");
helpLine(source, "/felis claim",
zh ? "认领你所在的服务器" : "take ownership of the server you're on");
helpLine(source, "/felis migrate",
zh ? "把你的服务器迁移到另一个账户" : "move your servers to another account");
helpLine(source, "/felis web",
zh ? "网页控制台地址" : "where the web consoles live");
helpLine(source, "/felis web op approve <code>",
zh ? "批准待处理的管理员登录" : "approve a pending operator sign-in");
}
private void sendServerList(CommandSource source) {
if (!gateInfo(source)) {
return;
}
boolean zh = zh(source);
if (!routingActive) {
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
source.sendMessage(Component.text(
zh ? "Felis 路由已禁用。" : "Felis routing is disabled.", NamedTextColor.YELLOW));
return;
}
List<ServerView> servers = registry.all().stream()
.filter(v -> !isSystemServer(v.name()))
.toList();
if (servers.isEmpty()) {
source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY));
source.sendMessage(Component.text(
zh ? "暂无已知的 felis 服务器。" : "No felis servers known yet.", NamedTextColor.GRAY));
return;
}
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
source.sendMessage(Component.text(zh ? "Felis 服务器:" : "Felis servers:", NamedTextColor.AQUA));
for (ServerView v : servers) {
String phase = v.phase() == null ? "?" : v.phase();
String ready = v.ready() ? (zh ? ",就绪" : ", ready") : "";
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
.append(Component.text("[" + phase + (v.ready() ? ", ready" : "") + "]",
.append(Component.text("[" + phase + ready + "]",
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
}
}
@@ -384,8 +419,9 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
String target = serverArg.trim();
@@ -398,12 +434,15 @@ public final class FelisVelocityPlugin {
}
if (match == null) {
player.sendMessage(Component.text(
"No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
zh ? "没有名为「" + target + "」的 felis 服务器。试试 /felis server。"
: "No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
return;
}
Optional<ServerConnection> current = player.getCurrentServer();
if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) {
player.sendMessage(Component.text("You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "你已经在「" + match.name() + "」上了。"
: "You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
return;
}
// Wake + park + transfer through the shared waiting queue; it reports its own
@@ -416,29 +455,36 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
Optional<ServerConnection> current = player.getCurrentServer();
if (current.isEmpty()) {
player.sendMessage(Component.text("Join a server before claiming it.", NamedTextColor.YELLOW));
player.sendMessage(Component.text(
zh ? "请先加入一个服务器再认领。" : "Join a server before claiming it.",
NamedTextColor.YELLOW));
return;
}
String name = current.get().getServerInfo().getName();
if (!registry.isManaged(name)) {
player.sendMessage(Component.text(
"« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW));
zh ? "「" + name + "」不是可认领的 felis 服务器。"
: "« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW));
return;
}
UUID uuid = player.getUniqueId();
player.sendMessage(Component.text("Claiming « " + name + " »…", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "正在认领「" + name + "」……" : "Claiming « " + name + " »…", NamedTextColor.GRAY));
async(() -> {
try {
apiClient.claim(name, uuid);
player.sendMessage(Component.text("You now own « " + name + " ».", NamedTextColor.GREEN));
player.sendMessage(Component.text(
zh ? "你现在拥有「" + name + "」了。" : "You now own « " + name + " ».",
NamedTextColor.GREEN));
} catch (LinkException e) {
player.sendMessage(Component.text(claimError(e, name), NamedTextColor.RED));
player.sendMessage(Component.text(claimError(e, name, zh), NamedTextColor.RED));
}
});
}
@@ -455,28 +501,34 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
UUID uuid = player.getUniqueId();
String who = player.getUsername();
player.sendMessage(Component.text("Starting account migration…", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "正在发起账户迁移……" : "Starting account migration…", NamedTextColor.GRAY));
async(() -> {
try {
apiClient.migrateStart(uuid);
String root = config.rootDomain();
String panelHost = config.panelHostname();
player.sendMessage(Component.text(
"Migration started — finish it on the web console:", NamedTextColor.GREEN));
zh ? "迁移已发起——请在网页控制台完成:"
: "Migration started — finish it on the web console:", NamedTextColor.GREEN));
player.sendMessage(Component.text(
" " + (root == null ? "the players' web console" : "https://console." + root),
" " + (panelHost == null
? (zh ? "玩家网页控制台" : "the players' web console")
: "https://" + panelHost + "/account"),
NamedTextColor.WHITE));
player.sendMessage(Component.text(
"You'll confirm it's you, name the account to receive your servers, then get a code.",
zh ? "你需要确认身份、指定接收服务器的账户,然后获得一个迁移码。"
: "You'll confirm it's you, name the account to receive your servers, then get a code.",
NamedTextColor.GRAY));
logger.info("Felis: account migration started in-game by {} ({})", who, uuid);
} catch (LinkException e) {
player.sendMessage(Component.text(migrateError(e), NamedTextColor.RED));
player.sendMessage(Component.text(migrateError(e, zh), NamedTextColor.RED));
}
});
}
@@ -485,17 +537,26 @@ public final class FelisVelocityPlugin {
if (!gateInfo(source)) {
return;
}
String root = config.rootDomain();
if (root == null) {
boolean zh = zh(source);
String panelHost = config.panelHostname();
String adminHost = config.adminHostname();
if (panelHost == null && adminHost == null) {
source.sendMessage(Component.text(
"The web console isn't configured on this proxy.", NamedTextColor.YELLOW));
zh ? "此代理未配置网页控制台。"
: "The web console isn't configured on this proxy.", NamedTextColor.YELLOW));
return;
}
source.sendMessage(Component.text("Felis web consoles", NamedTextColor.AQUA));
source.sendMessage(field("players", "https://console." + root));
source.sendMessage(field("operators", "https://op.console." + root));
source.sendMessage(Component.text(
" operators: /felis web op approve <code> vouches for a pending sign-in",
zh ? "Felis 网页控制台" : "Felis web consoles", NamedTextColor.AQUA));
if (panelHost != null) {
source.sendMessage(field(zh ? "玩家" : "players", "https://" + panelHost));
}
if (adminHost != null) {
source.sendMessage(field(zh ? "管理员" : "operators", "https://" + adminHost));
}
source.sendMessage(Component.text(
zh ? " 管理员:/felis web op approve <code> 用于为待处理登录作担保"
: " operators: /felis web op approve <code> vouches for a pending sign-in",
NamedTextColor.GRAY));
}
@@ -503,12 +564,20 @@ public final class FelisVelocityPlugin {
if (!gateInfo(source)) {
return;
}
source.sendMessage(Component.text("Operator sign-in", NamedTextColor.AQUA));
boolean zh = zh(source);
String adminHost = config.adminHostname();
String site = adminHost != null ? adminHost : (zh ? "管理员控制台" : "the operator console");
source.sendMessage(Component.text(
"An operator signing in at op.console shows an approval code. Run", NamedTextColor.GRAY));
zh ? "管理员登录" : "Operator sign-in", NamedTextColor.AQUA));
source.sendMessage(Component.text(
zh ? "管理员在 " + site + " 登录时会显示一个批准码。运行"
: "An operator signing in at " + site + " shows an approval code. Run",
NamedTextColor.GRAY));
source.sendMessage(Component.text(" /felis web op approve <code>", NamedTextColor.WHITE));
source.sendMessage(Component.text(
"to vouch for it — you must be an online, linked administrator.", NamedTextColor.GRAY));
zh ? "即可为其担保——你必须是已绑定并在线的管理员。"
: "to vouch for it — you must be an online, linked administrator.",
NamedTextColor.GRAY));
}
private void doOpApprove(CommandSource source, String codeArg) {
@@ -516,36 +585,56 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
String code = codeArg.trim();
if (!OP_LOGIN_CODE.matcher(code).matches()) {
player.sendMessage(Component.text(
"That doesn't look like a valid approval code.", NamedTextColor.RED));
zh ? "这不像一个有效的批准码。" : "That doesn't look like a valid approval code.",
NamedTextColor.RED));
return;
}
UUID approver = player.getUniqueId();
String who = player.getUsername();
player.sendMessage(Component.text("Approving operator sign-in…", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "正在批准管理员登录……" : "Approving operator sign-in…", NamedTextColor.GRAY));
async(() -> {
try {
apiClient.opLoginApprove(code, approver);
player.sendMessage(Component.text(
"Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
zh ? "已批准——对方现在可以完成登录了。"
: "Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver);
} catch (LinkException e) {
player.sendMessage(Component.text(opApproveError(e), NamedTextColor.RED));
player.sendMessage(Component.text(opApproveError(e, zh), NamedTextColor.RED));
}
});
}
// ---- helpers ----
private Component routingDisabled() {
/**
* zh reports whether the caller's client locale is Chinese, so player-facing
* text can follow the client language. The console (and any non-player source)
* always reads English, and a client that has not yet sent its settings falls
* back to English too. Package-private so {@link WaitingRouter} and the other
* proxy faces share the one locale rule.
*/
static boolean zh(CommandSource source) {
if (!(source instanceof Player)) {
return false;
}
Locale locale = ((Player) source).getPlayerSettings().getLocale();
return locale != null && "zh".equalsIgnoreCase(locale.getLanguage());
}
private Component routingDisabled(boolean zh) {
return Component.text(
"Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."),
zh ? "此代理已禁用 Felis 路由" + (onlineMode ? "(未设置 root-domain)。" : "(离线模式)。")
: "Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."),
NamedTextColor.YELLOW);
}
@@ -555,52 +644,66 @@ public final class FelisVelocityPlugin {
}
// claimError maps the felis-api claim refusals (spec §9.3) to player-safe text.
private static String claimError(LinkException e, String server) {
private static String claimError(LinkException e, String server, boolean zh) {
switch (e.statusCode()) {
case 412:
return "Link your account on the web console before claiming a server.";
return zh ? "请先在网页控制台绑定账户,再认领服务器。"
: "Link your account on the web console before claiming a server.";
case 403:
return "You've reached your server limit — you can't claim another.";
return zh ? "你已达到服务器数量上限——无法再认领。"
: "You've reached your server limit — you can't claim another.";
case 409:
return "« " + server + " » is already owned.";
return zh ? "「" + server + "」已有主人。"
: "« " + server + " » is already owned.";
case 404:
return "« " + server + " » is no longer available.";
return zh ? "「" + server + "」已不可用。"
: "« " + server + " » is no longer available.";
case 0:
return "Felis is temporarily unavailable — please try again.";
return zh ? "Felis 暂时不可用——请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
return "Couldn't claim « " + server + " » right now. Please try again.";
return zh ? "现在无法认领「" + server + "」。请稍后再试。"
: "Couldn't claim « " + server + " » right now. Please try again.";
}
}
// migrateError maps the felis-api migrate-start refusals (spec §B3) to player-safe
// text. A 404 means the caller's UUID isn't linked to any account to migrate; a 409
// means the linked account can't start one (already migrated, or retired).
private static String migrateError(LinkException e) {
private static String migrateError(LinkException e, boolean zh) {
switch (e.statusCode()) {
case 404:
return "Link your account on the web console before migrating.";
return zh ? "请先在网页控制台绑定账户,再进行迁移。"
: "Link your account on the web console before migrating.";
case 409:
return "This account can't start a migration (already migrated or retired).";
return zh ? "此账户无法发起迁移(已迁移或已停用)。"
: "This account can't start a migration (already migrated or retired).";
case 0:
return "Felis is temporarily unavailable — please try again.";
return zh ? "Felis 暂时不可用——请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
return "Couldn't start the migration right now. Please try again.";
return zh ? "现在无法发起迁移。请稍后再试。"
: "Couldn't start the migration right now. Please try again.";
}
}
// opApproveError maps the internal approve refusals to player-safe text. A 403 is
// the API's own admin re-check (defence in depth over the in-game gate); a 404
// means no live pending request carries that code.
private static String opApproveError(LinkException e) {
private static String opApproveError(LinkException e, boolean zh) {
switch (e.statusCode()) {
case 403:
return "Only a linked administrator may approve an operator sign-in.";
return zh ? "只有已绑定的管理员才能批准管理员登录。"
: "Only a linked administrator may approve an operator sign-in.";
case 404:
return "No pending operator sign-in with that code (it may have expired).";
return zh ? "没有携带该码的待处理管理员登录(可能已过期)。"
: "No pending operator sign-in with that code (it may have expired).";
case 0:
return "Felis is temporarily unavailable — please try again.";
return zh ? "Felis 暂时不可用——请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
return "Couldn't approve that sign-in right now. Please try again.";
return zh ? "现在无法批准该登录。请稍后再试。"
: "Couldn't approve that sign-in right now. Please try again.";
}
}
@@ -54,14 +54,16 @@ public final class MotdResponder {
event.setPing(b.build());
}
// The server-list ping carries no client locale, so the MOTD status uses the
// both-languages-in-one-line pattern the modded /link clients share.
private static String statusLine(ServerView v) {
if (v.ready()) {
return "online";
return "在线 / online";
}
if ("Running".equals(v.desiredState())) {
return "starting…";
return "启动中… / starting…";
}
return "sleeping — join to wake";
return "休眠中,加入即唤醒 / sleeping — join to wake";
}
private static NamedTextColor statusColor(ServerView v) {
@@ -47,8 +47,10 @@ import java.util.concurrent.ConcurrentHashMap;
* command/menu queue entries are checked the same way. The wake is then gated
* server-side by autostartPolicy keyed on the player's online-mode UUID: a 403 means
* this player may not start the server (we tell them and stop), a 429 means a wake is
* already in flight (we keep waiting). Real user-backend joins are reported back so
* the reaper sees activity and the player is auto-added to the allowlist.
* already in flight (we keep waiting), and a 503 means the cluster is at capacity
* (we tell them to try later — nothing is coming up, so we do not enqueue). Real
* user-backend joins are reported back so the reaper sees activity and the player is
* auto-added to the allowlist.
*/
public final class WaitingRouter {
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
@@ -132,7 +134,9 @@ public final class WaitingRouter {
if (login.isEmpty()) {
event.setInitialServer(null);
player.disconnect(Component.text(
"The Felis login gate is unavailable. Please reconnect shortly.",
FelisVelocityPlugin.zh(player)
? "Felis 登录网关不可用,请稍后重连。"
: "The Felis login gate is unavailable. Please reconnect shortly.",
NamedTextColor.RED));
return;
}
@@ -161,7 +165,10 @@ public final class WaitingRouter {
event.setResult(ServerPreConnectEvent.ServerResult.denied());
if (!serverNamed(event.getOriginalServer(), lobbyServer)) {
player.sendMessage(Component.text(
"The login gate may only release players to the lobby.", NamedTextColor.RED));
FelisVelocityPlugin.zh(player)
? "登录网关只能把玩家放行到大厅。"
: "The login gate may only release players to the lobby.",
NamedTextColor.RED));
log.warn("Felis: denied login-gate transfer for {} to {}",
player.getUniqueId(), event.getOriginalServer().getServerInfo().getName());
return null;
@@ -173,17 +180,20 @@ public final class WaitingRouter {
private void authorizeLoginRelease(ServerPreConnectEvent event) {
Player player = event.getPlayer();
UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player);
try {
if (!api.linkStatus(id)) {
player.sendMessage(Component.text(
"Finish signing in before leaving the login area.", NamedTextColor.YELLOW));
zh ? "请先完成登录,再离开登录区。"
: "Finish signing in before leaving the login area.", NamedTextColor.YELLOW));
return;
}
} catch (LinkException e) {
log.warn("Felis: could not verify login release for {} (status={}): {}",
id, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Login verification is temporarily unavailable. Please wait and try again.",
zh ? "登录验证暂时不可用,请稍候重试。"
: "Login verification is temporarily unavailable. Please wait and try again.",
NamedTextColor.RED));
return;
}
@@ -202,7 +212,8 @@ public final class WaitingRouter {
pendingTargets.remove(id, targetName);
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
player.sendMessage(Component.text(
"« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
zh ? "「" + targetName + "」已不可用。"
: "« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
return;
}
Optional<RegisteredServer> backend = registry.registered(targetName);
@@ -260,10 +271,12 @@ public final class WaitingRouter {
continue;
}
Player player = po.get();
boolean zh = FelisVelocityPlugin.zh(player);
if (now > w.deadlineMillis) {
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is taking longer than expected to start. "
zh ? "「" + w.serverName + "」启动耗时超出预期。你可以稍后在大厅重试。"
: "« " + w.serverName + " » is taking longer than expected to start. "
+ "You can try again from the lobby later.", NamedTextColor.YELLOW));
continue;
}
@@ -287,7 +300,8 @@ public final class WaitingRouter {
if (!api.linkStatus(id)) {
waiting.remove(id);
player.sendMessage(Component.text(
"Your account is no longer linked. Reconnect to sign in again.",
zh ? "你的账户已不再绑定。请重连以重新登录。"
: "Your account is no longer linked. Reconnect to sign in again.",
NamedTextColor.RED));
continue;
}
@@ -298,7 +312,8 @@ public final class WaitingRouter {
}
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
zh ? "「" + w.serverName + "」已就绪——正在把你传送过去……"
: "« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
// Tell a menu-driven lobby its tile is live before we pull the player off
// it; the proxy still performs the actual Connect just below.
MenuTransferListener listener = menuListener;
@@ -311,18 +326,21 @@ public final class WaitingRouter {
private void authorizeAndWait(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player);
plugin.async(() -> {
try {
if (!api.linkStatus(id)) {
player.sendMessage(Component.text(
"Finish signing in before joining a server.", NamedTextColor.YELLOW));
zh ? "请先完成登录,再加入服务器。"
: "Finish signing in before joining a server.", NamedTextColor.YELLOW));
return;
}
} catch (LinkException e) {
log.warn("Felis: could not verify queue entry for {} (status={}): {}",
id, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Login verification is temporarily unavailable. Please try again shortly.",
zh ? "登录验证暂时不可用,请稍后重试。"
: "Login verification is temporarily unavailable. Please try again shortly.",
NamedTextColor.RED));
return;
}
@@ -335,26 +353,44 @@ public final class WaitingRouter {
// both the account gate and the server-side autostart policy.
private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player);
try {
api.wake(serverName, id);
} catch (LinkException e) {
switch (e.statusCode()) {
case 403:
player.sendMessage(Component.text(
"You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
zh ? "你无权启动「" + serverName + "」。"
: "You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
return;
case 429:
break; // a wake is already in flight → join the existing wait
case 503:
if ("at_capacity".equals(e.errorCode())) {
// at_capacity: nothing is coming up, so enqueueing would only strand
// the player until the timeout. Be honest and let them retry later.
player.sendMessage(Component.text(
zh ? "集群当前已满——「" + serverName + "」暂时无法启动。请稍后再试。"
: "The cluster is at capacity right now — « " + serverName
+ " » can't start. Please try again later.",
NamedTextColor.YELLOW));
return;
}
// A 503 without the at_capacity code is a plain outage, not a
// capacity verdict — report it like any other failure.
// fall through
default:
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't start « " + serverName + " » right now. Try again shortly.",
zh ? "现在无法启动「" + serverName + "」。请稍后再试。"
: "Couldn't start « " + serverName + " » right now. Try again shortly.",
NamedTextColor.RED));
return;
}
}
player.sendMessage(Component.text(
"Starting « " + serverName + " » — you'll be moved in automatically.",
zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。"
: "Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY));
waiting.put(id, new Waiter(
serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
@@ -364,7 +400,9 @@ public final class WaitingRouter {
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
if (err != null || (result != null && !result.isSuccessful())) {
player.sendMessage(Component.text(
"Couldn't connect you to « " + serverName + " ». Please try again.",
FelisVelocityPlugin.zh(player)
? "无法把你连接到「" + serverName + "」。请重试。"
: "Couldn't connect you to « " + serverName + " ». Please try again.",
NamedTextColor.RED));
}
});