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:
97 files changed
+1882
-1403
No files matched your search
+15
-7
@@ -215,12 +215,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
Restorer: restorer,
|
Restorer: restorer,
|
||||||
Backuper: backuper,
|
Backuper: backuper,
|
||||||
Submissions: submissions,
|
Submissions: submissions,
|
||||||
// The external face is fronted by SessionAuth: it prefers a local-password
|
// The external face is fronted by SessionAuth: it prefers a local session
|
||||||
// session cookie and otherwise delegates to the Cloudflare-Access JWT verifier,
|
// cookie (minted by the passwordless doors) and otherwise delegates to the
|
||||||
// so both auth models coexist on one face. The delegate's Keyfunc is
|
// Cloudflare-Access JWT verifier, so both auth models coexist on one face. The
|
||||||
// intentionally nil — the JWT path fails closed until a JWKS-backed key function
|
// delegate's Keyfunc is intentionally nil — the JWT path fails closed until a
|
||||||
// is wired (deployment integration point) — while the local-password path is
|
// JWKS-backed key function is wired (deployment integration point) — while the
|
||||||
// live the moment `felis breakGlass` flips local_auth_enabled on.
|
// local session path is live the moment `felis breakGlass` flips
|
||||||
|
// local_auth_enabled on.
|
||||||
External: api.SessionAuth{
|
External: api.SessionAuth{
|
||||||
Repo: repo,
|
Repo: repo,
|
||||||
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
||||||
@@ -229,6 +230,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
},
|
},
|
||||||
RootDomain: cfg.Server.RootDomain,
|
RootDomain: cfg.Server.RootDomain,
|
||||||
AdminHostname: cfg.Auth.AdminHostname,
|
AdminHostname: cfg.Auth.AdminHostname,
|
||||||
|
PanelHostname: cfg.Auth.PanelHostname,
|
||||||
WakeCooldown: 30 * time.Second,
|
WakeCooldown: 30 * time.Second,
|
||||||
// Bound concurrent console/build-log SSE streams per principal. Generous enough
|
// Bound concurrent console/build-log SSE streams per principal. Generous enough
|
||||||
// for legitimate multi-tab / multi-server watching, while capping how many
|
// 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")
|
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())
|
internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
|
||||||
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
|
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
// authority is local root, so it legitimately BYPASSES the web Zero-Trust + Passkey
|
// 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
|
// 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
|
// 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
|
// 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`
|
// 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).
|
// 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
|
// 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
|
// (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,
|
// 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
|
// insert-only mint of an additional staff admin (provisionOperator) that
|
||||||
// deliberately never touches the global local_auth toggle. On a fresh machine (no
|
// 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
|
// 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
|
// 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
|
// 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.
|
// enough for the operator to log in.
|
||||||
if res.provisioned {
|
if res.provisioned {
|
||||||
if res.isOperator {
|
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.
|
// 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)
|
fmt.Fprintf(stdout, "\nfelis breakGlass: Operator account %q provisioned.\n", res.username)
|
||||||
} else {
|
} 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)
|
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
|
||||||
if res.setupTokenURL != "" {
|
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
|
// enableLocalAuth flips the runtime local_auth_enabled toggle on
|
||||||
// direct-to-Postgres. It is a load-bearing write of break-glass: without it
|
// direct-to-Postgres. It is a load-bearing write of break-glass: without it every
|
||||||
// handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a
|
// 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.
|
// successful provisionOwner with local auth off is not a usable thin thread.
|
||||||
func enableLocalAuth(ctx context.Context, s ownerStore) error {
|
func enableLocalAuth(ctx context.Context, s ownerStore) error {
|
||||||
// The setting is read back with json.Unmarshal into a bool, so the stored jsonb
|
// 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)
|
// 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
|
// 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
|
// 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
|
// but does NOT fail the recovery — break-glass must still work when the audit sink
|
||||||
|
|||||||
+1
-1
@@ -123,7 +123,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
panelURL := res.panelURL
|
panelURL := res.panelURL
|
||||||
if panelURL == "" {
|
if panelURL == "" {
|
||||||
panelURL = localPanelURL(setup.cfg.Server.RootDomain)
|
panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !res.provisioned && !res.connectConfigured {
|
if !res.provisioned && !res.connectConfigured {
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ func setupPanelNodePort() int {
|
|||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
|
|
||||||
func localPanelURL(rootDomain string) string {
|
func localPanelURL(rootDomain, adminHostname string) string {
|
||||||
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
|
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
|
||||||
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
|
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
|
||||||
}
|
}
|
||||||
host := defaultAdminHostname(rootDomain, "")
|
host := defaultAdminHostname(rootDomain, adminHostname)
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,8 @@ func localPanelOrigin() string {
|
|||||||
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
|
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPanelAccess(rootDomain string) panelAccessResult {
|
func checkPanelAccess(rootDomain, adminHostname string) panelAccessResult {
|
||||||
base := localPanelURL(rootDomain)
|
base := localPanelURL(rootDomain, adminHostname)
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return panelAccessResult{err: fmt.Errorf("root domain is empty")}
|
return panelAccessResult{err: fmt.Errorf("root domain is empty")}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import (
|
|||||||
// None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a
|
// None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a
|
||||||
// turnkey integration, and "Reverse proxy" just records hostnames and hands the
|
// 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
|
// 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 {
|
type connectChooserModel struct {
|
||||||
rootDomain string
|
rootDomain string
|
||||||
adminHost string
|
adminHost string
|
||||||
@@ -46,8 +47,8 @@ func (m *connectChooserModel) build() *huh.Form {
|
|||||||
// A dim, untitled footnote — deliberately subordinate to the picker above
|
// A dim, untitled footnote — deliberately subordinate to the picker above
|
||||||
// so the screen reads as a menu, not an info page.
|
// so the screen reads as a menu, not an info page.
|
||||||
huh.NewNote().Description(
|
huh.NewNote().Description(
|
||||||
"⚠ Local / reverse proxy gate the admin console on your Owner password alone. "+
|
"⚠ Local / reverse proxy gate the admin console on your Owner sign-in alone "+
|
||||||
"Cloudflare Access adds an edge check in front."),
|
"(passkey / email code). Cloudflare Access adds an edge check in front."),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost,
|
|||||||
|
|
||||||
// applyReverseProxy records the operator's chosen public hostnames and rolls the
|
// 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
|
// 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.
|
// proxy (Caddy/nginx/Traefik/…) terminates TLS in front of the NodePort origin.
|
||||||
func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error {
|
func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error {
|
||||||
if adminHost == "" {
|
if adminHost == "" {
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ func (m *menuModel) build() *huh.Form {
|
|||||||
huh.NewOption("Back up a world now (Sync)", bgSyncBackup),
|
huh.NewOption("Back up a world now (Sync)", bgSyncBackup),
|
||||||
),
|
),
|
||||||
// A dim footnote spelling out the one behavioural difference that matters:
|
// A dim footnote spelling out the one behavioural difference that matters:
|
||||||
// Owner-reset re-enables local-password login, operator-add never touches the
|
// Owner-reset re-enables local session sign-in, operator-add never touches
|
||||||
// global auth toggle.
|
// the global auth toggle.
|
||||||
huh.NewNote().Description(
|
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."),
|
"additional staff admin and leaves the global auth toggle untouched."),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestProvisionCmdSelectsPathByOperation(t *testing.T) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
t.Fatalf("owner provision: %v", msg.err)
|
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 {
|
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))
|
t.Fatalf("want 1 upsert and 0 inserts (performBreakGlass), got upserts=%d inserts=%d", len(f.upserts), len(f.inserts))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// newOperatorModel builds the model for the Add-Operator break-glass operation. It
|
||||||
// always starts at admin authentication: adding an Operator presupposes an existing
|
// 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
|
// 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
|
// 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.
|
// happy path insert a duplicate and hit ErrConflict on every attempt.
|
||||||
func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *ownerModel {
|
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
|
// performAddOperator and performBreakGlass share a signature; the operation
|
||||||
// discriminator selects which one runs. The operator path is insert-only and
|
// discriminator selects which one runs. The operator path is insert-only and
|
||||||
// never flips local auth (see performAddOperator); the Owner path upserts and
|
// never flips local auth (see performAddOperator); the Owner path upserts and
|
||||||
// enables local-password login.
|
// enables local session sign-in.
|
||||||
perform := performBreakGlass
|
perform := performBreakGlass
|
||||||
if m.operation == bgAddOperator {
|
if m.operation == bgAddOperator {
|
||||||
perform = performAddOperator
|
perform = performAddOperator
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
type preflightModel struct {
|
type preflightModel struct {
|
||||||
dbURL string
|
dbURL string
|
||||||
rootDomain string
|
rootDomain string
|
||||||
|
adminHost string
|
||||||
|
|
||||||
sp spinner.Model
|
sp spinner.Model
|
||||||
state pfState
|
state pfState
|
||||||
@@ -55,11 +56,11 @@ type pfMigApplyMsg struct {
|
|||||||
|
|
||||||
type pfPanelMsg struct{ err error }
|
type pfPanelMsg struct{ err error }
|
||||||
|
|
||||||
func newPreflightModel(dbURL, rootDomain string) *preflightModel {
|
func newPreflightModel(dbURL, rootDomain, adminHostname string) *preflightModel {
|
||||||
sp := spinner.New()
|
sp := spinner.New()
|
||||||
sp.Spinner = spinner.Dot
|
sp.Spinner = spinner.Dot
|
||||||
sp.Style = tuiLabel
|
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 {
|
func (m *preflightModel) Init() tea.Cmd {
|
||||||
@@ -221,7 +222,7 @@ func (m *preflightModel) applyMigrations() tea.Cmd {
|
|||||||
|
|
||||||
func (m *preflightModel) checkPanel() tea.Cmd {
|
func (m *preflightModel) checkPanel() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return pfPanelMsg{err: checkPanelAccess(m.rootDomain).err}
|
return pfPanelMsg{err: checkPanelAccess(m.rootDomain, m.adminHost).err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rm.stage = stagePreflight
|
rm.stage = stagePreflight
|
||||||
rm.screen = newPreflightModel(dbURL, rootDomain)
|
rm.screen = newPreflightModel(dbURL, rootDomain, adminHostname)
|
||||||
}
|
}
|
||||||
return rm
|
return rm
|
||||||
}
|
}
|
||||||
@@ -524,7 +524,7 @@ func (m *rootModel) applyConnectResult(msg connectResultMsg) {
|
|||||||
m.result.connectConfigured = true
|
m.result.connectConfigured = true
|
||||||
m.result.reverseProxyGuide = msg.guide
|
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) {
|
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
|
||||||
@@ -555,7 +555,7 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
|
|||||||
method = connectCloudflare
|
method = connectCloudflare
|
||||||
accessLabel = connectMethodLabel(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{
|
return m.adopt(&summaryModel{
|
||||||
panelURL: m.result.panelURL,
|
panelURL: m.result.panelURL,
|
||||||
accessLabel: accessLabel,
|
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 != "" {
|
if method != connectLocal && panelHostname != "" {
|
||||||
return "https://" + panelHostname
|
return "https://" + panelHostname
|
||||||
}
|
}
|
||||||
return localPanelURL(rootDomain)
|
return localPanelURL(rootDomain, adminHostname)
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1031,8 @@ EOF
|
|||||||
api-base-url=http://${api_ip}:8081
|
api-base-url=http://${api_ip}:8081
|
||||||
service-token=${SERVICE_TOKEN}
|
service-token=${SERVICE_TOKEN}
|
||||||
root-domain=${FELIS_ROOT_DOMAIN}
|
root-domain=${FELIS_ROOT_DOMAIN}
|
||||||
|
panel-hostname=console.${FELIS_ROOT_DOMAIN}
|
||||||
|
admin-hostname=op.console.${FELIS_ROOT_DOMAIN}
|
||||||
login-server=${LOGIN_SERVER}
|
login-server=${LOGIN_SERVER}
|
||||||
lobby-server=${LOBBY_SERVER}
|
lobby-server=${LOBBY_SERVER}
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -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_SERVICE_TOKEN` | internal service token (secret) | *(required for login)* |
|
||||||
| `FELIS_ROOT_DOMAIN` | deployment zone, builds `https://console.<zone>` | *(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_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` |
|
| `FELIS_HEALTH_PORT` | readiness port | `8080` |
|
||||||
|
|
||||||
If the API config **or** the root domain is absent the login flow stays **OFF** and
|
If the API config **or** the root domain is absent the login flow stays **OFF** and
|
||||||
|
|||||||
+114
-62
@@ -90,12 +90,12 @@ components:
|
|||||||
in: cookie
|
in: cookie
|
||||||
name: felis_session
|
name: felis_session
|
||||||
description: >-
|
description: >-
|
||||||
Opaque local-password session cookie (external face). Minted by
|
Opaque session cookie (external face). Minted by the passwordless
|
||||||
POST /api/v1/auth/login when local auth is enabled, HttpOnly+Secure+
|
session doors — passkey login, email-OTP, bind code, and op-login
|
||||||
SameSite=Lax and host-only, so an op.console session never reaches the
|
finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console
|
||||||
player console. Only its sha-256 is persisted. SessionAuth prefers this
|
session never reaches the player console. Only its sha-256 is
|
||||||
cookie and otherwise delegates to accessJWT, so the two models coexist on
|
persisted. SessionAuth prefers this cookie and otherwise delegates to
|
||||||
one face.
|
accessJWT, so the two models coexist on one face.
|
||||||
|
|
||||||
responses:
|
responses:
|
||||||
NoContent:
|
NoContent:
|
||||||
@@ -234,7 +234,7 @@ components:
|
|||||||
MyServerView:
|
MyServerView:
|
||||||
type: object
|
type: object
|
||||||
description: One row of the caller's server list (internal/api/repo.go MyServerView).
|
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:
|
properties:
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
subdomain: { type: string }
|
subdomain: { type: string }
|
||||||
@@ -243,6 +243,11 @@ components:
|
|||||||
phase:
|
phase:
|
||||||
allOf: [{ $ref: '#/components/schemas/Phase' }]
|
allOf: [{ $ref: '#/components/schemas/Phase' }]
|
||||||
description: Present only when known.
|
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:
|
BackupView:
|
||||||
type: object
|
type: object
|
||||||
@@ -331,32 +336,30 @@ components:
|
|||||||
UserView:
|
UserView:
|
||||||
type: object
|
type: object
|
||||||
description: One row of the admin user list (internal/api/repo.go UserView).
|
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:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
username: { type: string }
|
username: { type: string }
|
||||||
email: { type: string }
|
email: { type: string }
|
||||||
role: { type: string, enum: [admin, user] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
disabled: { type: boolean }
|
disabled: { type: boolean }
|
||||||
email_verified: { type: boolean }
|
email_verified: { type: boolean }
|
||||||
server_count: { type: integer }
|
server_count: { type: integer }
|
||||||
must_change_password: { type: boolean }
|
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
updated_at: { type: string, format: date-time }
|
updated_at: { type: string, format: date-time }
|
||||||
|
|
||||||
UserDetail:
|
UserDetail:
|
||||||
type: object
|
type: object
|
||||||
description: Full admin view of one user (internal/api/repo.go UserDetail).
|
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:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
username: { type: string }
|
username: { type: string }
|
||||||
email: { type: string }
|
email: { type: string }
|
||||||
role: { type: string, enum: [admin, user] }
|
role: { type: string, enum: [owner, admin, user] }
|
||||||
disabled: { type: boolean }
|
disabled: { type: boolean }
|
||||||
email_verified: { type: boolean }
|
email_verified: { type: boolean }
|
||||||
server_count: { type: integer }
|
server_count: { type: integer }
|
||||||
must_change_password: { type: boolean }
|
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
updated_at: { type: string, format: date-time }
|
updated_at: { type: string, format: date-time }
|
||||||
deleted_at:
|
deleted_at:
|
||||||
@@ -550,27 +553,6 @@ paths:
|
|||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$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:
|
/api/v1/internal/servers/{name}/ready:
|
||||||
post:
|
post:
|
||||||
tags: [servers-internal]
|
tags: [servers-internal]
|
||||||
@@ -787,12 +769,13 @@ paths:
|
|||||||
auth_source:
|
auth_source:
|
||||||
type: string
|
type: string
|
||||||
enum: [mojang, thirdparty]
|
enum: [mojang, thirdparty]
|
||||||
default: mojang
|
|
||||||
description: >
|
description: >
|
||||||
Which Yggdrasil authenticated the in-game UUID (spec §10
|
Which Yggdrasil authenticated the in-game UUID (spec §10
|
||||||
dual-Yggdrasil). Optional; an omitted value defaults to the
|
dual-Yggdrasil). Optional; when omitted it is derived from the
|
||||||
Mojang-priority source. Captured here because only the in-game
|
UUID's version nibble (felis-nano rewrites third-party profiles
|
||||||
side sees the authentication; it is copied onto the link at verify.
|
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:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Code minted.
|
description: Code minted.
|
||||||
@@ -804,6 +787,12 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
code: { type: string }
|
code: { type: string }
|
||||||
expires_at: { type: string, format: date-time }
|
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':
|
'400':
|
||||||
$ref: '#/components/responses/BadRequest'
|
$ref: '#/components/responses/BadRequest'
|
||||||
'401':
|
'401':
|
||||||
@@ -817,10 +806,11 @@ paths:
|
|||||||
description: >
|
description: >
|
||||||
Internal-only, read-only. After a new player scans the QR-encoded link code
|
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
|
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
|
for the UUID it minted against and admits the player on linked:true. Keyed by
|
||||||
the in-game session to user_id. Keyed by the verified UUID (not the scanned
|
the verified UUID (not the scanned code), so it consumes nothing and is safe
|
||||||
code), so it consumes nothing and is safe to poll repeatedly; an unlinked or
|
to poll repeatedly; an unlinked or never-seen UUID returns linked:false. The
|
||||||
never-seen UUID returns linked:false, and user_id is present only when linked.
|
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-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
@@ -828,7 +818,7 @@ paths:
|
|||||||
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Link-completion status; user_id is present only when linked.
|
description: Link-completion status.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -836,7 +826,6 @@ paths:
|
|||||||
required: [linked]
|
required: [linked]
|
||||||
properties:
|
properties:
|
||||||
linked: { type: boolean }
|
linked: { type: boolean }
|
||||||
user_id: { type: string }
|
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
@@ -970,9 +959,11 @@ paths:
|
|||||||
operationId: opLoginPending
|
operationId: opLoginPending
|
||||||
summary: List live pending op.console login requests, oldest first (spec §B).
|
summary: List live pending op.console login requests, oldest first (spec §B).
|
||||||
description: >
|
description: >
|
||||||
Internal-only. Velocity polls it and pushes waiting requests to online admins,
|
Internal-only. Lists the requests awaiting an in-game vouch. Today no plugin
|
||||||
who approve one with /felis web op approve <id>. No pending request is secret
|
consumes it — the staff member reads the request id off the op.console page
|
||||||
to the operator crew.
|
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-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
@@ -1640,6 +1631,62 @@ paths:
|
|||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$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:
|
/api/v1/servers/{name}/status:
|
||||||
get:
|
get:
|
||||||
tags: [servers]
|
tags: [servers]
|
||||||
@@ -2456,28 +2503,29 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [user_id, email, role, is_admin, must_change_password]
|
required: [user_id, email, role, is_admin, is_owner, email_verified]
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string }
|
user_id: { type: string }
|
||||||
email: { type: string, format: email }
|
email: { type: string, format: email }
|
||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum: [user, admin]
|
enum: [user, admin, owner]
|
||||||
description: The principal's role, mirroring users.role.
|
description: The principal's role, mirroring users.role.
|
||||||
is_admin:
|
is_admin:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: >-
|
description: >-
|
||||||
True only when role is admin AND the request arrived via the
|
True only when role is admin or owner AND the request arrived
|
||||||
admin Access path (Principal.IsAdmin()).
|
via the admin Access path (Principal.IsAdmin()).
|
||||||
must_change_password:
|
is_owner:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: >-
|
description: >-
|
||||||
True when a local-password staff account still owes its
|
True only for the Owner principal on the admin Access path
|
||||||
first-login password change. Meaningful only on the
|
(Principal.IsOwner()); gates owner-only panel surfaces.
|
||||||
local-password path (false on the JWT path). The panel routes
|
email_verified:
|
||||||
such an account straight to the change-password card. Reachable
|
type: boolean
|
||||||
while set, alongside change-password and logout, because the
|
description: >-
|
||||||
rest of the API is fenced off until the change completes.
|
Whether the account's email has been verified; the panel
|
||||||
|
nudges unverified accounts through the email-OTP flow.
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
@@ -2771,13 +2819,14 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
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:
|
properties:
|
||||||
username: { type: string }
|
username: { type: string }
|
||||||
email: { type: string, format: email }
|
email: { type: string, format: email }
|
||||||
role: { type: string, enum: [admin, user] }
|
role: { type: string, enum: [admin, user] }
|
||||||
password: { type: string, format: password }
|
|
||||||
must_change_password: { type: boolean, default: true }
|
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: User created.
|
description: User created.
|
||||||
@@ -3091,7 +3140,10 @@ paths:
|
|||||||
summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only).
|
summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only).
|
||||||
description: >-
|
description: >-
|
||||||
The UUID must not already be bound to a different user (409). Same (user, uuid)
|
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-face: [external]
|
||||||
x-felis-tier: owner
|
x-felis-tier: owner
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ sequenceDiagram
|
|||||||
Game->>Game: read verified online-mode UUID
|
Game->>Game: read verified online-mode UUID
|
||||||
Game->>LinkClient: requestCode(mc_uuid)
|
Game->>LinkClient: requestCode(mc_uuid)
|
||||||
LinkClient->>APIInternal: POST /api/v1/internal/account/link/code {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)
|
APIInternal->>Repo: CreateLinkCode(code, mc_uuid, auth_source, expires_at)
|
||||||
Repo-->>APIInternal: inserted account_link_codes row
|
Repo-->>APIInternal: inserted account_link_codes row
|
||||||
APIInternal-->>LinkClient: 201 {code, expires_at}
|
APIInternal-->>LinkClient: 201 {code, expires_at, panel_url?}
|
||||||
LinkClient-->>Game: LinkCode
|
LinkClient-->>Game: LinkCode
|
||||||
Game-->>Player: show one-time code in chat
|
Game-->>Player: show one-time code in chat
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -100,6 +100,12 @@ type API struct {
|
|||||||
// console (console.<root_domain>) the gate is inert.
|
// console (console.<root_domain>) the gate is inert.
|
||||||
AdminHostname string
|
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
|
// WakeCooldown throttles repeated wakes per server (spec §9.1: cooldown hangs
|
||||||
// on the wake lever). Zero disables throttling.
|
// on the wake lever). Zero disables throttling.
|
||||||
WakeCooldown time.Duration
|
WakeCooldown time.Duration
|
||||||
@@ -143,6 +149,21 @@ type API struct {
|
|||||||
streamCap *streamLimiter
|
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.
|
// now returns the current time using the injected clock.
|
||||||
func (a *API) now() time.Time {
|
func (a *API) now() time.Time {
|
||||||
if a.Now != nil {
|
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: "/readyz", Public: true, h: a.handleReadyz},
|
||||||
|
|
||||||
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
|
{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}/ready", h: a.handleReady},
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
|
{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
|
// 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
|
// (Mojang-first) and rewrites third-party UUIDs into a per-source namespace
|
||||||
// before returning the canonical profile (handlers_hasjoined.go).
|
// before returning the canonical profile (handlers_hasjoined.go).
|
||||||
{Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined},
|
{Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined},
|
||||||
// Op-login (passwordless console login): an in-game op requests a login that
|
// Op-login (passwordless op.console login): a staff member starts the login
|
||||||
// the web owner/admin approves, then redeems for a session. Internal face
|
// on the web, and an ONLINE in-game admin vouches for it via velocity's
|
||||||
// carries the pending queue and the approve action (service-token auth, no
|
// /felis web op approve. Internal face carries the pending queue and the
|
||||||
// Principal); the external face carries the start/status/finish the op drives.
|
// 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: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
|
{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: "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/permission", h: a.handleAccessPermission},
|
||||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup},
|
{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},
|
{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
|
// 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 —
|
// learn its own tier and decide which navigation surfaces to render. App-tier —
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type fakeRepo struct {
|
|||||||
linkAuthSource map[string]string
|
linkAuthSource map[string]string
|
||||||
// world backups (spec §7, §22). A nil slice lists empty.
|
// world backups (spec §7, §22). A nil slice lists empty.
|
||||||
backups []fakeBackup
|
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
|
// 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
|
// hermetic tests exercise the same fail-closed semantics the integration impl
|
||||||
// honors.
|
// 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) ----
|
// ---- fleet (SysAdmin cockpit read) ----
|
||||||
|
|
||||||
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
|
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
|
||||||
|
|||||||
@@ -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"
|
// parseWhitelistOutput extracts player names from vanilla's "whitelist list"
|
||||||
// reply, whose format is "There are N whitelisted player(s): a, b, c" (and "There
|
// 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
|
// are no whitelisted players" / a trailing colon for the empty case). The parse
|
||||||
|
|||||||
@@ -51,6 +51,26 @@ func validAuthSource(s string) bool {
|
|||||||
return s == authSourceMojang || s == authSourceThirdParty
|
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.
|
// newLinkCode returns a cryptographically random, unambiguous link code.
|
||||||
func newLinkCode() (string, error) {
|
func newLinkCode() (string, error) {
|
||||||
buf := make([]byte, linkCodeLen)
|
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"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Default an omitted source to Mojang (spec §10 priority) but reject an
|
// Default an omitted source from the UUID's version nibble (v3 = felis-nano
|
||||||
// unrecognised one — a typo'd source must not silently land as a stored value
|
// third-party rewrite, v4 = Mojang; see deriveAuthSource) but reject an
|
||||||
// the panel will later mislabel.
|
// unrecognised explicit one — a typo'd source must not silently land as a
|
||||||
|
// stored value the panel will later mislabel.
|
||||||
authSource := req.AuthSource
|
authSource := req.AuthSource
|
||||||
if authSource == "" {
|
if authSource == "" {
|
||||||
authSource = authSourceMojang
|
authSource = deriveAuthSource(req.MCUUID)
|
||||||
}
|
}
|
||||||
if !validAuthSource(authSource) {
|
if !validAuthSource(authSource) {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
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)
|
writeError(w, r, err)
|
||||||
return
|
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,
|
"code": code,
|
||||||
"expires_at": expiresAt.UTC(),
|
"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
|
// 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>
|
// 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
|
// → 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
|
// 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
|
// → on {linked:true} it admits the player with no reconnect — the whole point of
|
||||||
// with no reconnect — the whole point of scanning over typing.
|
// 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
|
// 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):
|
// 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"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
|
_, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrNotFound):
|
case errors.Is(err, ErrNotFound):
|
||||||
// Not linked yet. For the poller this is simply "keep waiting": velocity
|
// 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)
|
writeError(w, r, err)
|
||||||
return
|
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
|
// linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user
|
||||||
|
|||||||
@@ -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) {
|
t.Run("explicit thirdparty is stored", func(t *testing.T) {
|
||||||
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
|
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
|
||||||
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
|
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
|
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
|
||||||
// This is the passwordless counterpart of handleLogin and the returning-player
|
// This is the returning-player counterpart of handleBindRedeem: an account that
|
||||||
// counterpart of handleBindRedeem: an account that already proved control of an
|
// already proved control of an email (email_verified, migration 0010) logs back in
|
||||||
// email (email_verified, migration 0010) logs back in with a one-time code mailed
|
// with a one-time code mailed to that address — no password exists anywhere in the
|
||||||
// to that address — no password, no in-game Bind Code. The two halves are Public,
|
// product, and no in-game Bind Code is needed the second time. The two halves are
|
||||||
// pre-session routes: the caller has no principal yet, so identity is resolved from
|
// 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
|
// the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the
|
||||||
// code.
|
// code.
|
||||||
//
|
//
|
||||||
@@ -55,9 +55,9 @@ type loginEmailStartRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
|
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
|
||||||
// pre-session). It gates on local sessions being enabled — like handleLogin and
|
// pre-session). It gates on local sessions being enabled — like handleBindRedeem
|
||||||
// handleBindRedeem, minting a code toward a felis_session while SessionAuth would
|
// and the op-login door, minting a code toward a felis_session while SessionAuth
|
||||||
// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
|
// 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.
|
// 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
|
// 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
|
// 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).
|
// handleLoginEmailVerify redeems a login code into a session (Public, pre-session).
|
||||||
// It resolves the address to an account, verifies the code under otpPurposeLogin, and
|
// 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
|
// 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
|
// 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
|
// or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// 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
|
// account's username. The redeem never rewrites users.email (login re-proves an
|
||||||
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
|
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
|
||||||
// untouched by definition.
|
// untouched by definition.
|
||||||
@@ -113,7 +113,7 @@ func TestLoginEmailVertical(t *testing.T) {
|
|||||||
if vb["user_id"] != "u1" || vb["role"] != "user" {
|
if vb["user_id"] != "u1" || vb["role"] != "user" {
|
||||||
t.Fatalf("verify body = %v, want user_id:u1 role:user", vb)
|
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()
|
cookies := w.Result().Cookies()
|
||||||
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
||||||
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
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
|
// 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,
|
// local-auth toggle, the cross-site-forgery Content-Type guard (these are Public,
|
||||||
// credential-minting routes — same rationale as handleLogin), and the input gates
|
// credential-minting routes — same rationale as handleBindRedeem), and the input
|
||||||
// that must reject before any mint or lookup.
|
// gates that must reject before any mint or lookup.
|
||||||
func TestLoginEmailGates(t *testing.T) {
|
func TestLoginEmailGates(t *testing.T) {
|
||||||
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
|
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
|
||||||
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/naming"
|
"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})
|
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
|
// 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
|
// /internal/servers/{name}/ready). The RCON probe is the authoritative gate, so
|
||||||
// this is advisory: it audits the signal and returns 204.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
// Global running-server cap (spec §9.1), shared with the external wake. velocity
|
// 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
|
// treats 503 at_capacity as "cluster full, tell the player to try later" and does
|
||||||
// 429 cooldown's "already waking, keep waiting".
|
// 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)
|
ok, err := a.withinRunningCap(r.Context(), info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, r, err)
|
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
|
// 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
|
// (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 = 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
|
// expose ownership, and folding owner data into ServerInfo would force the
|
||||||
// lifecycle layer to consult Postgres.
|
// lifecycle layer to consult Postgres.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import (
|
|||||||
// No app-level attempt cap is enforced here (unlike the email-OTP flow, whose 1e6
|
// 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
|
// 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
|
// 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
|
// 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
|
// "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
|
// 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
|
// 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,
|
// 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
|
// no Zero Trust in front (unlike op.console). Like the email-OTP login door it is a
|
||||||
// public route, so it requires local sessions to be enabled and a JSON content type
|
// cookie-minting public route, so it requires local sessions to be enabled and a JSON
|
||||||
// (the cross-site-forgery guard) and mints the same host-only felis_session cookie.
|
// 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
|
// The code is trimmed and uppercased so a player who typed it with stray spaces or in
|
||||||
// lowercase still matches, mirroring handleLinkVerify.
|
// lowercase still matches, mirroring handleLinkVerify.
|
||||||
func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
|
||||||
// The minted session is a felis_session cookie, honored only when local sessions
|
// 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
|
// 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
|
// 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
|
// deployment wanting player cookies without local admin login would decouple them
|
||||||
// in SessionAuth — out of scope here (KNOWN coupling).
|
// in SessionAuth — out of scope here (KNOWN coupling).
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ func TestBindRedeemExpiredCode(t *testing.T) {
|
|||||||
|
|
||||||
// TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that
|
// TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that
|
||||||
// SessionAuth would not honor: with local sessions off it returns 403, never a dead
|
// 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) {
|
func TestBindRedeemLocalAuthDisabled(t *testing.T) {
|
||||||
repo := newFakeRepo() // local_auth_enabled never set → fail closed
|
repo := newFakeRepo() // local_auth_enabled never set → fail closed
|
||||||
api := newTestAPI(repo, newFakeCluster())
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import (
|
|||||||
// op.console STAFF login (spec §B op-login): the two-factor door for the most
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
|
||||||
//
|
//
|
||||||
// The two factors:
|
// The two factors:
|
||||||
@@ -26,9 +27,9 @@ import (
|
|||||||
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
|
// 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).
|
// 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
|
// - 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
|
// request via velocity's /felis command (internal approve). The API's own user
|
||||||
// role=admin account may approve; velocity additionally gates the command on
|
// table is the sole authority: only a UUID linked to a role=admin account may
|
||||||
// in-game op, so the API check is defence in depth over its own user table.
|
// 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
|
// 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.
|
// 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
|
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
|
||||||
// face). Velocity polls it and pushes the waiting requests to online admins, who
|
// face). Today no plugin consumes it: the approver learns the request id out-of-band
|
||||||
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
|
// (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.
|
// token and no pending request is secret to the operator crew.
|
||||||
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
|
||||||
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
|
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
|
// 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
|
// 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
|
// and refuses unless that account is role=admin — this check against the API's
|
||||||
// in-game op gate, checked against the API's authoritative user table.
|
// authoritative user table is the only gate; velocity's command itself is unprivileged.
|
||||||
type opLoginApproveRequest struct {
|
type opLoginApproveRequest struct {
|
||||||
ApproverUUID string `json:"approver_uuid"`
|
ApproverUUID string `json:"approver_uuid"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
// all collapse to one op_login_invalid envelope; an early-but-correct code is
|
// 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
|
// preserved (approval is read before the code is consumed), and a wrong code costs
|
||||||
// an attempt without burning the approval.
|
// an attempt without burning the approval.
|
||||||
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
|
// - Admin-only approval. Only a linked role=admin UUID may vouch; the API's own
|
||||||
// API's own user table, defence in depth over velocity's in-game op gate.
|
// 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
|
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ func statusPath(mcUUID string) string {
|
|||||||
|
|
||||||
// TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and
|
// TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and
|
||||||
// proves its load-bearing invariant: the internal completion poll reports the link
|
// 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
|
// only after the WEB verify writes it. velocity mints and polls on the internal
|
||||||
// that verified — never to a UUID the poll itself could name. velocity mints and
|
// face (it holds no web Principal); the durable bind is born on the external face
|
||||||
// polls on the internal face (it holds no web Principal); the durable bind is born
|
// from a logged-in user. That split is the whole security model of the scan, so
|
||||||
// on the external face from a logged-in user. That split is the whole security
|
// the test drives both faces of one API. The poll carries ONLY the boolean — the
|
||||||
// model of the scan, so the test drives both faces of one API.
|
// 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) {
|
func TestQRLoginCompletionPollVertical(t *testing.T) {
|
||||||
const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
user := &Principal{UserID: "u-scan", Email: "[email protected]", Role: "user"}
|
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())
|
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
|
// Now the poll flips: velocity sees linked:true and admits the player. The
|
||||||
// in-game session to — and that user_id is the verifier's, the only identity the
|
// response stays identity-free — linked is the entire contract.
|
||||||
// poll could ever return, since the poll cannot mint a link of its own.
|
|
||||||
w = do(ih, "GET", statusPath(mcUUID), "", nil)
|
w = do(ih, "GET", statusPath(mcUUID), "", nil)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("post-verify poll: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
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 {
|
if b["linked"] != true {
|
||||||
t.Fatalf("post-verify poll body = %v, want linked:true", b)
|
t.Fatalf("post-verify poll body = %v, want linked:true", b)
|
||||||
}
|
}
|
||||||
if got := b["user_id"]; got != user.UserID {
|
if _, ok := b["user_id"]; ok {
|
||||||
t.Fatalf("post-verify poll user_id = %v, want %q (the verifier's id)", got, user.UserID)
|
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())
|
t.Fatalf("poll %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
b := acctBody(t, w)
|
b := acctBody(t, w)
|
||||||
if b["linked"] != true || b["user_id"] != "u-held" {
|
if b["linked"] != true {
|
||||||
t.Fatalf("poll %d body = %v, want linked:true user_id:u-held", i, b)
|
t.Fatalf("poll %d body = %v, want linked:true", i, b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The read must not have disturbed the durable link.
|
// 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
|
// 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
|
// reads whether a UUID is linked — a fact the public web face must not be able
|
||||||
// fish out by UUID — so crossing onto the external face must 404, not answer.
|
// to fish out by UUID — so crossing onto the external face must 404, not answer.
|
||||||
func TestQRLoginStatusFaceSeparation(t *testing.T) {
|
func TestQRLoginStatusFaceSeparation(t *testing.T) {
|
||||||
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
|||||||
@@ -203,6 +203,22 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -437,7 +437,15 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.AuthSource == "" {
|
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 {
|
if err := a.Repo.LinkAccount(r.Context(), userID, body.MCUUID, body.AuthSource); err != nil {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// 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
|
// linked user is an admin. It intentionally does not test HOW the account signs in:
|
||||||
// who signs in via SSO (Cloudflare Access, §14) carries role='admin' with a NULL hash
|
// an Operator may authenticate via SSO (Cloudflare Access, §14) or any local
|
||||||
// and must be protected just the same — the hash is orthogonal to "is staff" and "logs
|
// passwordless door and must be protected just the same — the sign-in method is
|
||||||
// in via the Login Server". Keyed by UUID, the only identity velocity holds.
|
// 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) {
|
func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) {
|
||||||
var ok bool
|
var ok bool
|
||||||
err := p.db.QueryRowContext(ctx,
|
err := p.db.QueryRowContext(ctx,
|
||||||
|
|||||||
+11
-6
@@ -18,13 +18,18 @@ type ServerRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
|
// 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 {
|
type MyServerView struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Subdomain string `json:"subdomain"`
|
Subdomain string `json:"subdomain"`
|
||||||
Owned bool `json:"owned"`
|
Owned bool `json:"owned"`
|
||||||
Claimable bool `json:"claimable"`
|
Claimable bool `json:"claimable"`
|
||||||
Phase string `json:"phase,omitempty"`
|
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
|
// 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);
|
// - code missing/expired → ErrLinkCodeInvalid (does not consume it);
|
||||||
// - the uuid is not yet linked → create a role='user' player row with id
|
// - 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
|
// newUserID (username derived from the uuid so it is unique and
|
||||||
// and deterministic), write the account_links binding, consume the code, and
|
// deterministic), write the account_links binding, consume the code, and
|
||||||
// return newUserID;
|
// return newUserID;
|
||||||
// - the uuid is already linked to a role='user' player → return THAT user
|
// - the uuid is already linked to a role='user' player → return THAT user
|
||||||
// (idempotent "log in via the game"), consuming the code;
|
// (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
|
// Mojang-priority reclaim must never bar them. The predicate is exactly three
|
||||||
// conjuncts: the UUID is linked (account_links), that link authenticated via
|
// conjuncts: the UUID is linked (account_links), that link authenticated via
|
||||||
// 'thirdparty' (auth_source), and the linked user is an admin (role='admin').
|
// '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
|
// It deliberately does NOT ask HOW the staff account signs in: an Operator may
|
||||||
// in through SSO (Cloudflare Access, IdP-agnostic per §14) carries role='admin'
|
// authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local
|
||||||
// with no password_hash, and must be protected all the same — a password hash is
|
// 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
|
// 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
|
// 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
|
// exception never broadens to ordinary thirdparty players (Mojang priority still
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ var reserved = map[string]struct{}{
|
|||||||
"lobby": {},
|
"lobby": {},
|
||||||
"admin": {},
|
"admin": {},
|
||||||
"panel": {},
|
"panel": {},
|
||||||
|
"console": {}, // the player web console (console.<root>); op.console carries a dot and can never collide
|
||||||
"api": {},
|
"api": {},
|
||||||
"felis": {},
|
"felis": {},
|
||||||
"velocity": {},
|
"velocity": {},
|
||||||
@@ -112,12 +113,6 @@ func ValidateSystemServerName(name string) error {
|
|||||||
return nil
|
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
|
// worldVolumeName mirrors operator.dataVolumeName: the per-server StatefulSet's
|
||||||
// volumeClaimTemplate is named "world", so a single-replica server's world PVC
|
// 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
|
// is "world-<name>-0". This is the one naming convention shared by the operator
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestValidateServerName(t *testing.T) {
|
|||||||
{"lobby", false}, // reserved
|
{"lobby", false}, // reserved
|
||||||
{"admin", false}, // reserved
|
{"admin", false}, // reserved
|
||||||
{"api", false}, // reserved
|
{"api", false}, // reserved
|
||||||
|
{"console", false}, // reserved web console host
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
err := naming.ValidateServerName(c.name)
|
err := naming.ValidateServerName(c.name)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Felis Control Panel</title>
|
<title>Felis Console</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">Felis panel assets were not built into this binary.</div>
|
<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
@@ -7,7 +7,6 @@ import type {
|
|||||||
CreateServerRequest,
|
CreateServerRequest,
|
||||||
FleetServer,
|
FleetServer,
|
||||||
Identity,
|
Identity,
|
||||||
LoginResult,
|
|
||||||
Phase,
|
Phase,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
WhitelistImage,
|
WhitelistImage,
|
||||||
@@ -43,7 +42,6 @@ interface MockAccount {
|
|||||||
role: Role;
|
role: Role;
|
||||||
email: string;
|
email: string;
|
||||||
linked: boolean;
|
linked: boolean;
|
||||||
mustChangePassword: boolean;
|
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
@@ -104,8 +102,8 @@ interface SessionContext extends RequestContext {
|
|||||||
const SESSION_COOKIE = "felis_mock_session";
|
const SESSION_COOKIE = "felis_mock_session";
|
||||||
const ROOT_DOMAIN = "dev.felis.localhost";
|
const ROOT_DOMAIN = "dev.felis.localhost";
|
||||||
const API_BASE = "/api/v1";
|
const API_BASE = "/api/v1";
|
||||||
const MOCK_PASSWORD = "devpassword";
|
|
||||||
const MOCK_LINK_CODE = "LINK1234";
|
const MOCK_LINK_CODE = "LINK1234";
|
||||||
|
const MOCK_OTP_CODE = "123456";
|
||||||
const MC_UUID = "00000000-0000-4000-8000-000000000001";
|
const MC_UUID = "00000000-0000-4000-8000-000000000001";
|
||||||
const RESET_ROUTE = `${API_BASE}/__mock/reset`;
|
const RESET_ROUTE = `${API_BASE}/__mock/reset`;
|
||||||
|
|
||||||
@@ -138,7 +136,7 @@ const LOGIN_HINT_STYLE = `
|
|||||||
const LOGIN_HINT_SCRIPT = `
|
const LOGIN_HINT_SCRIPT = `
|
||||||
(() => {
|
(() => {
|
||||||
const id = "felis-mock-login-hint";
|
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 sync = () => {
|
||||||
const existing = document.getElementById(id);
|
const existing = document.getElementById(id);
|
||||||
if (location.pathname === "/login") {
|
if (location.pathname === "/login") {
|
||||||
@@ -198,10 +196,9 @@ function mockBackups(): BackupView[] {
|
|||||||
function initialState(): MockState {
|
function initialState(): MockState {
|
||||||
return {
|
return {
|
||||||
accounts: {
|
accounts: {
|
||||||
owner: account("owner", "owner", true, false, false),
|
owner: account("owner", "owner", true, false),
|
||||||
user: account("user", "user", false, false, false),
|
user: account("user", "user", false, false),
|
||||||
linked: account("linked", "user", true, false, true),
|
linked: account("linked", "user", true, true),
|
||||||
setup: account("setup", "admin", true, true, false),
|
|
||||||
},
|
},
|
||||||
images: [
|
images: [
|
||||||
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
|
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
|
||||||
@@ -214,29 +211,29 @@ function initialState(): MockState {
|
|||||||
],
|
],
|
||||||
servers: [
|
servers: [
|
||||||
server("survival", "Survival SMP", "Running", "owner", {
|
server("survival", "Survival SMP", "Running", "owner", {
|
||||||
players: 12,
|
playersOnline: 12,
|
||||||
maxPlayers: 20,
|
playersMax: 20,
|
||||||
autostartPolicy: "public",
|
autostartPolicy: "public",
|
||||||
}),
|
}),
|
||||||
server("lobby", "Hub Lobby", "Running", "linked", {
|
server("lobby", "Hub Lobby", "Running", "linked", {
|
||||||
players: 28,
|
playersOnline: 28,
|
||||||
maxPlayers: 60,
|
playersMax: 60,
|
||||||
autostartPolicy: "public",
|
autostartPolicy: "public",
|
||||||
}),
|
}),
|
||||||
server("creative", "Creative Lab", "Stopped", "user", {
|
server("creative", "Creative Lab", "Stopped", "user", {
|
||||||
autostartPolicy: "public",
|
autostartPolicy: "public",
|
||||||
maxPlayers: 16,
|
playersMax: 16,
|
||||||
}),
|
}),
|
||||||
server("modded", "Modded Testbed", "Starting", "owner", {
|
server("modded", "Modded Testbed", "Starting", "owner", {
|
||||||
autostartPolicy: "allowlist",
|
autostartPolicy: "allowlist",
|
||||||
maxPlayers: 12,
|
playersMax: 12,
|
||||||
}),
|
}),
|
||||||
server("broken", "Broken Node", "Failed", "user", {
|
server("broken", "Broken Node", "Failed", "user", {
|
||||||
autostartPolicy: "ownerOnly",
|
autostartPolicy: "ownerOnly",
|
||||||
maxPlayers: 8,
|
playersMax: 8,
|
||||||
}),
|
}),
|
||||||
server("claim-me", "Claimable Node", "Stopped", null, {
|
server("claim-me", "Claimable Node", "Stopped", null, {
|
||||||
maxPlayers: 10,
|
playersMax: 10,
|
||||||
}),
|
}),
|
||||||
...generatedServers(),
|
...generatedServers(),
|
||||||
],
|
],
|
||||||
@@ -259,7 +256,7 @@ function initialState(): MockState {
|
|||||||
"dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer",
|
"dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer",
|
||||||
"xray_cheater", "fly_hacker",
|
"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
|
// and a page (>10) so the roster's filter + paging are both exercisable, with a
|
||||||
// few non-whitelisted names to try kick / ban on.
|
// few non-whitelisted names to try kick / ban on.
|
||||||
online: [
|
online: [
|
||||||
@@ -398,8 +395,8 @@ function generatedServers(): MockServer[] {
|
|||||||
const max = 10 + ((i * 7) % 50);
|
const max = 10 + ((i * 7) % 50);
|
||||||
out.push(
|
out.push(
|
||||||
server(`${theme}-${String(n).padStart(2, "0")}`, `${theme} #${n}`, phase, owners[i % owners.length], {
|
server(`${theme}-${String(n).padStart(2, "0")}`, `${theme} #${n}`, phase, owners[i % owners.length], {
|
||||||
players: phase === "Running" ? 1 + ((i * 3) % max) : 0,
|
playersOnline: phase === "Running" ? 1 + ((i * 3) % max) : 0,
|
||||||
maxPlayers: max,
|
playersMax: max,
|
||||||
autostartPolicy: policies[i % policies.length],
|
autostartPolicy: policies[i % policies.length],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -416,13 +413,10 @@ function mockStartupMessage(): string {
|
|||||||
` API base: ${API_BASE}`,
|
` API base: ${API_BASE}`,
|
||||||
` Root domain: ${ROOT_DOMAIN}`,
|
` Root domain: ${ROOT_DOMAIN}`,
|
||||||
"",
|
"",
|
||||||
" Accounts:",
|
" Sign-in (passwordless):",
|
||||||
` owner / ${MOCK_PASSWORD} admin, linked`,
|
` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`,
|
||||||
` user / ${MOCK_PASSWORD} user, not linked`,
|
` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`,
|
||||||
` linked / ${MOCK_PASSWORD} user, linked`,
|
" Passkey: any assertion accepted → owner (admin, linked)",
|
||||||
` setup / ${MOCK_PASSWORD} admin, first-login password change`,
|
|
||||||
"",
|
|
||||||
` Link code: ${MOCK_LINK_CODE}`,
|
|
||||||
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
|
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
|
||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -432,14 +426,12 @@ function account(
|
|||||||
id: AccountID,
|
id: AccountID,
|
||||||
role: Role,
|
role: Role,
|
||||||
linked: boolean,
|
linked: boolean,
|
||||||
mustChangePassword: boolean,
|
|
||||||
emailVerified: boolean,
|
emailVerified: boolean,
|
||||||
): MockAccount {
|
): MockAccount {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
role,
|
role,
|
||||||
linked,
|
linked,
|
||||||
mustChangePassword,
|
|
||||||
emailVerified,
|
emailVerified,
|
||||||
email: `${id}@mock.felis.local`,
|
email: `${id}@mock.felis.local`,
|
||||||
};
|
};
|
||||||
@@ -458,8 +450,8 @@ function server(
|
|||||||
displayName,
|
displayName,
|
||||||
phase,
|
phase,
|
||||||
desiredState: phase === "Stopped" ? "Stopped" : "Running",
|
desiredState: phase === "Stopped" ? "Stopped" : "Running",
|
||||||
players: phase === "Running" ? 1 : 0,
|
playersOnline: phase === "Running" ? 1 : 0,
|
||||||
maxPlayers: 20,
|
playersMax: 20,
|
||||||
autostartPolicy: "ownerOnly",
|
autostartPolicy: "ownerOnly",
|
||||||
owned: false,
|
owned: false,
|
||||||
claimable: false,
|
claimable: false,
|
||||||
@@ -515,15 +507,6 @@ function clearSessionCookie(res: ServerResponse): void {
|
|||||||
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
|
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 {
|
function identity(accountInfo: MockAccount): Identity {
|
||||||
return {
|
return {
|
||||||
user_id: `mock-${accountInfo.id}`,
|
user_id: `mock-${accountInfo.id}`,
|
||||||
@@ -531,7 +514,6 @@ function identity(accountInfo: MockAccount): Identity {
|
|||||||
role: accountInfo.role,
|
role: accountInfo.role,
|
||||||
is_admin: isAdmin(accountInfo.role),
|
is_admin: isAdmin(accountInfo.role),
|
||||||
is_owner: isOwner(accountInfo.role),
|
is_owner: isOwner(accountInfo.role),
|
||||||
must_change_password: accountInfo.mustChangePassword,
|
|
||||||
email_verified: accountInfo.emailVerified,
|
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
|
// 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 SysAdmin cockpit's read). It is the mock mirror of the Go fleetServerView:
|
||||||
// the CRD field names (playersOnline/playersMax, ready, endpoint*) — NOT the
|
// the CRD field names (playersOnline/playersMax, ready, endpoint*) plus the
|
||||||
// me/servers projection's players/maxPlayers — plus the owner joined as the email
|
// runtime `ready`/`endpoint*` fields, and the owner joined as the email
|
||||||
// (COALESCE(email, username) server-side). Endpoint and live player counts are
|
// (COALESCE(email, username) server-side). Endpoint and live player counts are
|
||||||
// gated on Running, exactly as the real cluster reports them.
|
// gated on Running, exactly as the real cluster reports them.
|
||||||
function fleetView(state: MockState): FleetServer[] {
|
function fleetView(state: MockState): FleetServer[] {
|
||||||
@@ -572,8 +554,8 @@ function fleetView(state: MockState): FleetServer[] {
|
|||||||
autostartPolicy: s.autostartPolicy,
|
autostartPolicy: s.autostartPolicy,
|
||||||
endpointMode: "domain",
|
endpointMode: "domain",
|
||||||
endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined,
|
endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined,
|
||||||
playersOnline: ready ? s.players ?? 0 : 0,
|
playersOnline: ready ? s.playersOnline ?? 0 : 0,
|
||||||
playersMax: s.maxPlayers ?? 0,
|
playersMax: s.playersMax ?? 0,
|
||||||
owner: s.owner ? state.accounts[s.owner].email : "",
|
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 {
|
function setPhase(serverInfo: MockServer, phase: Phase): void {
|
||||||
serverInfo.phase = phase;
|
serverInfo.phase = phase;
|
||||||
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
|
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 {
|
function policy(value: unknown): AutostartPolicy {
|
||||||
@@ -615,8 +597,8 @@ function createServer(
|
|||||||
|
|
||||||
const created = server(name, req.displayName?.trim() || name, "Stopped", owner, {
|
const created = server(name, req.displayName?.trim() || name, "Stopped", owner, {
|
||||||
subdomain,
|
subdomain,
|
||||||
players: 0,
|
playersOnline: 0,
|
||||||
maxPlayers: 20,
|
playersMax: 20,
|
||||||
autostartPolicy: policy(req.autostartPolicy),
|
autostartPolicy: policy(req.autostartPolicy),
|
||||||
});
|
});
|
||||||
state.servers.unshift(created);
|
state.servers.unshift(created);
|
||||||
@@ -630,23 +612,6 @@ function sendCreateError(res: ServerResponse, code: CreateError): void {
|
|||||||
|
|
||||||
async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||||
switch (route(ctx)) {
|
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": {
|
case "POST auth/bind": {
|
||||||
const body = await readJSON<{ code?: string }>(ctx.req);
|
const body = await readJSON<{ code?: string }>(ctx.req);
|
||||||
const code = body.code?.trim().toUpperCase();
|
const code = body.code?.trim().toUpperCase();
|
||||||
@@ -734,7 +699,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
case "POST auth/email/verify": {
|
case "POST auth/email/verify": {
|
||||||
const body = await readJSON<{ email?: string; code?: string }>(ctx.req);
|
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");
|
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -805,10 +770,6 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) });
|
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) });
|
||||||
return true;
|
return true;
|
||||||
case "POST auth/change-password":
|
|
||||||
ctx.account.mustChangePassword = false;
|
|
||||||
sendJSON(ctx.res, 200, { ok: true });
|
|
||||||
return true;
|
|
||||||
case "GET backups":
|
case "GET backups":
|
||||||
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
||||||
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
|
// 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": {
|
case "POST account/email/verify": {
|
||||||
const body = await readJSON<{ code?: string }>(ctx.req);
|
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");
|
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -931,7 +892,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
disabled: !!acc.disabled,
|
disabled: !!acc.disabled,
|
||||||
email_verified: acc.emailVerified,
|
email_verified: acc.emailVerified,
|
||||||
server_count: serverCount,
|
server_count: serverCount,
|
||||||
must_change_password: acc.mustChangePassword,
|
|
||||||
created_at: acc.created_at || new Date().toISOString(),
|
created_at: acc.created_at || new Date().toISOString(),
|
||||||
updated_at: acc.updated_at || new Date().toISOString(),
|
updated_at: acc.updated_at || new Date().toISOString(),
|
||||||
} as UserView;
|
} as UserView;
|
||||||
@@ -971,7 +931,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
role: body.role || "user",
|
role: body.role || "user",
|
||||||
email: body.email || `${username}@example.com`,
|
email: body.email || `${username}@example.com`,
|
||||||
linked: false,
|
linked: false,
|
||||||
mustChangePassword: body.must_change_password ?? false,
|
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
@@ -996,7 +955,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
server_count: 0,
|
server_count: 0,
|
||||||
must_change_password: newAcc.mustChangePassword,
|
|
||||||
created_at: newAcc.created_at,
|
created_at: newAcc.created_at,
|
||||||
updated_at: newAcc.updated_at,
|
updated_at: newAcc.updated_at,
|
||||||
} as UserView);
|
} as UserView);
|
||||||
@@ -1033,7 +991,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
disabled: !!acc.disabled,
|
disabled: !!acc.disabled,
|
||||||
email_verified: acc.emailVerified,
|
email_verified: acc.emailVerified,
|
||||||
server_count: serverCount,
|
server_count: serverCount,
|
||||||
must_change_password: acc.mustChangePassword,
|
|
||||||
created_at: acc.created_at || new Date().toISOString(),
|
created_at: acc.created_at || new Date().toISOString(),
|
||||||
updated_at: acc.updated_at || new Date().toISOString(),
|
updated_at: acc.updated_at || new Date().toISOString(),
|
||||||
linked_accounts,
|
linked_accounts,
|
||||||
@@ -1069,7 +1026,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
disabled: !!acc.disabled,
|
disabled: !!acc.disabled,
|
||||||
email_verified: acc.emailVerified,
|
email_verified: acc.emailVerified,
|
||||||
server_count: serverCount,
|
server_count: serverCount,
|
||||||
must_change_password: acc.mustChangePassword,
|
|
||||||
created_at: acc.created_at || new Date().toISOString(),
|
created_at: acc.created_at || new Date().toISOString(),
|
||||||
updated_at: acc.updated_at,
|
updated_at: acc.updated_at,
|
||||||
} as UserView);
|
} as UserView);
|
||||||
@@ -1119,14 +1075,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
return true;
|
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
|
// GET /api/v1/users/{id}/quotas
|
||||||
if (is("GET", ctx) && subAction === "quotas") {
|
if (is("GET", ctx) && subAction === "quotas") {
|
||||||
if (!acc.quota) {
|
if (!acc.quota) {
|
||||||
@@ -1791,7 +1739,7 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (is("GET", ctx) && sub === "players") {
|
if (is("GET", ctx) && sub === "players") {
|
||||||
const max = serverInfo.maxPlayers ?? 0;
|
const max = serverInfo.playersMax ?? 0;
|
||||||
sendJSON(ctx.res, 200, {
|
sendJSON(ctx.res, 200, {
|
||||||
name: serverInfo.name,
|
name: serverInfo.name,
|
||||||
online: access.online.length,
|
online: access.online.length,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Felis · Control Panel</title>
|
<title>Felis · Console</title>
|
||||||
<script>
|
<script>
|
||||||
try {
|
try {
|
||||||
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
|||||||
+4
-6
@@ -6,7 +6,6 @@ import { RequireAdmin } from "@/components/RequireAdmin";
|
|||||||
import { RequireAuth } from "@/components/RequireAuth";
|
import { RequireAuth } from "@/components/RequireAuth";
|
||||||
import { RequireOwner } from "@/components/RequireOwner";
|
import { RequireOwner } from "@/components/RequireOwner";
|
||||||
import { Login } from "@/pages/Login";
|
import { Login } from "@/pages/Login";
|
||||||
import { ChangePassword } from "@/pages/ChangePassword";
|
|
||||||
import { Setup } from "@/pages/Setup";
|
import { Setup } from "@/pages/Setup";
|
||||||
import { Dashboard } from "@/pages/Dashboard";
|
import { Dashboard } from "@/pages/Dashboard";
|
||||||
import { ServersPage } from "@/pages/servers/ServersPage";
|
import { ServersPage } from "@/pages/servers/ServersPage";
|
||||||
@@ -35,19 +34,18 @@ export default function App() {
|
|||||||
<TierProvider>
|
<TierProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<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
|
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="/login" element={<Login />} />
|
||||||
<Route path="/change-password" element={<ChangePassword />} />
|
|
||||||
{/* Owner first-run onboarding. Like /login it sits OUTSIDE RequireAuth:
|
{/* Owner first-run onboarding. Like /login it sits OUTSIDE RequireAuth:
|
||||||
the visitor arrives from the `felis setup` link with no session, and
|
the visitor arrives from the `felis setup` link with no session, and
|
||||||
redeeming the one-time token is what mints one. */}
|
redeeming the one-time token is what mints one. */}
|
||||||
<Route path="/setup" element={<Setup />} />
|
<Route path="/setup" element={<Setup />} />
|
||||||
|
|
||||||
{/* Everything else requires a session. RequireAuth gates the whole app:
|
{/* Everything else requires a session. RequireAuth gates the whole app:
|
||||||
no/expired session → /login, forced first-login change →
|
no/expired session → /login, transient /me failure → still renders
|
||||||
/change-password, transient /me failure → still renders (graded ZT). */}
|
(graded ZT). */}
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
{/* User-Side — app-tier */}
|
{/* User-Side — app-tier */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus, Loader2, Copy, Check } from "lucide-react";
|
import { Plus, Loader2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -27,39 +27,24 @@ interface Props {
|
|||||||
onCreated: (id: string) => void;
|
onCreated: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateRandomPassword(length = 16): string {
|
// Passwordless create (spec §B): the account is minted with no credential at all.
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$";
|
// The new user signs in with an in-game /link bind code (or email-OTP / passkey
|
||||||
let password = "";
|
// once their address is verified), so there is nothing to hand over here — on
|
||||||
for (let i = 0; i < length; i++) {
|
// success we just jump to the new user's detail page.
|
||||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateUserDialog({ onCreated }: Props) {
|
export function CreateUserDialog({ onCreated }: Props) {
|
||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [role, setRole] = useState<"user" | "admin">("user");
|
const [role, setRole] = useState<"user" | "admin">("user");
|
||||||
const [mustChange, setMustChange] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
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() {
|
function reset() {
|
||||||
setUsername("");
|
setUsername("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setRole("user");
|
setRole("user");
|
||||||
setMustChange(true);
|
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setCreatedUser(null);
|
|
||||||
setGeneratedPassword("");
|
|
||||||
setCopied(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -72,21 +57,19 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const genPassword = generateRandomPassword();
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const u = await api.createUser({
|
const u = await api.createUser({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
email: email.trim() || undefined,
|
email: email.trim() || undefined,
|
||||||
role,
|
role,
|
||||||
password: genPassword,
|
|
||||||
must_change_password: mustChange,
|
|
||||||
});
|
});
|
||||||
setGeneratedPassword(genPassword);
|
setOpen(false);
|
||||||
setCreatedUser(u);
|
reset();
|
||||||
|
onCreated(u.id);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e && e.code === "already_exists") {
|
if (e && e.code === "already_exists") {
|
||||||
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
|
setErr(t("users_create_validation_username_taken"));
|
||||||
} else {
|
} else {
|
||||||
setErr(humanizeError(e));
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -126,40 +88,10 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-md" hideClose={submitting}>
|
<DialogContent className="sm:max-w-md" hideClose={submitting}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{createdUser ? t("users_create_success_title") || "创建成功" : t("users_create_title")}</DialogTitle>
|
<DialogTitle>{t("users_create_title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t("users_create_desc")}</DialogDescription>
|
||||||
{createdUser
|
|
||||||
? t("users_create_success_desc") || "请务必复制并妥善保管该用户的初始凭据,关闭后密码将不再显示。"
|
|
||||||
: t("users_create_desc")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -205,19 +137,6 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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} />}
|
{err && <MessageLine kind="error" message={err} />}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -231,7 +150,6 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -237,10 +237,10 @@ export function EditServerDialog({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="es-cpu">CPU 限制</Label>
|
<Label htmlFor="es-cpu">{t("edit_server_cpu")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="es-cpu"
|
id="es-cpu"
|
||||||
placeholder='例如 1, 2, 500m'
|
placeholder={t("edit_server_cpu_placeholder")}
|
||||||
value={form.cpu}
|
value={form.cpu}
|
||||||
onChange={(e) => set("cpu", e.target.value)}
|
onChange={(e) => set("cpu", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useTier } from "@/lib/tier";
|
|||||||
//
|
//
|
||||||
// loading → a full-screen spinner (never flash login during boot /me)
|
// loading → a full-screen spinner (never flash login during boot /me)
|
||||||
// unauthenticated → /login (a genuine 401: no/expired session)
|
// unauthenticated → /login (a genuine 401: no/expired session)
|
||||||
// mustChangePassword → /change-password (forced first-login change)
|
|
||||||
// otherwise → render the app (<Outlet/>)
|
// otherwise → render the app (<Outlet/>)
|
||||||
//
|
//
|
||||||
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
|
// 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
|
// 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.
|
// 401 bounces to /login. Every admin route remains independently server-guarded.
|
||||||
export function RequireAuth() {
|
export function RequireAuth() {
|
||||||
const { loading, unauthenticated, mustChangePassword } = useTier();
|
const { loading, unauthenticated } = useTier();
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -28,6 +27,5 @@ export function RequireAuth() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (unauthenticated) return <Navigate to="/login" replace />;
|
if (unauthenticated) return <Navigate to="/login" replace />;
|
||||||
if (mustChangePassword) return <Navigate to="/change-password" replace />;
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
|
|||||||
<div className="flex items-center gap-1.5 shrink-0 mr-1">
|
<div className="flex items-center gap-1.5 shrink-0 mr-1">
|
||||||
<span>
|
<span>
|
||||||
{running
|
{running
|
||||||
? `${server.players ?? 0}${server.maxPlayers ? `/${server.maxPlayers}` : ""}`
|
? `${server.playersOnline ?? 0}${server.playersMax ? `/${server.playersMax}` : ""}`
|
||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
<Users className="h-3.5 w-3.5" />
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"title": "Account",
|
"title": "Account",
|
||||||
"subtitle": "Identity and Minecraft linking.",
|
"subtitle": "Identity and Minecraft linking.",
|
||||||
"session": "Session",
|
"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",
|
"sign_out": "Sign out",
|
||||||
"signing_out": "Signing out…",
|
"signing_out": "Signing out…",
|
||||||
"minecraft_link": "Minecraft link",
|
"minecraft_link": "Minecraft link",
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
"email_verification": "Email Verification",
|
"email_verification": "Email Verification",
|
||||||
"email_desc": "Verify your email address to secure your account.",
|
"email_desc": "Verify your email address to secure your account.",
|
||||||
"email_verified": "Verified",
|
"email_verified": "Verified",
|
||||||
"email_unverified": "Unverified",
|
|
||||||
"send_code": "Send Code",
|
"send_code": "Send Code",
|
||||||
"sending_code": "Sending…",
|
"sending_code": "Sending…",
|
||||||
"email_step1": "Enter Email Address",
|
"email_step1": "Enter Email Address",
|
||||||
@@ -31,16 +30,43 @@
|
|||||||
"email_verify_btn": "Verify",
|
"email_verify_btn": "Verify",
|
||||||
"email_verifying": "Verifying…",
|
"email_verifying": "Verifying…",
|
||||||
"email_otp_sent": "Verification code sent.",
|
"email_otp_sent": "Verification code sent.",
|
||||||
|
"otp_code_placeholder": "6-digit code",
|
||||||
|
"change_email": "Change email",
|
||||||
|
"continue_btn": "Continue",
|
||||||
"passkeys": "Passkeys",
|
"passkeys": "Passkeys",
|
||||||
"passkeys_desc": "Passkeys let you log in securely using your fingerprint, face, or screen lock PIN.",
|
"passkeys_desc": "Passkeys let you log in securely using your fingerprint, face, or screen lock PIN.",
|
||||||
"no_passkeys": "No registered passkeys.",
|
"no_passkeys": "No registered passkeys.",
|
||||||
|
"loading_passkeys": "Loading passkeys…",
|
||||||
"add_passkey": "Add Passkey",
|
"add_passkey": "Add Passkey",
|
||||||
"passkey_name": "Device Nickname",
|
"passkey_name": "Device Nickname",
|
||||||
"passkey_name_placeholder": "e.g., My Phone, YubiKey",
|
"passkey_name_placeholder": "e.g., My Phone, YubiKey",
|
||||||
"registering_passkey": "Registering…",
|
"registering_passkey": "Registering…",
|
||||||
"delete_passkey": "Delete",
|
|
||||||
"deleting_passkey": "Deleting…",
|
|
||||||
"created_at": "Registered at: ",
|
"created_at": "Registered at: ",
|
||||||
"last_used": "Last used: ",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
{
|
{
|
||||||
"title": "Admin",
|
"title": "Admin",
|
||||||
"subtitle": "Server & content administration",
|
"subtitle": "Server & content administration",
|
||||||
"signed_in_as": " · signed in as {{email}}",
|
|
||||||
"servers": "Servers",
|
"servers": "Servers",
|
||||||
"servers_desc": "Create platform servers from the structured form and manage the ones you operate.",
|
|
||||||
"images": "Images",
|
"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_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.",
|
"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",
|
"add_image_title": "Add External Image",
|
||||||
@@ -26,21 +22,14 @@
|
|||||||
"context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
|
"context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
|
||||||
"base_image_label": "Base Image",
|
"base_image_label": "Base Image",
|
||||||
"base_image_placeholder": "e.g. library/postgres:15",
|
"base_image_placeholder": "e.g. library/postgres:15",
|
||||||
"build_history_title": "Build History",
|
|
||||||
"view_logs_btn": "Logs",
|
"view_logs_btn": "Logs",
|
||||||
"cancel_build_btn": "Cancel",
|
"cancel_build_btn": "Cancel",
|
||||||
"no_builds_title": "No Builds Found",
|
"no_builds_title": "No Builds Found",
|
||||||
"no_builds_hint": "You can trigger your first image build task using the form on the top right.",
|
"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_title": "No images whitelisted",
|
||||||
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
|
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
|
||||||
"enabled": "enabled",
|
"enabled": "enabled",
|
||||||
"disabled": "disabled",
|
"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_ref": "Image Reference",
|
||||||
"table_source": "Source",
|
"table_source": "Source",
|
||||||
"table_status": "Status",
|
"table_status": "Status",
|
||||||
@@ -49,6 +38,8 @@
|
|||||||
"table_requester": "Requester",
|
"table_requester": "Requester",
|
||||||
"table_created_at": "Created At",
|
"table_created_at": "Created At",
|
||||||
"table_duration": "Duration",
|
"table_duration": "Duration",
|
||||||
|
"build_duration_seconds": "{{s}}s",
|
||||||
|
"build_duration_minutes": "{{m}}m {{s}}s",
|
||||||
"filter_all": "All",
|
"filter_all": "All",
|
||||||
"filter_enabled": "Enabled",
|
"filter_enabled": "Enabled",
|
||||||
"filter_disabled": "Disabled",
|
"filter_disabled": "Disabled",
|
||||||
@@ -74,9 +65,7 @@
|
|||||||
"reject_reason": "Rejection Reason",
|
"reject_reason": "Rejection Reason",
|
||||||
"updates_title": "Maintenance Window",
|
"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_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_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_start_label": "Start Time",
|
||||||
"updates_end_label": "End Time",
|
"updates_end_label": "End Time",
|
||||||
"updates_set_title": "Configure Maintenance Window",
|
"updates_set_title": "Configure Maintenance Window",
|
||||||
@@ -107,14 +96,10 @@
|
|||||||
"users_subtitle": "Manage platform user accounts, quotas, and sessions.",
|
"users_subtitle": "Manage platform user accounts, quotas, and sessions.",
|
||||||
"users_create_btn": "Create User",
|
"users_create_btn": "Create User",
|
||||||
"users_create_title": "Create New 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_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_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_username_placeholder": "e.g. alice",
|
"users_create_username_placeholder": "e.g. alice",
|
||||||
"users_create_validation_username": "Username is required.",
|
"users_create_validation_username": "Username is required.",
|
||||||
"users_create_validation_username_taken": "This username is already taken.",
|
"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_placeholder": "Search username or email...",
|
||||||
"users_search_btn": "Search",
|
"users_search_btn": "Search",
|
||||||
"users_filter_role_all": "All Roles",
|
"users_filter_role_all": "All Roles",
|
||||||
@@ -130,7 +115,6 @@
|
|||||||
"users_col_status": "Status",
|
"users_col_status": "Status",
|
||||||
"users_col_created": "Created",
|
"users_col_created": "Created",
|
||||||
"users_view_detail": "Details",
|
"users_view_detail": "Details",
|
||||||
"users_total_count": "{{count}} total users",
|
|
||||||
"users_empty_title": "No Users Found",
|
"users_empty_title": "No Users Found",
|
||||||
"users_empty_hint": "No users match the current filters.",
|
"users_empty_hint": "No users match the current filters.",
|
||||||
"users_back_to_list": "Back to user list",
|
"users_back_to_list": "Back to user list",
|
||||||
@@ -138,7 +122,6 @@
|
|||||||
"users_field_username": "Username",
|
"users_field_username": "Username",
|
||||||
"users_field_email": "Email",
|
"users_field_email": "Email",
|
||||||
"users_field_role": "Role",
|
"users_field_role": "Role",
|
||||||
"users_field_password": "Initial Password",
|
|
||||||
"users_save_btn": "Save Changes",
|
"users_save_btn": "Save Changes",
|
||||||
"users_save_ok": "Changes saved successfully.",
|
"users_save_ok": "Changes saved successfully.",
|
||||||
"users_linked_accounts": "Linked Minecraft Accounts",
|
"users_linked_accounts": "Linked Minecraft Accounts",
|
||||||
@@ -174,25 +157,21 @@
|
|||||||
"users_danger_enable": "Enable User",
|
"users_danger_enable": "Enable User",
|
||||||
"users_danger_enable_desc": "Allow this user to log in again.",
|
"users_danger_enable_desc": "Allow this user to log in again.",
|
||||||
"users_danger_enable_btn": "Enable",
|
"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_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_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_title": "Enable User",
|
||||||
"users_danger_enable_dlg_desc": "This user will be able to log in again.",
|
"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_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_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": "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_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_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..."
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
{
|
{
|
||||||
"login_title": "Sign in to Felis",
|
"login_title": "Sign in to Felis",
|
||||||
"login_subtitle": "Operator console",
|
"login_subtitle": "Player console",
|
||||||
"username": "Username or Email",
|
"login_subtitle_op": "Operator console",
|
||||||
"password": "Password",
|
|
||||||
"sign_in": "Sign in",
|
|
||||||
"signing_in": "Signing in…",
|
"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",
|
"or_divider": "or",
|
||||||
"tab_email_btn": "Sign in with Email OTP",
|
|
||||||
"tab_bind_btn": "Sign in with Bind Code",
|
"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_address": "Email Address",
|
||||||
"email_placeholder": "Enter your registered email",
|
"email_placeholder": "Enter your registered email",
|
||||||
"otp_code": "Verification Code",
|
"otp_code": "Verification Code",
|
||||||
@@ -23,20 +17,18 @@
|
|||||||
"otp_btn": "Verify & Sign In",
|
"otp_btn": "Verify & Sign In",
|
||||||
"resend_in": "s",
|
"resend_in": "s",
|
||||||
"passkey_btn": "Sign in with Passkey",
|
"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": "Bind Code",
|
||||||
"bind_code_placeholder": "e.g., ABCD2345",
|
"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",
|
"bind_btn": "Verify & Sign In",
|
||||||
"binding": "Verifying…",
|
"binding": "Verifying…",
|
||||||
"change_password_title": "Set a new 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.",
|
||||||
"change_password_subtitle_forced": "Your account was issued a one-time password. Choose a new one to continue.",
|
"op_start_btn": "Request operator sign-in",
|
||||||
"change_password_subtitle_voluntary": "Update your console password.",
|
"op_approve_hint": "A code has been emailed to you. Ask an online operator to approve this request in-game:",
|
||||||
"current_password": "Current password",
|
"op_waiting": "Waiting for in-game approval…",
|
||||||
"new_password": "New password",
|
"op_approved": "Approved — enter the code from your email.",
|
||||||
"confirm_new_password": "Confirm new password",
|
"op_restart": "Start over",
|
||||||
"password_mismatch": "Passwords don't match.",
|
|
||||||
"password_min_length": "At least {{min}} characters.",
|
|
||||||
"change_password_btn": "Change password",
|
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"setup_title": "Set up your account",
|
"setup_title": "Set up your account",
|
||||||
"setup_welcome": "Welcome, {{name}}",
|
"setup_welcome": "Welcome, {{name}}",
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
"not_yours_title": "No permission to access backups",
|
"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.",
|
"not_yours_body": "Only the owner or an admin can view and restore this server's backups.",
|
||||||
"latest_title": "Latest backup",
|
"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.",
|
"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_inactive": "Idle archive",
|
||||||
"reason_manual": "Manual backup",
|
"reason_manual": "Manual backup",
|
||||||
@@ -29,7 +27,6 @@
|
|||||||
"expired_cannot_restore": "This backup has expired and can no longer be restored.",
|
"expired_cannot_restore": "This backup has expired and can no longer be restored.",
|
||||||
"col_created": "Backup Time",
|
"col_created": "Backup Time",
|
||||||
"col_size": "Size",
|
"col_size": "Size",
|
||||||
"col_reason": "Type / Reason",
|
|
||||||
"col_expires": "Expires",
|
"col_expires": "Expires",
|
||||||
"col_owner": "Former Owner",
|
"col_owner": "Former Owner",
|
||||||
"col_actions": "Actions"
|
"col_actions": "Actions"
|
||||||
|
|||||||
@@ -15,10 +15,7 @@
|
|||||||
"loading_config": "Loading config…",
|
"loading_config": "Loading config…",
|
||||||
"brand_name": "Felis",
|
"brand_name": "Felis",
|
||||||
"brand_tagline": "K8s-native Minecraft orchestration",
|
"brand_tagline": "K8s-native Minecraft orchestration",
|
||||||
"page_title": "Felis · Control Panel",
|
"page_title": "Felis · Console",
|
||||||
"lang_en": "EN",
|
|
||||||
"lang_zh": "中文",
|
|
||||||
"lang_toggle_hint": "Switch to {{lang}}",
|
|
||||||
"toggle_theme": "Toggle theme",
|
"toggle_theme": "Toggle theme",
|
||||||
"pagination_prev": "Previous",
|
"pagination_prev": "Previous",
|
||||||
"pagination_next": "Next",
|
"pagination_next": "Next",
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
{
|
{
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"subtitle": "Your fleet at a glance.",
|
"subtitle": "Your fleet at a glance.",
|
||||||
"no_servers_title": "No servers yet",
|
|
||||||
"no_servers_hint": "Create your first server or claim an unowned one.",
|
"no_servers_hint": "Create your first server or claim an unowned one.",
|
||||||
"go_to_my_servers": "Go to My servers",
|
|
||||||
"stat_servers": "Servers",
|
"stat_servers": "Servers",
|
||||||
"stat_running": "Running",
|
"stat_running": "Running",
|
||||||
"stat_players_online": "Players online",
|
"stat_players_online": "Players online",
|
||||||
"fleet": "Fleet",
|
"fleet": "Fleet",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"loading_scene": "Loading scene…",
|
"loading_scene": "Loading scene…",
|
||||||
"preparing_scene": "Preparing scene…",
|
|
||||||
"fleet_load": "Fleet Load & Health",
|
"fleet_load": "Fleet Load & Health",
|
||||||
"active_load": "Player Load Rate",
|
"active_load": "Player Load Rate",
|
||||||
"status_distribution": "Node Status Distribution",
|
"status_distribution": "Node Status Distribution",
|
||||||
"account_status": "Account Status",
|
|
||||||
"account_linked_title": "Account Linked",
|
"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_linked_desc": "Your identity is bound to a Minecraft UUID. You have full server ownership and wake permissions.",
|
||||||
"account_unlinked_title": "Account Unlinked",
|
"account_unlinked_title": "Account Unlinked",
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
{
|
{
|
||||||
"local_auth_disabled": "Password sign-in is turned off here — reach this console through your organization's secure access.",
|
"local_auth_disabled": "Direct sign-in is turned off here — reach this console through your organization's secure access.",
|
||||||
"invalid_credentials": "Incorrect username or password.",
|
"staff_account": "This is a staff account — use Operator sign-in instead.",
|
||||||
"weak_password": "Pick a password between 8 and 72 characters.",
|
|
||||||
"password_unchanged": "Your new password must differ from the current one.",
|
|
||||||
"not_linked": "Link your Minecraft account before claiming (Account → Link).",
|
"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.",
|
"already_linked": "That Minecraft account is already linked to another user.",
|
||||||
"quota_exceeded": "You have reached your server quota.",
|
"quota_exceeded": "You have reached your server quota.",
|
||||||
"already_claimed": "Someone else just claimed this server.",
|
"already_claimed": "Someone else just claimed this server.",
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "SysAdmin",
|
"title": "SysAdmin",
|
||||||
"subtitle": "Platform observability cockpit — a window, not a lever.",
|
"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_title": "Fleet",
|
||||||
"fleet_subtitle": "Every server on the platform — phase, owner, players online; start, stop and open a console inline.",
|
"fleet_subtitle": "Every server on the platform — phase, owner, players online; start, stop and open a console inline.",
|
||||||
"fleet_live": "Live",
|
"fleet_live": "Live",
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
"filter_status_all": "All statuses",
|
"filter_status_all": "All statuses",
|
||||||
"search_no_match": "No matching servers found.",
|
"search_no_match": "No matching servers found.",
|
||||||
"search_clear_btn": "Clear filters",
|
"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_linked": "No servers linked to you",
|
||||||
"no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.",
|
"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_running": "Running",
|
||||||
"phase_starting": "Starting",
|
"phase_starting": "Starting",
|
||||||
"phase_stopping": "Stopping",
|
"phase_stopping": "Stopping",
|
||||||
@@ -35,7 +32,6 @@
|
|||||||
"console_offline_title": "Console is offline",
|
"console_offline_title": "Console is offline",
|
||||||
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
|
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
|
||||||
"my_servers_breadcrumb": "My servers",
|
"my_servers_breadcrumb": "My servers",
|
||||||
"console_card_title": "Console",
|
|
||||||
"command_placeholder": "Type a command… e.g. list",
|
"command_placeholder": "Type a command… e.g. list",
|
||||||
"log_connecting": "Connecting…",
|
"log_connecting": "Connecting…",
|
||||||
"log_live": "Live",
|
"log_live": "Live",
|
||||||
@@ -74,7 +70,6 @@
|
|||||||
"access_whitelist_empty": "No players on the whitelist yet.",
|
"access_whitelist_empty": "No players on the whitelist yet.",
|
||||||
"access_whitelist_empty_hint": "Add a player above to let them join.",
|
"access_whitelist_empty_hint": "Add a player above to let them join.",
|
||||||
"access_whitelist_remove": "Remove {{player}} from the whitelist",
|
"access_whitelist_remove": "Remove {{player}} from the whitelist",
|
||||||
"access_whitelist_remove_q": "Remove?",
|
|
||||||
"access_remove": "Remove",
|
"access_remove": "Remove",
|
||||||
"access_whitelist_load_error": "Couldn't load the whitelist.",
|
"access_whitelist_load_error": "Couldn't load the whitelist.",
|
||||||
"access_whitelist_added": "Added {{player}} to the whitelist.",
|
"access_whitelist_added": "Added {{player}} to the whitelist.",
|
||||||
@@ -89,14 +84,11 @@
|
|||||||
"access_online_raw": "View raw server reply",
|
"access_online_raw": "View raw server reply",
|
||||||
"access_updated_at": "Updated {{time}}",
|
"access_updated_at": "Updated {{time}}",
|
||||||
"access_kick_btn": "Kick",
|
"access_kick_btn": "Kick",
|
||||||
"access_kick_q": "Kick?",
|
|
||||||
"access_ban_q": "Ban & block rejoin?",
|
|
||||||
"access_kicked": "Kicked {{player}}.",
|
"access_kicked": "Kicked {{player}}.",
|
||||||
"access_ban_title": "Bans",
|
"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_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_ban_btn": "Ban",
|
||||||
"access_pardon_btn": "Pardon",
|
"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": "Ban {{player}}? They'll be kicked and blocked from rejoining.",
|
||||||
"access_ban_confirm_yes": "Ban",
|
"access_ban_confirm_yes": "Ban",
|
||||||
"access_ban_empty": "No banned players.",
|
"access_ban_empty": "No banned players.",
|
||||||
@@ -129,41 +121,20 @@
|
|||||||
"create_server_policy_allowlist": "Allowlist — listed players wake it",
|
"create_server_policy_allowlist": "Allowlist — listed players wake it",
|
||||||
"create_server_cancel": "Cancel",
|
"create_server_cancel": "Cancel",
|
||||||
"create_server_submit": "Create",
|
"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_title": "Edit Server Config",
|
||||||
"edit_server_desc": "Configure display name, autostart policy, image, memory, and CPU",
|
"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_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_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_group_name": "Group Name",
|
||||||
"luckperms_node": "Permission Node",
|
"luckperms_node": "Permission Node",
|
||||||
"luckperms_action": "Action",
|
"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": "Value",
|
||||||
"luckperms_value_grant": "Grant (True)",
|
"luckperms_value_grant": "Grant (True)",
|
||||||
"luckperms_value_deny": "Deny (False)",
|
"luckperms_value_deny": "Deny (False)",
|
||||||
"luckperms_world": "World Context (Optional)",
|
"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_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_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_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_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_back_to_console": "Back to console",
|
||||||
"luckperms_select_player_prompt": "Please select an online player from the list, or enter a username to query.",
|
"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_value_column": "Value",
|
||||||
"luckperms_world_column": "World",
|
"luckperms_world_column": "World",
|
||||||
"luckperms_actions_column": "Actions",
|
"luckperms_actions_column": "Actions",
|
||||||
"luckperms_query_btn": "Query Player",
|
|
||||||
"luckperms_custom_group_placeholder": "Enter custom group name...",
|
"luckperms_custom_group_placeholder": "Enter custom group name...",
|
||||||
"luckperms_batch_players": "Player Usernames List",
|
"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_recent_actions": "Recent Actions History",
|
||||||
"luckperms_no_recent_actions": "No recent actions.",
|
"luckperms_no_recent_actions": "No recent actions.",
|
||||||
"luckperms_revert": "Revert",
|
"luckperms_revert": "Revert",
|
||||||
"luckperms_reverting": "Reverting...",
|
"luckperms_reverting": "Reverting...",
|
||||||
"luckperms_revert_success": "Action reverted successfully!",
|
"luckperms_revert_success": "Action reverted successfully!",
|
||||||
"luckperms_quick_presets": "Common Presets",
|
|
||||||
"luckperms_presets": "Presets",
|
"luckperms_presets": "Presets",
|
||||||
"luckperms_batch_status": "Batch Progress",
|
"luckperms_no_parent_groups": "No parent groups assigned",
|
||||||
"luckperms_success_count": "Success: {{count}}",
|
"luckperms_global": "global",
|
||||||
"luckperms_failed_count": "Failed: {{count}}"
|
"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",
|
"display_name_placeholder": "e.g., Pixelmon Adventure Pack",
|
||||||
"file_label": "Build Context (.tar.gz)",
|
"file_label": "Build Context (.tar.gz)",
|
||||||
"file_drag_hint": "Drag and drop a .tar.gz file here, or click to browse",
|
"file_drag_hint": "Drag and drop a .tar.gz file here, or click to browse",
|
||||||
"file_selected": "Selected file: {{name}} ({{size}})",
|
|
||||||
"submit_btn": "Submit",
|
"submit_btn": "Submit",
|
||||||
"submitting_create": "Creating submission...",
|
"submitting_create": "Creating submission...",
|
||||||
"submitting_upload": "Uploading build context...",
|
"submitting_upload": "Uploading build context...",
|
||||||
"submit_success": "Modpack submitted successfully!",
|
|
||||||
"list_card_title": "Submission History",
|
|
||||||
"filter_all": "All Statuses",
|
"filter_all": "All Statuses",
|
||||||
"status_pending_review": "Pending Review",
|
"status_pending_review": "Pending Review",
|
||||||
"status_approved": "Approved",
|
"status_approved": "Approved",
|
||||||
@@ -21,7 +18,6 @@
|
|||||||
"table_status": "Status",
|
"table_status": "Status",
|
||||||
"table_created_at": "Submitted At",
|
"table_created_at": "Submitted At",
|
||||||
"table_reviewed_by": "Reviewed By",
|
"table_reviewed_by": "Reviewed By",
|
||||||
"table_image_ref": "Image Reference",
|
|
||||||
"table_reject_reason": "Reject Reason",
|
"table_reject_reason": "Reject Reason",
|
||||||
"no_submissions_title": "No Submissions Found",
|
"no_submissions_title": "No Submissions Found",
|
||||||
"no_submissions_hint": "You haven't submitted any modpacks yet.",
|
"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_type": "Please upload a valid .tar.gz file.",
|
||||||
"error_file_size": "File exceeds the allowed size limit.",
|
"error_file_size": "File exceeds the allowed size limit.",
|
||||||
"error_name_required": "Display name is required.",
|
"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)"
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"title": "账户",
|
"title": "账户",
|
||||||
"subtitle": "身份验证与 Minecraft 关联。",
|
"subtitle": "身份验证与 Minecraft 关联。",
|
||||||
"session": "会话",
|
"session": "会话",
|
||||||
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由本地密码登录还是平台身份代理(Zero-Trust / Access)签发。",
|
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由 Passkey / 邮箱登录还是平台身份代理(Zero-Trust / Access)签发。",
|
||||||
"sign_out": "退出登录",
|
"sign_out": "退出登录",
|
||||||
"signing_out": "退出中…",
|
"signing_out": "退出中…",
|
||||||
"minecraft_link": "Minecraft 关联",
|
"minecraft_link": "Minecraft 关联",
|
||||||
@@ -10,18 +10,17 @@
|
|||||||
"linked_title": "Minecraft 账户已关联。",
|
"linked_title": "Minecraft 账户已关联。",
|
||||||
"linked_desc": "关联后可认领及管理服务器——所有权相关操作(认领、启动、停止)已解锁。",
|
"linked_desc": "关联后可认领及管理服务器——所有权相关操作(认领、启动、停止)已解锁。",
|
||||||
"uuid_label": "UUID",
|
"uuid_label": "UUID",
|
||||||
"step1_title": "在游戏中获取验证码",
|
"step1_title": "在游戏中获取绑定码",
|
||||||
"step1_desc_prefix": "加入任意服务器,在聊天框输入 ",
|
"step1_desc_prefix": "加入任意服务器,在聊天框输入 ",
|
||||||
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性验证码(约 10 分钟内有效)。",
|
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性绑定码(约 10 分钟内有效)。",
|
||||||
"step2_title": "在此输入",
|
"step2_title": "在此输入",
|
||||||
"link_code": "关联码",
|
"link_code": "绑定码",
|
||||||
"link_code_placeholder": "ABCD2345",
|
"link_code_placeholder": "ABCD2345",
|
||||||
"verify_btn": "关联",
|
"verify_btn": "关联",
|
||||||
"verifying": "验证中…",
|
"verifying": "验证中…",
|
||||||
"email_verification": "邮箱验证",
|
"email_verification": "邮箱验证",
|
||||||
"email_desc": "验证你的电子邮箱以确保账号安全。",
|
"email_desc": "验证你的电子邮箱以确保账号安全。",
|
||||||
"email_verified": "已验证",
|
"email_verified": "已验证",
|
||||||
"email_unverified": "未验证",
|
|
||||||
"send_code": "获取验证码",
|
"send_code": "获取验证码",
|
||||||
"sending_code": "发送中…",
|
"sending_code": "发送中…",
|
||||||
"email_step1": "输入电子邮箱",
|
"email_step1": "输入电子邮箱",
|
||||||
@@ -31,16 +30,43 @@
|
|||||||
"email_verify_btn": "验证",
|
"email_verify_btn": "验证",
|
||||||
"email_verifying": "验证中…",
|
"email_verifying": "验证中…",
|
||||||
"email_otp_sent": "验证码已发送。",
|
"email_otp_sent": "验证码已发送。",
|
||||||
|
"otp_code_placeholder": "6 位验证码",
|
||||||
|
"change_email": "修改邮箱",
|
||||||
|
"continue_btn": "继续",
|
||||||
"passkeys": "Passkey 注册管理",
|
"passkeys": "Passkey 注册管理",
|
||||||
"passkeys_desc": "Passkey 允许你使用指纹、面容或设备 PIN 码安全登录面板。",
|
"passkeys_desc": "Passkey 允许你使用指纹、面容或设备 PIN 码安全登录面板。",
|
||||||
"no_passkeys": "未绑定任何 Passkey。",
|
"no_passkeys": "未绑定任何 Passkey。",
|
||||||
|
"loading_passkeys": "加载 Passkey 列表中…",
|
||||||
"add_passkey": "注册新 Passkey",
|
"add_passkey": "注册新 Passkey",
|
||||||
"passkey_name": "设备昵称",
|
"passkey_name": "设备昵称",
|
||||||
"passkey_name_placeholder": "例如:我的手机, YubiKey",
|
"passkey_name_placeholder": "例如:我的手机, YubiKey",
|
||||||
"registering_passkey": "注册中…",
|
"registering_passkey": "注册中…",
|
||||||
"delete_passkey": "删除",
|
|
||||||
"deleting_passkey": "删除中…",
|
|
||||||
"created_at": "注册时间:",
|
"created_at": "注册时间:",
|
||||||
"last_used": "上次使用:",
|
"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}} 台服务器转移至本账户。"
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
{
|
{
|
||||||
"title": "管理",
|
"title": "管理",
|
||||||
"subtitle": "服务器与内容管理",
|
"subtitle": "服务器与内容管理",
|
||||||
"signed_in_as": " · 当前登录:{{email}}",
|
|
||||||
"servers": "服务器",
|
"servers": "服务器",
|
||||||
"servers_desc": "通过结构化表单创建并管理平台服务器。",
|
|
||||||
"images": "镜像",
|
"images": "镜像",
|
||||||
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
|
|
||||||
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
|
|
||||||
"images_title": "镜像",
|
"images_title": "镜像",
|
||||||
"images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。您可以添加外部镜像,或将不需要的镜像从白名单中删除。",
|
"images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。您可以添加外部镜像,或将不需要的镜像从白名单中删除。",
|
||||||
"add_image_title": "添加外部镜像",
|
"add_image_title": "添加外部镜像",
|
||||||
@@ -26,21 +22,14 @@
|
|||||||
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
|
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
|
||||||
"base_image_label": "基础镜像",
|
"base_image_label": "基础镜像",
|
||||||
"base_image_placeholder": "例如: library/postgres:15",
|
"base_image_placeholder": "例如: library/postgres:15",
|
||||||
"build_history_title": "构建历史",
|
|
||||||
"view_logs_btn": "日志",
|
"view_logs_btn": "日志",
|
||||||
"cancel_build_btn": "取消",
|
"cancel_build_btn": "取消",
|
||||||
"no_builds_title": "暂无构建任务",
|
"no_builds_title": "暂无构建任务",
|
||||||
"no_builds_hint": "您可以使用右上角表单触发第一个镜像构建任务。",
|
"no_builds_hint": "您可以使用右上角表单触发第一个镜像构建任务。",
|
||||||
"build_log_title": "构建日志终端",
|
|
||||||
"log_streaming": "实时输出中",
|
|
||||||
"log_finished": "已结束",
|
|
||||||
"no_images_title": "无白名单镜像",
|
"no_images_title": "无白名单镜像",
|
||||||
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
|
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"footer_kubectl": "kubectl / CRD",
|
|
||||||
"footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
|
|
||||||
"footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。",
|
|
||||||
"table_ref": "镜像名称",
|
"table_ref": "镜像名称",
|
||||||
"table_source": "来源",
|
"table_source": "来源",
|
||||||
"table_status": "状态",
|
"table_status": "状态",
|
||||||
@@ -49,6 +38,8 @@
|
|||||||
"table_requester": "发起人",
|
"table_requester": "发起人",
|
||||||
"table_created_at": "创建时间",
|
"table_created_at": "创建时间",
|
||||||
"table_duration": "耗时",
|
"table_duration": "耗时",
|
||||||
|
"build_duration_seconds": "{{s}}秒",
|
||||||
|
"build_duration_minutes": "{{m}}分{{s}}秒",
|
||||||
"filter_all": "全部",
|
"filter_all": "全部",
|
||||||
"filter_enabled": "已启用",
|
"filter_enabled": "已启用",
|
||||||
"filter_disabled": "已禁用",
|
"filter_disabled": "已禁用",
|
||||||
@@ -74,9 +65,7 @@
|
|||||||
"reject_reason": "驳回理由",
|
"reject_reason": "驳回理由",
|
||||||
"updates_title": "维护窗口",
|
"updates_title": "维护窗口",
|
||||||
"updates_subtitle": "配置全局系统维护窗口。在此窗口内,Felis 可以自动应用系统更新;在窗口外,更新将降级为仅通知,不会自动执行。",
|
"updates_subtitle": "配置全局系统维护窗口。在此窗口内,Felis 可以自动应用系统更新;在窗口外,更新将降级为仅通知,不会自动执行。",
|
||||||
"updates_current_title": "当前设置",
|
|
||||||
"updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。",
|
"updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。",
|
||||||
"updates_current_set": "Felis 可在以下时间段内自动执行更新:",
|
|
||||||
"updates_start_label": "开始时间",
|
"updates_start_label": "开始时间",
|
||||||
"updates_end_label": "结束时间",
|
"updates_end_label": "结束时间",
|
||||||
"updates_set_title": "配置维护窗口",
|
"updates_set_title": "配置维护窗口",
|
||||||
@@ -107,14 +96,10 @@
|
|||||||
"users_subtitle": "管理平台用户账号、配额和会话。",
|
"users_subtitle": "管理平台用户账号、配额和会话。",
|
||||||
"users_create_btn": "创建用户",
|
"users_create_btn": "创建用户",
|
||||||
"users_create_title": "创建新用户",
|
"users_create_title": "创建新用户",
|
||||||
"users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。",
|
"users_create_desc": "创建一个新的平台账号。账号无密码——用户通过游戏内 /link 绑定码登录,绑定后也可使用邮箱验证码 / Passkey 登录。",
|
||||||
"users_create_success_title": "用户创建成功",
|
|
||||||
"users_create_success_desc": "请复制并妥善保管该用户的初始凭据。关闭此对话框后,此初始密码将无法再次查看!",
|
|
||||||
"users_create_username_placeholder": "例如: alice",
|
"users_create_username_placeholder": "例如: alice",
|
||||||
"users_create_validation_username": "用户名为必填项。",
|
"users_create_validation_username": "用户名为必填项。",
|
||||||
"users_create_validation_username_taken": "该用户名已被使用。",
|
"users_create_validation_username_taken": "该用户名已被使用。",
|
||||||
"users_create_validation_password": "密码至少需要 8 个字符。",
|
|
||||||
"users_create_must_change": "要求首次登录修改密码",
|
|
||||||
"users_search_placeholder": "搜索用户名或邮箱...",
|
"users_search_placeholder": "搜索用户名或邮箱...",
|
||||||
"users_search_btn": "搜索",
|
"users_search_btn": "搜索",
|
||||||
"users_filter_role_all": "全部角色",
|
"users_filter_role_all": "全部角色",
|
||||||
@@ -130,7 +115,6 @@
|
|||||||
"users_col_status": "状态",
|
"users_col_status": "状态",
|
||||||
"users_col_created": "创建时间",
|
"users_col_created": "创建时间",
|
||||||
"users_view_detail": "详情",
|
"users_view_detail": "详情",
|
||||||
"users_total_count": "共 {{count}} 个用户",
|
|
||||||
"users_empty_title": "未找到用户",
|
"users_empty_title": "未找到用户",
|
||||||
"users_empty_hint": "没有匹配当前筛选条件的用户。",
|
"users_empty_hint": "没有匹配当前筛选条件的用户。",
|
||||||
"users_back_to_list": "返回用户列表",
|
"users_back_to_list": "返回用户列表",
|
||||||
@@ -138,7 +122,6 @@
|
|||||||
"users_field_username": "用户名",
|
"users_field_username": "用户名",
|
||||||
"users_field_email": "邮箱",
|
"users_field_email": "邮箱",
|
||||||
"users_field_role": "角色",
|
"users_field_role": "角色",
|
||||||
"users_field_password": "初始密码",
|
|
||||||
"users_save_btn": "保存更改",
|
"users_save_btn": "保存更改",
|
||||||
"users_save_ok": "更改保存成功。",
|
"users_save_ok": "更改保存成功。",
|
||||||
"users_linked_accounts": "已关联的 Minecraft 账号",
|
"users_linked_accounts": "已关联的 Minecraft 账号",
|
||||||
@@ -174,25 +157,21 @@
|
|||||||
"users_danger_enable": "启用用户",
|
"users_danger_enable": "启用用户",
|
||||||
"users_danger_enable_desc": "允许此用户重新登录。",
|
"users_danger_enable_desc": "允许此用户重新登录。",
|
||||||
"users_danger_enable_btn": "启用",
|
"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_title": "禁用用户",
|
||||||
"users_danger_disable_dlg_desc": "此用户将无法登录。所有活跃会话将被立即撤销。",
|
"users_danger_disable_dlg_desc": "此用户将无法登录。所有活跃会话将被立即撤销。",
|
||||||
"users_danger_enable_dlg_title": "启用用户",
|
"users_danger_enable_dlg_title": "启用用户",
|
||||||
"users_danger_enable_dlg_desc": "此用户将可以重新登录。",
|
"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_title": "删除用户",
|
||||||
"users_danger_delete_dlg_desc": "此操作不可逆。该用户拥有的所有服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
"users_danger_delete_dlg_desc": "此操作不可逆。该用户拥有的所有服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
||||||
"users_danger_delete": "删除用户",
|
"users_danger_delete": "删除用户",
|
||||||
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
||||||
"users_danger_delete_btn": "删除用户",
|
"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、镜像引用或状态..."
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
{
|
{
|
||||||
"login_title": "登录 Felis",
|
"login_title": "登录 Felis",
|
||||||
"login_subtitle": "运维控制台",
|
"login_subtitle": "玩家控制台",
|
||||||
"username": "用户名或邮箱",
|
"login_subtitle_op": "运维控制台",
|
||||||
"password": "密码",
|
|
||||||
"sign_in": "登录",
|
|
||||||
"signing_in": "登录中…",
|
"signing_in": "登录中…",
|
||||||
"tab_password": "账号密码",
|
|
||||||
"tab_bind": "游戏绑定码",
|
|
||||||
"tab_email": "邮箱验证码",
|
|
||||||
"other_login_methods": "其他登录方式",
|
|
||||||
"or_divider": "或",
|
"or_divider": "或",
|
||||||
"tab_email_btn": "使用邮箱验证码登录",
|
|
||||||
"tab_bind_btn": "使用游戏绑定码登录",
|
"tab_bind_btn": "使用游戏绑定码登录",
|
||||||
"back_to_password": "返回密码登录",
|
"tab_op_btn": "管理员登录",
|
||||||
|
"back_to_login": "返回其他登录方式",
|
||||||
"email_address": "邮箱地址",
|
"email_address": "邮箱地址",
|
||||||
"email_placeholder": "请输入绑定的邮箱",
|
"email_placeholder": "请输入绑定的邮箱",
|
||||||
"otp_code": "验证码",
|
"otp_code": "验证码",
|
||||||
@@ -23,20 +17,18 @@
|
|||||||
"otp_btn": "验证并登录",
|
"otp_btn": "验证并登录",
|
||||||
"resend_in": "秒后重试",
|
"resend_in": "秒后重试",
|
||||||
"passkey_btn": "使用 Passkey 登录",
|
"passkey_btn": "使用 Passkey 登录",
|
||||||
|
"passkey_email_hint": "使用 Passkey 请在上方输入绑定邮箱,或留空直接免密登录。",
|
||||||
"bind_code": "绑定码",
|
"bind_code": "绑定码",
|
||||||
"bind_code_placeholder": "例如:ABCD2345",
|
"bind_code_placeholder": "例如:ABCD2345",
|
||||||
"bind_hint": "在游戏内输入 /login 即可获取一次性绑定码",
|
"bind_hint": "在游戏内输入 /link 即可获取一次性绑定码",
|
||||||
"bind_btn": "验证并登录",
|
"bind_btn": "验证并登录",
|
||||||
"binding": "验证中…",
|
"binding": "验证中…",
|
||||||
"change_password_title": "设置新密码",
|
"op_hint": "仅限管理员:验证码将发送至您的邮箱,且需要一位在线管理员在游戏内批准此次登录。",
|
||||||
"change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。",
|
"op_start_btn": "发起管理员登录",
|
||||||
"change_password_subtitle_voluntary": "修改控制台登录密码。",
|
"op_approve_hint": "验证码已发送至您的邮箱。请让一位在线管理员在游戏内批准此次请求:",
|
||||||
"current_password": "当前密码",
|
"op_waiting": "等待游戏内批准…",
|
||||||
"new_password": "新密码",
|
"op_approved": "已批准——请输入邮件中的验证码。",
|
||||||
"confirm_new_password": "确认新密码",
|
"op_restart": "重新开始",
|
||||||
"password_mismatch": "两次输入的密码不一致。",
|
|
||||||
"password_min_length": "密码至少 {{min}} 个字符。",
|
|
||||||
"change_password_btn": "修改密码",
|
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"setup_title": "初始化你的账户",
|
"setup_title": "初始化你的账户",
|
||||||
"setup_welcome": "欢迎,{{name}}",
|
"setup_welcome": "欢迎,{{name}}",
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
"not_yours_title": "无权访问备份",
|
"not_yours_title": "无权访问备份",
|
||||||
"not_yours_body": "只有所有者或管理员才能查看并恢复该服务器的备份。",
|
"not_yours_body": "只有所有者或管理员才能查看并恢复该服务器的备份。",
|
||||||
"latest_title": "最新备份",
|
"latest_title": "最新备份",
|
||||||
"latest_note": "默认的恢复目标。你也可以从下方的历史备份中选择更早的备份进行恢复。",
|
|
||||||
"history_title": "历史备份",
|
|
||||||
"history_note": "历史备份在过期前均可用于恢复。到期后系统会自动清理,无需手动删除或管理。",
|
"history_note": "历史备份在过期前均可用于恢复。到期后系统会自动清理,无需手动删除或管理。",
|
||||||
"reason_inactive": "闲置自动回收",
|
"reason_inactive": "闲置自动回收",
|
||||||
"reason_manual": "手动备份",
|
"reason_manual": "手动备份",
|
||||||
@@ -29,7 +27,6 @@
|
|||||||
"expired_cannot_restore": "此备份已过期,无法恢复。",
|
"expired_cannot_restore": "此备份已过期,无法恢复。",
|
||||||
"col_created": "创建时间",
|
"col_created": "创建时间",
|
||||||
"col_size": "大小",
|
"col_size": "大小",
|
||||||
"col_reason": "类型 / 原因",
|
|
||||||
"col_expires": "过期时间",
|
"col_expires": "过期时间",
|
||||||
"col_owner": "原所有者",
|
"col_owner": "原所有者",
|
||||||
"col_actions": "操作"
|
"col_actions": "操作"
|
||||||
|
|||||||
@@ -15,10 +15,7 @@
|
|||||||
"loading_config": "正在加载配置…",
|
"loading_config": "正在加载配置…",
|
||||||
"brand_name": "Felis",
|
"brand_name": "Felis",
|
||||||
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
|
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
|
||||||
"page_title": "Felis · 控制面板",
|
"page_title": "Felis · 控制台",
|
||||||
"lang_en": "EN",
|
|
||||||
"lang_zh": "中文",
|
|
||||||
"lang_toggle_hint": "切换至 {{lang}}",
|
|
||||||
"toggle_theme": "切换主题",
|
"toggle_theme": "切换主题",
|
||||||
"pagination_prev": "上一页",
|
"pagination_prev": "上一页",
|
||||||
"pagination_next": "下一页",
|
"pagination_next": "下一页",
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
{
|
{
|
||||||
"title": "仪表盘",
|
"title": "仪表盘",
|
||||||
"subtitle": "服务器运行状态一览。",
|
"subtitle": "服务器运行状态一览。",
|
||||||
"no_servers_title": "暂无服务器",
|
|
||||||
"no_servers_hint": "创建一个新服务器或认领一台现有服务器即可开始。",
|
"no_servers_hint": "创建一个新服务器或认领一台现有服务器即可开始。",
|
||||||
"go_to_my_servers": "前往我的服务器",
|
|
||||||
"stat_servers": "服务器",
|
"stat_servers": "服务器",
|
||||||
"stat_running": "运行中",
|
"stat_running": "运行中",
|
||||||
"stat_players_online": "在线玩家",
|
"stat_players_online": "在线玩家",
|
||||||
"fleet": "服务器概览",
|
"fleet": "服务器概览",
|
||||||
"manage": "管理",
|
"manage": "管理",
|
||||||
"loading_scene": "正在加载 3D 场景…",
|
"loading_scene": "正在加载 3D 场景…",
|
||||||
"preparing_scene": "正在准备场景…",
|
|
||||||
"fleet_load": "负载与健康度",
|
"fleet_load": "负载与健康度",
|
||||||
"active_load": "玩家在线负载",
|
"active_load": "玩家在线负载",
|
||||||
"status_distribution": "节点状态分布",
|
"status_distribution": "节点状态分布",
|
||||||
"account_status": "账号绑定状态",
|
|
||||||
"account_linked_title": "已关联游戏身份",
|
"account_linked_title": "已关联游戏身份",
|
||||||
"account_linked_desc": "您的 Web 身份已成功绑定至 Minecraft 角色。拥有完整的所有权认领及启动权限。",
|
"account_linked_desc": "您的 Web 身份已成功绑定至 Minecraft 角色。拥有完整的所有权认领及启动权限。",
|
||||||
"account_unlinked_title": "未关联游戏角色",
|
"account_unlinked_title": "未关联游戏角色",
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
{
|
{
|
||||||
"local_auth_disabled": "当前部署已禁用本地密码登录,请通过组织的安全入口访问控制台。",
|
"local_auth_disabled": "当前部署已禁用本地登录,请通过组织的安全入口访问控制台。",
|
||||||
"invalid_credentials": "用户名或密码错误。",
|
"staff_account": "该账户为管理员账户——请使用管理员登录。",
|
||||||
"weak_password": "密码长度需在 8 到 72 个字符之间。",
|
|
||||||
"password_unchanged": "新密码不可与当前密码相同。",
|
|
||||||
"not_linked": "请先关联 Minecraft 账户(账户页 → 关联)。",
|
"not_linked": "请先关联 Minecraft 账户(账户页 → 关联)。",
|
||||||
"invalid_code": "关联码无效或已过期——请在游戏中重新输入 /link 获取新码。",
|
"invalid_code": "代码无效或已过期——请重新获取后再试。",
|
||||||
"already_linked": "该 Minecraft 账户已关联至其他用户。",
|
"already_linked": "该 Minecraft 账户已关联至其他用户。",
|
||||||
"quota_exceeded": "服务器数量已达配额上限。",
|
"quota_exceeded": "服务器数量已达配额上限。",
|
||||||
"already_claimed": "该服务器已被他人抢先认领。",
|
"already_claimed": "该服务器已被他人抢先认领。",
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "系统管理",
|
"title": "系统管理",
|
||||||
"subtitle": "平台可观测性看板——观察视角,非管理入口。",
|
"subtitle": "平台可观测性看板——观察视角,非管理入口。",
|
||||||
"cluster_wide_fleet": "全平台服务器",
|
|
||||||
"open_fleet_table": "查看全平台服务器列表 →",
|
|
||||||
"footer_pre": "控制面扩缩、RBAC、Secrets 及集群生命周期属于 ",
|
|
||||||
"footer_kubectl": "kubectl / CRD",
|
|
||||||
"footer_post": " 范畴,无法从此处操作。系统管理面仅提供可观测性——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。",
|
|
||||||
"fleet_title": "全平台服务器",
|
"fleet_title": "全平台服务器",
|
||||||
"fleet_subtitle": "平台所有服务器——运行状态、所有者、在线人数,可直接启停与进入控制台。",
|
"fleet_subtitle": "平台所有服务器——运行状态、所有者、在线人数,可直接启停与进入控制台。",
|
||||||
"fleet_live": "实时刷新",
|
"fleet_live": "实时刷新",
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
"filter_status_all": "全部状态",
|
"filter_status_all": "全部状态",
|
||||||
"search_no_match": "没有匹配的服务器。",
|
"search_no_match": "没有匹配的服务器。",
|
||||||
"search_clear_btn": "清除筛选",
|
"search_clear_btn": "清除筛选",
|
||||||
"servers_count": "共 {{count}} 台",
|
|
||||||
"servers_count_filtered": "显示 {{shown}} / {{total}} 台",
|
|
||||||
"no_servers_linked": "暂无关联的服务器",
|
"no_servers_linked": "暂无关联的服务器",
|
||||||
"no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。",
|
"no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。",
|
||||||
"my_servers_footer": "服务器及游戏类型由平台统一管理。认领后即可操作节点,原始集群配置不会暴露在此。",
|
|
||||||
"phase_running": "运行中",
|
"phase_running": "运行中",
|
||||||
"phase_starting": "启动中",
|
"phase_starting": "启动中",
|
||||||
"phase_stopping": "停止中",
|
"phase_stopping": "停止中",
|
||||||
@@ -35,7 +32,6 @@
|
|||||||
"console_offline_title": "控制台未连接",
|
"console_offline_title": "控制台未连接",
|
||||||
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
|
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
|
||||||
"my_servers_breadcrumb": "我的服务器",
|
"my_servers_breadcrumb": "我的服务器",
|
||||||
"console_card_title": "控制台",
|
|
||||||
"command_placeholder": "输入命令…如 list",
|
"command_placeholder": "输入命令…如 list",
|
||||||
"log_connecting": "连接中…",
|
"log_connecting": "连接中…",
|
||||||
"log_live": "实时",
|
"log_live": "实时",
|
||||||
@@ -74,7 +70,6 @@
|
|||||||
"access_whitelist_empty": "白名单暂无玩家。",
|
"access_whitelist_empty": "白名单暂无玩家。",
|
||||||
"access_whitelist_empty_hint": "在上方添加玩家即可放行进服。",
|
"access_whitelist_empty_hint": "在上方添加玩家即可放行进服。",
|
||||||
"access_whitelist_remove": "将 {{player}} 移出白名单",
|
"access_whitelist_remove": "将 {{player}} 移出白名单",
|
||||||
"access_whitelist_remove_q": "移除?",
|
|
||||||
"access_remove": "移除",
|
"access_remove": "移除",
|
||||||
"access_whitelist_load_error": "无法加载白名单。",
|
"access_whitelist_load_error": "无法加载白名单。",
|
||||||
"access_whitelist_added": "已将 {{player}} 加入白名单。",
|
"access_whitelist_added": "已将 {{player}} 加入白名单。",
|
||||||
@@ -89,14 +84,11 @@
|
|||||||
"access_online_raw": "查看服务器原始返回",
|
"access_online_raw": "查看服务器原始返回",
|
||||||
"access_updated_at": "更新于 {{time}}",
|
"access_updated_at": "更新于 {{time}}",
|
||||||
"access_kick_btn": "踢出",
|
"access_kick_btn": "踢出",
|
||||||
"access_kick_q": "踢出?",
|
|
||||||
"access_ban_q": "封禁并禁止再进?",
|
|
||||||
"access_kicked": "已踢出 {{player}}。",
|
"access_kicked": "已踢出 {{player}}。",
|
||||||
"access_ban_title": "封禁",
|
"access_ban_title": "封禁",
|
||||||
"access_ban_desc": "封禁会将玩家踢出并禁止再次进入。展开可查看当前封禁名单并一键解封,也可输入完整玩家 ID 直接封禁。",
|
"access_ban_desc": "封禁会将玩家踢出并禁止再次进入。展开可查看当前封禁名单并一键解封,也可输入完整玩家 ID 直接封禁。",
|
||||||
"access_ban_btn": "封禁",
|
"access_ban_btn": "封禁",
|
||||||
"access_pardon_btn": "解封",
|
"access_pardon_btn": "解封",
|
||||||
"access_pardon_q": "解封并允许再进?",
|
|
||||||
"access_ban_confirm": "确认封禁 {{player}}?此玩家将被踢出并无法再进入。",
|
"access_ban_confirm": "确认封禁 {{player}}?此玩家将被踢出并无法再进入。",
|
||||||
"access_ban_confirm_yes": "确认封禁",
|
"access_ban_confirm_yes": "确认封禁",
|
||||||
"access_ban_empty": "暂无封禁玩家。",
|
"access_ban_empty": "暂无封禁玩家。",
|
||||||
@@ -129,41 +121,20 @@
|
|||||||
"create_server_policy_allowlist": "白名单——仅名单内玩家可启动",
|
"create_server_policy_allowlist": "白名单——仅名单内玩家可启动",
|
||||||
"create_server_cancel": "取消",
|
"create_server_cancel": "取消",
|
||||||
"create_server_submit": "创建",
|
"create_server_submit": "创建",
|
||||||
"create_server_creating": "创建中…",
|
|
||||||
"no_servers_managed": "你还没有管理的服务器",
|
|
||||||
"no_servers_managed_hint": "使用「新建服务器」在线分配。",
|
|
||||||
"server_admin_footer": "服务器仅通过此结构化表单创建——平台将选择映射为审核后的 Kubernetes spec。宿主机网络、宿主机路径、任意镜像、特权 Pod 等原始集群配置在此不可表达。",
|
|
||||||
"edit_server_title": "编辑服务器配置",
|
"edit_server_title": "编辑服务器配置",
|
||||||
"edit_server_desc": "配置显示名、自启策略、镜像、内存与CPU",
|
"edit_server_desc": "配置显示名、自启策略、镜像、内存与CPU",
|
||||||
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
|
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
|
||||||
"edit_server_unchanged": "保持不变",
|
|
||||||
"edit_server_immutable": "保持不变 (不可修改)",
|
|
||||||
"edit_server_submit": "保存配置",
|
"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_group_name": "用户组名称",
|
||||||
"luckperms_node": "权限节点",
|
"luckperms_node": "权限节点",
|
||||||
"luckperms_action": "操作类型",
|
"luckperms_action": "操作类型",
|
||||||
"luckperms_action_add": "添加至用户组 (add)",
|
|
||||||
"luckperms_action_remove": "从用户组移除 (remove)",
|
|
||||||
"luckperms_action_set": "设置权限节点 (set)",
|
|
||||||
"luckperms_action_unset": "取消权限节点 (unset)",
|
|
||||||
"luckperms_value": "权限值 (Value)",
|
"luckperms_value": "权限值 (Value)",
|
||||||
"luckperms_value_grant": "允许 (True)",
|
"luckperms_value_grant": "允许 (True)",
|
||||||
"luckperms_value_deny": "拒绝 (False)",
|
"luckperms_value_deny": "拒绝 (False)",
|
||||||
"luckperms_world": "世界范围 context (可选)",
|
"luckperms_world": "世界范围 context (可选)",
|
||||||
"luckperms_confirm": "执行变更",
|
|
||||||
"luckperms_executing": "正在执行命令…",
|
|
||||||
"luckperms_success": "LuckPerms 命令成功执行:",
|
|
||||||
"luckperms_error_invalid_player": "玩家用户名格式错误 (支持1-16位英文字母、数字和下划线)",
|
"luckperms_error_invalid_player": "玩家用户名格式错误 (支持1-16位英文字母、数字和下划线)",
|
||||||
"luckperms_error_invalid_node": "权限节点格式错误 (支持1-64位字母、数字、点号、横线、下划线及通配符*)",
|
"luckperms_error_invalid_node": "权限节点格式错误 (支持1-64位字母、数字、点号、横线、下划线及通配符*)",
|
||||||
"luckperms_error_invalid_group": "用户组名称格式错误 (支持1-48位字母、数字、横线和下划线)",
|
|
||||||
"luckperms_error_invalid_world": "世界范围格式错误 (支持1-48位字母、数字、横线和下划线)",
|
"luckperms_error_invalid_world": "世界范围格式错误 (支持1-48位字母、数字、横线和下划线)",
|
||||||
"luckperms_title": "LuckPerms 权限管理",
|
|
||||||
"luckperms_desc": "精细化管理服务器上玩家的权限节点与用户组(需要 LuckPerms 插件处于运行状态)。",
|
"luckperms_desc": "精细化管理服务器上玩家的权限节点与用户组(需要 LuckPerms 插件处于运行状态)。",
|
||||||
"luckperms_back_to_console": "返回控制台",
|
"luckperms_back_to_console": "返回控制台",
|
||||||
"luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询",
|
"luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询",
|
||||||
@@ -175,18 +146,20 @@
|
|||||||
"luckperms_value_column": "状态值",
|
"luckperms_value_column": "状态值",
|
||||||
"luckperms_world_column": "生效世界",
|
"luckperms_world_column": "生效世界",
|
||||||
"luckperms_actions_column": "操作",
|
"luckperms_actions_column": "操作",
|
||||||
"luckperms_query_btn": "查询玩家",
|
|
||||||
"luckperms_custom_group_placeholder": "输入自定义组名...",
|
"luckperms_custom_group_placeholder": "输入自定义组名...",
|
||||||
"luckperms_batch_players": "玩家用户名列表",
|
"luckperms_batch_players": "玩家用户名列表",
|
||||||
"luckperms_batch_players_placeholder": "输入玩家用户名,支持输入多个(用英文逗号或空格分隔)",
|
|
||||||
"luckperms_recent_actions": "最近操作历史",
|
"luckperms_recent_actions": "最近操作历史",
|
||||||
"luckperms_no_recent_actions": "暂无最近操作历史。",
|
"luckperms_no_recent_actions": "暂无最近操作历史。",
|
||||||
"luckperms_revert": "撤销",
|
"luckperms_revert": "撤销",
|
||||||
"luckperms_reverting": "正在撤销...",
|
"luckperms_reverting": "正在撤销...",
|
||||||
"luckperms_revert_success": "已成功撤销该操作!",
|
"luckperms_revert_success": "已成功撤销该操作!",
|
||||||
"luckperms_quick_presets": "常用快速预设",
|
|
||||||
"luckperms_presets": "预设",
|
"luckperms_presets": "预设",
|
||||||
"luckperms_batch_status": "批量执行进度",
|
"luckperms_no_parent_groups": "未分配任何父组",
|
||||||
"luckperms_success_count": "成功: {{count}}",
|
"luckperms_global": "全局",
|
||||||
"luckperms_failed_count": "失败: {{count}}"
|
"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 冒险包",
|
"display_name_placeholder": "例如:Pixelmon 冒险包",
|
||||||
"file_label": "构建上下文 (.tar.gz)",
|
"file_label": "构建上下文 (.tar.gz)",
|
||||||
"file_drag_hint": "拖拽 .tar.gz 文件到此处,或点击浏览文件",
|
"file_drag_hint": "拖拽 .tar.gz 文件到此处,或点击浏览文件",
|
||||||
"file_selected": "已选择文件: {{name}} ({{size}})",
|
|
||||||
"submit_btn": "提交",
|
"submit_btn": "提交",
|
||||||
"submitting_create": "正在创建提交...",
|
"submitting_create": "正在创建提交...",
|
||||||
"submitting_upload": "正在上传构建上下文...",
|
"submitting_upload": "正在上传构建上下文...",
|
||||||
"submit_success": "模组包提交成功!",
|
|
||||||
"list_card_title": "提交历史",
|
|
||||||
"filter_all": "全部状态",
|
"filter_all": "全部状态",
|
||||||
"status_pending_review": "等待审核",
|
"status_pending_review": "等待审核",
|
||||||
"status_approved": "审核通过",
|
"status_approved": "审核通过",
|
||||||
@@ -21,7 +18,6 @@
|
|||||||
"table_status": "状态",
|
"table_status": "状态",
|
||||||
"table_created_at": "提交时间",
|
"table_created_at": "提交时间",
|
||||||
"table_reviewed_by": "审核人",
|
"table_reviewed_by": "审核人",
|
||||||
"table_image_ref": "镜像引用",
|
|
||||||
"table_reject_reason": "拒绝原因",
|
"table_reject_reason": "拒绝原因",
|
||||||
"no_submissions_title": "暂无提交记录",
|
"no_submissions_title": "暂无提交记录",
|
||||||
"no_submissions_hint": "您还没有提交过任何模组包。",
|
"no_submissions_hint": "您还没有提交过任何模组包。",
|
||||||
@@ -29,5 +25,9 @@
|
|||||||
"error_file_type": "请上传有效的 .tar.gz 压缩文件。",
|
"error_file_type": "请上传有效的 .tar.gz 压缩文件。",
|
||||||
"error_file_size": "文件超过了允许的大小限制。",
|
"error_file_size": "文件超过了允许的大小限制。",
|
||||||
"error_name_required": "必须填写显示名称。",
|
"error_name_required": "必须填写显示名称。",
|
||||||
"error_file_required": "必须上传构建上下文文件。"
|
"error_file_required": "必须上传构建上下文文件。",
|
||||||
|
"field_context_ref": "构建上下文引用",
|
||||||
|
"field_image_ref": "目标镜像引用",
|
||||||
|
"clear_btn": "清除",
|
||||||
|
"file_hint": "支持 .tar.gz 格式 (最大 1GB)"
|
||||||
}
|
}
|
||||||
+101
-60
@@ -62,57 +62,12 @@ describe("api.me wire shape", () => {
|
|||||||
expect((opts as RequestInit).method).toBe("GET");
|
expect((opts as RequestInit).method).toBe("GET");
|
||||||
expect((opts as RequestInit).credentials).toBe("include");
|
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());
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
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 () => {
|
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
|
||||||
const fetchSpy = fakeFetch({ ok: true });
|
const fetchSpy = fakeFetch({ ok: true });
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
@@ -150,31 +105,117 @@ describe("local-password auth wire shapes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("changePassword POSTs {current_password, new_password}", async () => {
|
it("maps the auth error codes to stable human copy", async () => {
|
||||||
const fetchSpy = fakeFetch({ ok: true });
|
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);
|
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
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||||
.calls[0];
|
.calls[0];
|
||||||
expect(String(url)).toBe("/auth/change-password");
|
expect(String(url)).toBe("/auth/op-login/start");
|
||||||
expect((opts as RequestInit).method).toBe("POST");
|
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({
|
expect((opts as RequestInit).headers).toEqual({
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||||
current_password: "old-pw",
|
email: "o[email protected]",
|
||||||
new_password: "brand-new-pw",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps the auth error codes to stable human copy", async () => {
|
it("opLoginStatus GETs /auth/op-login/status/{id} and surfaces approved", async () => {
|
||||||
const { humanizeError } = await import("./api");
|
const fetchSpy = fakeFetch({ approved: true });
|
||||||
expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
|
const res = await api.opLoginStatus("req-1");
|
||||||
expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
|
expect(res.approved).toBe(true);
|
||||||
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
|
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
@@ -12,7 +12,6 @@ import type {
|
|||||||
KickResult,
|
KickResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
LinkStatus,
|
LinkStatus,
|
||||||
LoginResult,
|
|
||||||
BindResult,
|
BindResult,
|
||||||
PatchUserRequest,
|
PatchUserRequest,
|
||||||
PlayersResult,
|
PlayersResult,
|
||||||
@@ -108,13 +107,10 @@ export interface SetupState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
|
// Session doors (spec §B). The product is passwordless: a session is minted only
|
||||||
// side effect — the panel never sees it — and returns only what to route on next
|
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
|
||||||
// (must_change_password forces the change card before any other surface). The
|
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
|
||||||
// username/password pair is the ONLY local credential; Passkey/PWA are Phase
|
// Zero-Trust-only deployment.
|
||||||
// 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 }),
|
|
||||||
|
|
||||||
// logout is idempotent server-side (clears the session row + cookie); calling it
|
// 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
|
// 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) =>
|
authPasskeyDiscoverableFinish: (login_id: string, assertion: any) =>
|
||||||
request<any>("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }),
|
request<any>("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }),
|
||||||
|
|
||||||
// changePassword is callable during the first-login lockdown (the route is
|
// Op-login (spec §B): the staff door. start mails an OTP to a staff address and
|
||||||
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an
|
// returns a request handle; an online admin vouches in-game with
|
||||||
// unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
|
// `/felis web op approve <request_id>`; the panel polls status until approved,
|
||||||
// every OTHER session. The caller's own session is kept, so no re-login is needed.
|
// then finish redeems {request_id, code} into a session. start answers 202 with a
|
||||||
changePassword: (current_password: string, new_password: string) =>
|
// request_id for ANY well-formed address (anti-enumeration), so the UI just waits.
|
||||||
request<{ ok: boolean }>("POST", "/auth/change-password", {
|
opLoginStart: (email: string) =>
|
||||||
current_password,
|
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
|
||||||
new_password,
|
|
||||||
|
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
|
// Setup bootstrap (spec §B). redeem consumes the one-time token from the setup URL
|
||||||
@@ -369,6 +372,46 @@ export const api = {
|
|||||||
passkeyDelete: (id: string) =>
|
passkeyDelete: (id: string) =>
|
||||||
request<void>("DELETE", `/account/passkey/credentials/${id}`),
|
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: () =>
|
listSubmissions: () =>
|
||||||
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
|
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
|
||||||
|
|
||||||
@@ -429,9 +472,6 @@ export const api = {
|
|||||||
disableUser: (id: string, disabled: boolean) =>
|
disableUser: (id: string, disabled: boolean) =>
|
||||||
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
|
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`),
|
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
|
||||||
|
|
||||||
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
||||||
@@ -496,15 +536,12 @@ export function humanizeError(e: unknown): string {
|
|||||||
|
|
||||||
const err = e as Partial<ApiError>;
|
const err = e as Partial<ApiError>;
|
||||||
switch (err.code) {
|
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":
|
case "local_auth_disabled":
|
||||||
return t("local_auth_disabled");
|
return t("local_auth_disabled");
|
||||||
case "invalid_credentials":
|
case "staff_account":
|
||||||
return t("invalid_credentials");
|
return t("staff_account");
|
||||||
case "weak_password":
|
|
||||||
return t("weak_password");
|
|
||||||
case "password_unchanged":
|
|
||||||
return t("password_unchanged");
|
|
||||||
case "not_linked":
|
case "not_linked":
|
||||||
return t("not_linked");
|
return t("not_linked");
|
||||||
case "invalid_code":
|
case "invalid_code":
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { deriveAuth, isUnauthorized } from "./auth";
|
import { deriveAuth, isUnauthorized } from "./auth";
|
||||||
import type { Identity } from "./types";
|
import type { Identity } from "./types";
|
||||||
|
|
||||||
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login,
|
// 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
|
// and — critically — who is KEPT in
|
||||||
// the app despite a /me failure. The one distinction that must never blur is a true
|
// 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),
|
// 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
|
// because mistaking the latter for the former would log out a healthy Zero-Trust
|
||||||
@@ -14,7 +14,6 @@ const admin: Identity = {
|
|||||||
email: "[email protected]",
|
email: "[email protected]",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
is_admin: true,
|
is_admin: true,
|
||||||
must_change_password: false,
|
|
||||||
is_owner: false,
|
is_owner: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ describe("deriveAuth", () => {
|
|||||||
expect(s.loading).toBe(true);
|
expect(s.loading).toBe(true);
|
||||||
expect(s.unauthenticated).toBe(false);
|
expect(s.unauthenticated).toBe(false);
|
||||||
expect(s.isAdmin).toBe(false);
|
expect(s.isAdmin).toBe(false);
|
||||||
expect(s.mustChangePassword).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a settled 401 with no identity is unauthenticated (→ /login)", () => {
|
it("a settled 401 with no identity is unauthenticated (→ /login)", () => {
|
||||||
@@ -61,21 +59,13 @@ describe("deriveAuth", () => {
|
|||||||
const s = deriveAuth(admin, null, false);
|
const s = deriveAuth(admin, null, false);
|
||||||
expect(s.unauthenticated).toBe(false);
|
expect(s.unauthenticated).toBe(false);
|
||||||
expect(s.isAdmin).toBe(true);
|
expect(s.isAdmin).toBe(true);
|
||||||
expect(s.mustChangePassword).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces must_change_password from the identity", () => {
|
it("fails closed on a malformed identity missing is_admin", () => {
|
||||||
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", () => {
|
|
||||||
// Mirrors the wire-shape trap: absent fields are undefined, not thrown access.
|
// 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 partial = { user_id: "u", email: "e", role: "user" } as unknown as Identity;
|
||||||
const s = deriveAuth(partial, null, false);
|
const s = deriveAuth(partial, null, false);
|
||||||
expect(s.isAdmin).toBe(false);
|
expect(s.isAdmin).toBe(false);
|
||||||
expect(s.mustChangePassword).toBe(false);
|
|
||||||
expect(s.unauthenticated).toBe(false);
|
expect(s.unauthenticated).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ApiError, Identity } from "./types";
|
import type { ApiError, Identity } from "./types";
|
||||||
|
|
||||||
// Pure auth-state derivation, kept out of tier.tsx so it can be pinned without a
|
// 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
|
// 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
|
// 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
|
// (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
|
/** 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. */
|
* transient or 5xx failure leaves this false so the app keeps rendering. */
|
||||||
unauthenticated: boolean;
|
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 —
|
/** 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)
|
/** 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
|
* 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:
|
* 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(
|
export function deriveAuth(
|
||||||
identity: Identity | null,
|
identity: Identity | null,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
@@ -50,6 +48,5 @@ export function deriveAuth(
|
|||||||
loading,
|
loading,
|
||||||
isAdmin: identity?.is_admin === true,
|
isAdmin: identity?.is_admin === true,
|
||||||
unauthenticated: !loading && identity === null && isUnauthorized(error),
|
unauthenticated: !loading && identity === null && isUnauthorized(error),
|
||||||
mustChangePassword: identity?.must_change_password === true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
rootDomain: 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 = {
|
const FALLBACK: RuntimeConfig = {
|
||||||
@@ -26,6 +30,8 @@ export async function loadConfig(): Promise<RuntimeConfig> {
|
|||||||
cached = {
|
cached = {
|
||||||
apiBase: raw.apiBase ?? FALLBACK.apiBase,
|
apiBase: raw.apiBase ?? FALLBACK.apiBase,
|
||||||
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
|
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
|
||||||
|
panelHostname: raw.panelHostname,
|
||||||
|
adminHostname: raw.adminHostname,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
cached = FALLBACK;
|
cached = FALLBACK;
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ import { deriveAuth, type AuthState } from "./auth";
|
|||||||
// simply don't see admin surfaces. (The backend 403s admin data calls
|
// simply don't see admin surfaces. (The backend 403s admin data calls
|
||||||
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
|
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
|
||||||
//
|
//
|
||||||
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login;
|
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
|
||||||
// `mustChangePassword` forces the change-password card; `refresh()` re-reads /me
|
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
||||||
// after a login / change / logout so the gate re-evaluates without a reload.
|
|
||||||
//
|
//
|
||||||
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
// 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,
|
isAdmin: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
unauthenticated: false,
|
unauthenticated: false,
|
||||||
mustChangePassword: false,
|
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+8
-25
@@ -25,8 +25,8 @@ export interface ServerInfo {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
phase: Phase;
|
phase: Phase;
|
||||||
desiredState?: "Running" | "Stopped";
|
desiredState?: "Running" | "Stopped";
|
||||||
players?: number;
|
playersOnline?: number;
|
||||||
maxPlayers?: number;
|
playersMax?: number;
|
||||||
autostartPolicy?: AutostartPolicy;
|
autostartPolicy?: AutostartPolicy;
|
||||||
/** Whether the caller may claim this server (unowned + linked + quota). */
|
/** Whether the caller may claim this server (unowned + linked + quota). */
|
||||||
claimable?: boolean;
|
claimable?: boolean;
|
||||||
@@ -74,7 +74,7 @@ export interface AccessResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
|
/** 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
|
* `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
|
* "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
|
* 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.
|
* 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
|
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
|
||||||
* shape — `playersOnline`/`playersMax` (not players/maxPlayers), plus `ready` and
|
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
|
||||||
* the `endpoint*` runtime fields — whereas ServerInfo is the /me/servers
|
* required — whereas ServerInfo is the /me/servers projection with them optional.
|
||||||
* projection. Sharing one interface would silently read `undefined` across the
|
* Sharing one interface would blur which fields each face actually guarantees. */
|
||||||
* fetch().json() boundary for every renamed field. */
|
|
||||||
export interface FleetServer {
|
export interface FleetServer {
|
||||||
name: string;
|
name: string;
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
@@ -213,24 +212,9 @@ export interface Identity {
|
|||||||
/** Server-computed Principal.IsOwner() — true only for the platform-level
|
/** Server-computed Principal.IsOwner() — true only for the platform-level
|
||||||
* owner account (one above admin). Owners get user management; admins don't. */
|
* owner account (one above admin). Owners get user management; admins don't. */
|
||||||
is_owner: boolean;
|
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;
|
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 {
|
export interface BindResult {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
linked: boolean;
|
linked: boolean;
|
||||||
@@ -288,7 +272,6 @@ export interface UserView {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
server_count: number;
|
server_count: number;
|
||||||
must_change_password: boolean;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -304,12 +287,12 @@ export interface UserDetail extends UserView {
|
|||||||
linked_accounts: LinkedAccount[];
|
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 {
|
export interface CreateUserRequest {
|
||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
role: "admin" | "user";
|
role: "admin" | "user";
|
||||||
password: string;
|
|
||||||
must_change_password: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchUserRequest {
|
export interface PatchUserRequest {
|
||||||
|
|||||||
+258
-7
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, type FormEvent } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
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.
|
// Sign-out ends a local session (passkey / email-OTP / bind-code / op-login):
|
||||||
// For a local session that read now 401s → the tier model flips to
|
// 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
|
// `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
|
// 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
|
// 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)}
|
onClick={() => setEmailSent(false)}
|
||||||
className="h-auto p-0 font-normal"
|
className="h-auto p-0 font-normal"
|
||||||
>
|
>
|
||||||
修改邮箱
|
{t("change_email")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -387,10 +388,10 @@ export function Account() {
|
|||||||
onClick={cancelRegistration}
|
onClick={cancelRegistration}
|
||||||
disabled={registeringPasskey}
|
disabled={registeringPasskey}
|
||||||
>
|
>
|
||||||
取消
|
{t("common:cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={registeringPasskey || !passkeyNickname.trim()}>
|
<Button type="submit" disabled={registeringPasskey || !passkeyNickname.trim()}>
|
||||||
{registeringPasskey ? t("registering_passkey") : "继续"}
|
{registeringPasskey ? t("registering_passkey") : t("continue_btn")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -400,7 +401,7 @@ export function Account() {
|
|||||||
<CardContent className="text-sm space-y-4">
|
<CardContent className="text-sm space-y-4">
|
||||||
<p className="text-muted-foreground">{t("passkeys_desc")}</p>
|
<p className="text-muted-foreground">{t("passkeys_desc")}</p>
|
||||||
{passkeys.loading && !passkeys.data ? (
|
{passkeys.loading && !passkeys.data ? (
|
||||||
<Loading label="加载 Passkey 列表中..." />
|
<Loading label={t("loading_passkeys")} />
|
||||||
) : passkeys.error ? (
|
) : passkeys.error ? (
|
||||||
<ErrorState error={passkeys.error} onRetry={passkeys.reload} />
|
<ErrorState error={passkeys.error} onRetry={passkeys.reload} />
|
||||||
) : !passkeys.data?.credentials || passkeys.data.credentials.length === 0 ? (
|
) : !passkeys.data?.credentials || passkeys.data.credentials.length === 0 ? (
|
||||||
@@ -441,6 +442,11 @@ export function Account() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<MigrationCard
|
||||||
|
userId={identity?.user_id}
|
||||||
|
hasPasskey={(passkeys.data?.credentials?.length ?? 0) > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<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 }) {
|
function StepBadge({ n }: { n: number }) {
|
||||||
return (
|
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">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ export function Dashboard() {
|
|||||||
return {
|
return {
|
||||||
total: list.length,
|
total: list.length,
|
||||||
running: by("Running"),
|
running: by("Running"),
|
||||||
players: list.reduce((n, s) => n + (s.players ?? 0), 0),
|
players: list.reduce((n, s) => n + (s.playersOnline ?? 0), 0),
|
||||||
};
|
};
|
||||||
}, [servers]);
|
}, [servers]);
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ function FleetView({
|
|||||||
else if (s.phase === "Failed") failed++;
|
else if (s.phase === "Failed") failed++;
|
||||||
else unknown++;
|
else unknown++;
|
||||||
|
|
||||||
maxPlayers += s.maxPlayers ?? 0;
|
maxPlayers += s.playersMax ?? 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+306
-183
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, type FormEvent } from "react";
|
import { useState, useEffect, type FormEvent } from "react";
|
||||||
import { Navigate, useNavigate } from "react-router-dom";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { AuthLayout } from "@/components/AuthLayout";
|
import { AuthLayout } from "@/components/AuthLayout";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -9,29 +9,36 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
import { loadConfig } from "@/lib/config";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
|
|
||||||
// Login is the local-password sign-in (spec §B1). It is the ONLY local credential
|
// Login is the passwordless sign-in (spec §B). Passkey and email-OTP are the
|
||||||
// surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success
|
// primary doors; a first-time player arrives with an in-game Bind Code (/link);
|
||||||
// the API sets an HttpOnly session cookie (invisible here); we then refresh the tier
|
// staff use the vouched op-login door (email code + in-game approval). No password
|
||||||
// context so the gate re-evaluates, and route to the forced change-password card
|
// exists anywhere in the product. On success the API sets an HttpOnly session
|
||||||
// when the account still owes its first-login change, else to the dashboard.
|
// 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)
|
// 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() {
|
export function Login() {
|
||||||
const { loading, identity, mustChangePassword, refresh } = useTier();
|
const { loading, identity, refresh } = useTier();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation("auth");
|
const { t } = useTranslation("auth");
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"password" | "bind" | "email">("password");
|
const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main");
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [bindCode, setBindCode] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [otpCode, setOtpCode] = useState("");
|
const [otpCode, setOtpCode] = useState("");
|
||||||
const [otpSent, setOtpSent] = useState(false);
|
const [otpSent, setOtpSent] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(0);
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -44,6 +51,32 @@ export function Login() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [countdown]);
|
}, [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
|
// 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.
|
// would briefly see a login form before being redirected away.
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -56,26 +89,8 @@ export function Login() {
|
|||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (identity && mustChangePassword) return <Navigate to="/change-password" replace />;
|
|
||||||
if (identity) return <Navigate to="/" 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) {
|
async function handleBindSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const code = bindCode.trim();
|
const code = bindCode.trim();
|
||||||
@@ -127,7 +142,7 @@ export function Login() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const identifier = username.trim();
|
const identifier = email.trim();
|
||||||
try {
|
try {
|
||||||
let assertion: any;
|
let assertion: any;
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
@@ -165,9 +180,9 @@ export function Login() {
|
|||||||
|
|
||||||
await api.authPasskeyDiscoverableFinish(options.login_id, assertion);
|
await api.authPasskeyDiscoverableFinish(options.login_id, assertion);
|
||||||
} else {
|
} else {
|
||||||
// Username-first (Email-first) passkey login
|
// Email-first passkey login
|
||||||
if (!identifier.includes("@")) {
|
if (!identifier.includes("@")) {
|
||||||
throw new Error("使用 Passkey 登录请在上方输入框中输入您绑定的邮箱,或留空直接进行免密登录。");
|
throw new Error(t("passkey_email_hint"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await api.authPasskeyLoginBegin(identifier);
|
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 (
|
return (
|
||||||
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
|
<AuthLayout
|
||||||
|
title={t("login_title")}
|
||||||
|
subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{activeTab === "password" && (
|
{activeTab === "main" && (
|
||||||
<div className="space-y-4">
|
<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">
|
<form onSubmit={handleEmailSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">{t("email_address")}</Label>
|
<Label htmlFor="email">{t("email_address")}</Label>
|
||||||
@@ -373,10 +282,11 @@ export function Login() {
|
|||||||
placeholder={t("email_placeholder")}
|
placeholder={t("email_placeholder")}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email webauthn"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
disabled={submitting || otpSent}
|
disabled={submitting || otpSent}
|
||||||
aria-invalid={error ? true : undefined}
|
aria-invalid={error ? true : undefined}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
@@ -447,18 +357,231 @@ export function Login() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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">
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setError(null);
|
setOpRequestId(null);
|
||||||
setActiveTab("password");
|
setOpApproved(false);
|
||||||
|
setOpCode("");
|
||||||
|
switchTab("op");
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
|
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
|
||||||
>
|
>
|
||||||
<span>←</span>
|
<span>←</span>
|
||||||
<span>{t("back_to_password")}</span>
|
<span>{t("op_restart")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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="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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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>
|
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
|
||||||
</div>
|
</div>
|
||||||
{sub.image_ref && (
|
{sub.image_ref && (
|
||||||
<div>
|
<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>
|
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -510,7 +510,7 @@ export function MySubmissionsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="mr-1 h-3 w-3" />
|
<X className="mr-1 h-3 w-3" />
|
||||||
清除
|
{t("clear_btn")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -518,7 +518,7 @@ export function MySubmissionsPage() {
|
|||||||
<>
|
<>
|
||||||
<Upload className="h-8 w-8 text-muted-foreground/80 mb-2" />
|
<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-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>
|
</div>
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export function ServerLuckPerms() {
|
|||||||
</Badge>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@ export function ServerLuckPerms() {
|
|||||||
{p.world}
|
{p.world}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground italic">global</span>
|
<span className="text-muted-foreground italic">{t("luckperms_global")}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-center">
|
<td className="px-4 py-2.5 text-center">
|
||||||
@@ -569,7 +569,7 @@ export function ServerLuckPerms() {
|
|||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -681,7 +681,7 @@ export function ServerLuckPerms() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={clearHistory}
|
onClick={clearHistory}
|
||||||
className="text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
|
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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -746,7 +746,7 @@ export function ServerLuckPerms() {
|
|||||||
<details className="group/details">
|
<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">
|
<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>
|
<span className="transition-transform group-open/details:rotate-90">▶</span>
|
||||||
RCON Console Output
|
{t("luckperms_rcon_output")}
|
||||||
</summary>
|
</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">
|
<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}
|
{h.output}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function ImageAdmin() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("add_image_title")}</DialogTitle>
|
<DialogTitle>{t("add_image_title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。
|
{t("add_image_desc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleAdd} className="space-y-4">
|
<form onSubmit={handleAdd} className="space-y-4">
|
||||||
@@ -184,7 +184,7 @@ export function ImageAdmin() {
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{/* Filters Bar */}
|
{/* Filters Bar */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -242,8 +242,8 @@ export function ImageAdmin() {
|
|||||||
) : filteredImages.length === 0 ? (
|
) : filteredImages.length === 0 ? (
|
||||||
<div className="p-4 border-b-0">
|
<div className="p-4 border-b-0">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_images_title")}
|
title={search.trim() || statusFilter !== "all" ? t("search_no_results") : t("no_images_title")}
|
||||||
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_images_hint")}
|
hint={search.trim() || statusFilter !== "all" ? t("search_no_results_hint") : t("no_images_hint")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -40,25 +40,26 @@ const STATUS_BADGE_STYLE: Record<BuildStatus, string> = {
|
|||||||
cancelled: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20",
|
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();
|
const start = new Date(createdAt).getTime();
|
||||||
if (!Number.isFinite(start)) return "";
|
if (!Number.isFinite(start)) return "";
|
||||||
const end = finishedAt ? new Date(finishedAt).getTime() : Date.now();
|
const end = finishedAt ? new Date(finishedAt).getTime() : Date.now();
|
||||||
if (!Number.isFinite(end) || end < start) return "";
|
if (!Number.isFinite(end) || end < start) return "";
|
||||||
const diffSec = Math.round((end - start) / 1000);
|
const diffSec = Math.round((end - start) / 1000);
|
||||||
if (diffSec < 60) {
|
if (diffSec < 60) {
|
||||||
return isZh ? `${diffSec}秒` : `${diffSec}s`;
|
return t("build_duration_seconds", { s: diffSec });
|
||||||
}
|
}
|
||||||
const m = Math.floor(diffSec / 60);
|
return t("build_duration_minutes", { m: Math.floor(diffSec / 60), s: diffSec % 60 });
|
||||||
const s = diffSec % 60;
|
|
||||||
return isZh ? `${m}分${s}秒` : `${m}m ${s}s`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageBuildPage() {
|
export function ImageBuildPage() {
|
||||||
const { t, i18n } = useTranslation("admin");
|
const { t, i18n } = useTranslation("admin");
|
||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const isZh = locale.startsWith("zh");
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const { identity } = useTier();
|
const { identity } = useTier();
|
||||||
|
|
||||||
@@ -289,7 +290,7 @@ export function ImageBuildPage() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("trigger_build_title")}</DialogTitle>
|
<DialogTitle>{t("trigger_build_title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。
|
{t("trigger_build_desc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleTrigger} className="space-y-4">
|
<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 === "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 === "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>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
@@ -346,7 +347,7 @@ export function ImageBuildPage() {
|
|||||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5 animate-bounce" />
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5 animate-bounce" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-bold text-amber-400">
|
<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>
|
||||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||||
{t("build_import_submission_warning_desc")}
|
{t("build_import_submission_warning_desc")}
|
||||||
@@ -434,7 +435,7 @@ export function ImageBuildPage() {
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{/* Filters Bar */}
|
{/* Filters Bar */}
|
||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b">
|
||||||
<SearchInput value={search} onChange={setSearch} placeholder="搜索构建 ID、镜像引用或状态..." />
|
<SearchInput value={search} onChange={setSearch} placeholder={t("builds_search_placeholder")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List Content */}
|
{/* List Content */}
|
||||||
@@ -443,8 +444,8 @@ export function ImageBuildPage() {
|
|||||||
) : filteredBuilds.length === 0 ? (
|
) : filteredBuilds.length === 0 ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={search.trim() ? "无匹配构建任务" : t("no_builds_title")}
|
title={search.trim() ? t("search_no_results") : t("no_builds_title")}
|
||||||
hint={search.trim() ? "尝试更换搜索词" : t("no_builds_hint")}
|
hint={search.trim() ? t("search_no_results_hint") : t("no_builds_hint")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -479,7 +480,7 @@ export function ImageBuildPage() {
|
|||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span
|
<span
|
||||||
className="font-mono font-medium text-foreground select-all block max-w-xl truncate"
|
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}
|
{b.image_ref}
|
||||||
</span>
|
</span>
|
||||||
@@ -519,7 +520,7 @@ export function ImageBuildPage() {
|
|||||||
|
|
||||||
{/* Column 6: Duration */}
|
{/* Column 6: Duration */}
|
||||||
<td className="px-4 py-3 align-middle text-center font-mono text-muted-foreground whitespace-nowrap">
|
<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>
|
</td>
|
||||||
|
|
||||||
{/* Column 7: Action */}
|
{/* Column 7: Action */}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function SubmissionsPage() {
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{/* Filters Bar */}
|
{/* Filters Bar */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -230,8 +230,8 @@ export function SubmissionsPage() {
|
|||||||
) : filteredSubmissions.length === 0 ? (
|
) : filteredSubmissions.length === 0 ? (
|
||||||
<div className="p-4 border-b-0">
|
<div className="p-4 border-b-0">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_submissions_title")}
|
title={search.trim() || statusFilter !== "all" ? t("search_no_results") : t("no_submissions_title")}
|
||||||
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_submissions_hint")}
|
hint={search.trim() || statusFilter !== "all" ? t("search_no_results_hint") : t("no_submissions_hint")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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>
|
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
|
||||||
</div>
|
</div>
|
||||||
{sub.image_ref && (
|
{sub.image_ref && (
|
||||||
<div>
|
<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>
|
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -350,7 +350,7 @@ export function SubmissionsPage() {
|
|||||||
|
|
||||||
{sub.build_id && (
|
{sub.build_id && (
|
||||||
<div>
|
<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>
|
<code className="font-mono bg-background border rounded px-1.5 py-0.5">{sub.build_id}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
UserRound,
|
UserRound,
|
||||||
Mail,
|
Mail,
|
||||||
Calendar,
|
Calendar,
|
||||||
Key,
|
|
||||||
Clock,
|
Clock,
|
||||||
Trash2,
|
Trash2,
|
||||||
Power,
|
Power,
|
||||||
@@ -151,7 +150,7 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
|
|||||||
onSaved();
|
onSaved();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e && e.code === "already_exists") {
|
if (e && e.code === "already_exists") {
|
||||||
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
|
setErr(t("users_create_validation_username_taken"));
|
||||||
} else {
|
} else {
|
||||||
setErr(humanizeError(e));
|
setErr(humanizeError(e));
|
||||||
}
|
}
|
||||||
@@ -640,7 +639,7 @@ function DangerZone({
|
|||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
const [dlg, setDlg] = useState<"disable" | "resetPw" | "delete" | null>(null);
|
const [dlg, setDlg] = useState<"disable" | "delete" | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-destructive/30">
|
<Card className="border-destructive/30">
|
||||||
@@ -658,18 +657,6 @@ function DangerZone({
|
|||||||
onAction={() => setDlg("disable")}
|
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 */}
|
{/* Delete user */}
|
||||||
<DangerRow
|
<DangerRow
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
@@ -722,7 +709,7 @@ function DangerDialogs({
|
|||||||
onChanged,
|
onChanged,
|
||||||
navigate,
|
navigate,
|
||||||
}: {
|
}: {
|
||||||
dlg: "disable" | "resetPw" | "delete" | null;
|
dlg: "disable" | "delete" | null;
|
||||||
setDlg: (v: null) => void;
|
setDlg: (v: null) => void;
|
||||||
user: UserDetail;
|
user: UserDetail;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
@@ -731,12 +718,10 @@ function DangerDialogs({
|
|||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [ok, setOk] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
setDlg(null);
|
setDlg(null);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setOk(null);
|
|
||||||
setLoading(false);
|
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() {
|
async function handleDelete() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
@@ -801,31 +769,6 @@ function DangerDialogs({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Delete user dialog */}
|
||||||
<Dialog open={dlg === "delete"} onOpenChange={(v) => { if (!v) close(); }}>
|
<Dialog open={dlg === "delete"} onOpenChange={(v) => { if (!v) close(); }}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ export function ServersPage() {
|
|||||||
ready: s.phase === "Running",
|
ready: s.phase === "Running",
|
||||||
desiredState: s.desiredState,
|
desiredState: s.desiredState,
|
||||||
autostartPolicy: s.autostartPolicy,
|
autostartPolicy: s.autostartPolicy,
|
||||||
playersOnline: s.players ?? 0,
|
playersOnline: s.playersOnline ?? 0,
|
||||||
playersMax: s.maxPlayers ?? 0,
|
playersMax: s.playersMax ?? 0,
|
||||||
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
|
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
|
||||||
claimable: s.claimable,
|
claimable: s.claimable,
|
||||||
owned: s.owned,
|
owned: s.owned,
|
||||||
|
|||||||
+1
-1
@@ -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
|
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
|
(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
|
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.
|
code to their logged-in account. The web side is already built.
|
||||||
|
|
||||||
The **Velocity** module additionally carries the §11 domain-autostart routing
|
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
|
* 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
|
* 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
|
* 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
|
* 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
|
* stalls the tick loop. Failures collapse to a generic chat line with details
|
||||||
* confined to the server log.
|
* confined to the server log.
|
||||||
@@ -65,7 +65,7 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
|
|||||||
try {
|
try {
|
||||||
player = source.getPlayerOrException();
|
player = source.getPlayerOrException();
|
||||||
} catch (CommandSyntaxException e) {
|
} 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;
|
return 0;
|
||||||
}
|
}
|
||||||
requestAndReply(source.getServer(), player);
|
requestAndReply(source.getServer(), player);
|
||||||
@@ -75,18 +75,22 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
|
|||||||
|
|
||||||
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||||
UUID uuid = player.getUUID();
|
UUID uuid = player.getUUID();
|
||||||
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
|
||||||
io.submit(() -> {
|
io.submit(() -> {
|
||||||
try {
|
try {
|
||||||
LinkCode code = linkClient.requestCode(uuid);
|
LinkCode code = linkClient.requestCode(uuid);
|
||||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
server.execute(() -> {
|
||||||
"Your link code: " + code.code()
|
player.sendSystemMessage(Component.literal(
|
||||||
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
"绑定码 / 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) {
|
} catch (LinkException e) {
|
||||||
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
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 {
|
try {
|
||||||
player = source.getPlayerOrException();
|
player = source.getPlayerOrException();
|
||||||
} catch (CommandSyntaxException e) {
|
} 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;
|
return 0;
|
||||||
}
|
}
|
||||||
requestAndReply(source.getServer(), player);
|
requestAndReply(source.getServer(), player);
|
||||||
@@ -80,18 +80,22 @@ public final class FelisForgeMod {
|
|||||||
|
|
||||||
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||||
UUID uuid = player.getUUID();
|
UUID uuid = player.getUUID();
|
||||||
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
|
||||||
io.submit(() -> {
|
io.submit(() -> {
|
||||||
try {
|
try {
|
||||||
LinkCode code = linkClient.requestCode(uuid);
|
LinkCode code = linkClient.requestCode(uuid);
|
||||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
server.execute(() -> {
|
||||||
"Your link code: " + code.code()
|
player.sendSystemMessage(Component.literal(
|
||||||
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
"绑定码 / 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) {
|
} catch (LinkException e) {
|
||||||
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
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
|
* (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
|
* the shared {@link LinkConfigLoader}. {@code FELIS_ROOT_DOMAIN} builds the console
|
||||||
* link; {@code FELIS_LOBBY_SERVER} (default {@code lobby}) is the transfer target;
|
* 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
|
* 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
|
* 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
|
* 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;
|
private static final int DEFAULT_PORT = 8080;
|
||||||
|
|
||||||
// Poll cadence and window. 20 ticks ≈ 1s at Limbo's tick rate; polling once a
|
// 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)
|
// second is responsive without hammering felis-api. The default window (10 min)
|
||||||
// matches the Bind Code TTL — no point holding a player past code expiry.
|
// 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 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 MIN_TIMEOUT_SECONDS = 30L;
|
||||||
private static final long MAX_TIMEOUT_SECONDS = 3600L;
|
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
|
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 {
|
try {
|
||||||
player.openBook(loginBook(code));
|
player.openBook(loginBook(code, url));
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
// A client that refuses the book (rare) still gets the chat instructions
|
// A client that refuses the book (rare) still gets the chat instructions
|
||||||
// below, so a book failure is not fatal to the flow.
|
// below, so a book failure is not fatal to the flow.
|
||||||
LOG.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage());
|
LOG.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage());
|
||||||
}
|
}
|
||||||
player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code());
|
player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code());
|
||||||
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + consoleUrl
|
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + url
|
||||||
+ " §e完成登录(勿用微信/QQ内置浏览器)。");
|
+ " §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;
|
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||||
int taskId = getServer().getScheduler().runTaskTimerAsync(
|
int taskId = getServer().getScheduler().runTaskTimerAsync(
|
||||||
@@ -341,13 +346,13 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
|
|
||||||
// ---- rendering / wire ----
|
// ---- rendering / wire ----
|
||||||
|
|
||||||
private Book loginBook(LinkCode code) {
|
private Book loginBook(LinkCode code, String url) {
|
||||||
Component page = Component.text("Felis 登录 / Login\n\n")
|
Component page = Component.text("Felis 登录 / Login\n\n")
|
||||||
.append(Component.text("绑定码 / Code:\n"))
|
.append(Component.text("绑定码 / Code:\n"))
|
||||||
.append(Component.text(code.code() + "\n\n").color(NamedTextColor.GOLD))
|
.append(Component.text(code.code() + "\n\n").color(NamedTextColor.GOLD))
|
||||||
.append(Component.text("▶ 点此打开登录页\n▶ Open login page\n")
|
.append(Component.text("▶ 点此打开登录页\n▶ Open login page\n")
|
||||||
.color(NamedTextColor.AQUA)
|
.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).")
|
.append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).")
|
||||||
.color(NamedTextColor.GRAY));
|
.color(NamedTextColor.GRAY));
|
||||||
return Book.book(
|
return Book.book(
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public final class FelisNeoForgeMod {
|
|||||||
try {
|
try {
|
||||||
player = source.getPlayerOrException();
|
player = source.getPlayerOrException();
|
||||||
} catch (CommandSyntaxException e) {
|
} 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;
|
return 0;
|
||||||
}
|
}
|
||||||
requestAndReply(source.getServer(), player);
|
requestAndReply(source.getServer(), player);
|
||||||
@@ -84,18 +84,22 @@ public final class FelisNeoForgeMod {
|
|||||||
|
|
||||||
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||||
UUID uuid = player.getUUID();
|
UUID uuid = player.getUUID();
|
||||||
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
|
||||||
io.submit(() -> {
|
io.submit(() -> {
|
||||||
try {
|
try {
|
||||||
LinkCode code = linkClient.requestCode(uuid);
|
LinkCode code = linkClient.requestCode(uuid);
|
||||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
server.execute(() -> {
|
||||||
"Your link code: " + code.code()
|
player.sendSystemMessage(Component.literal(
|
||||||
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
"绑定码 / 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) {
|
} catch (LinkException e) {
|
||||||
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
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 &
|
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &
|
||||||
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
|
* 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
|
* 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 —
|
* menu. A claim refusal comes back as an {@code Error} frame and is shown to the
|
||||||
* the only place claim/quota/policy failures surface — and readiness arrives as
|
* player here; wake-path refusals (policy gate, capacity) are chat messages the
|
||||||
* {@code TransferReady} just before the proxy Connects them.
|
* 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 {
|
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
|
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. */
|
/** 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) {
|
private void openMenu(Player player) {
|
||||||
|
boolean zh = zh(player);
|
||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.YELLOW));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int shown = Math.min(servers.size(), MAX_TILES);
|
int shown = Math.min(servers.size(), MAX_TILES);
|
||||||
List<String> view = new ArrayList<>(servers.subList(0, shown));
|
List<String> view = new ArrayList<>(servers.subList(0, shown));
|
||||||
MenuHolder holder = new MenuHolder(view);
|
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);
|
holder.setInventory(inv);
|
||||||
for (int i = 0; i < shown; i++) {
|
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);
|
player.openInventory(inv);
|
||||||
// Ask the proxy for live status of every tile; answers repaint them.
|
// 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:
|
case ControlFrame.ERROR:
|
||||||
// The proxy already sanitizes transport faults; this is the only place
|
// The proxy already sanitizes transport faults; this is the only place
|
||||||
// a claim/quota/policy refusal becomes visible to the player.
|
// 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;
|
break;
|
||||||
case ControlFrame.TRANSFER_READY:
|
case ControlFrame.TRANSFER_READY:
|
||||||
// The proxy performs the actual Connect; just make sure a stale menu is
|
// 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
|
return; // a server we are not showing
|
||||||
}
|
}
|
||||||
holder.put(frame.server(), frame);
|
holder.put(frame.server(), frame);
|
||||||
top.setItem(slot, tile(frame));
|
top.setItem(slot, tile(frame, zh(player)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void closeIfMenu(Player player) {
|
private void closeIfMenu(Player player) {
|
||||||
@@ -214,21 +215,26 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
|||||||
|
|
||||||
// ---- rendering ----
|
// ---- 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;
|
Material material;
|
||||||
String action;
|
String action;
|
||||||
NamedTextColor color;
|
NamedTextColor color;
|
||||||
if (f.claimable()) {
|
if (f.claimable()) {
|
||||||
material = Material.GOLD_BLOCK;
|
material = Material.GOLD_BLOCK;
|
||||||
action = "Claim & Start";
|
action = zh ? "认领并启动" : "Claim & Start";
|
||||||
color = NamedTextColor.GOLD;
|
color = NamedTextColor.GOLD;
|
||||||
} else if (f.ready()) {
|
} else if (f.ready()) {
|
||||||
material = Material.LIME_CONCRETE;
|
material = Material.LIME_CONCRETE;
|
||||||
action = "Join";
|
action = zh ? "加入" : "Join";
|
||||||
color = NamedTextColor.GREEN;
|
color = NamedTextColor.GREEN;
|
||||||
} else {
|
} else {
|
||||||
material = Material.RED_CONCRETE;
|
material = Material.RED_CONCRETE;
|
||||||
action = "Wake";
|
action = zh ? "唤醒" : "Wake";
|
||||||
color = NamedTextColor.RED;
|
color = NamedTextColor.RED;
|
||||||
}
|
}
|
||||||
ItemStack item = new ItemStack(material);
|
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)
|
meta.displayName(Component.text(action + " · " + f.server(), color)
|
||||||
.decoration(TextDecoration.ITALIC, false));
|
.decoration(TextDecoration.ITALIC, false));
|
||||||
List<Component> lore = new ArrayList<>();
|
List<Component> lore = new ArrayList<>();
|
||||||
lore.add(line("Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
|
lore.add(line(zh ? "状态" : "Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
|
||||||
lore.add(line("Players", f.playersOnline() + "/" + f.playersMax()));
|
lore.add(line(zh ? "在线" : "Players", f.playersOnline() + "/" + f.playersMax()));
|
||||||
meta.lore(lore);
|
meta.lore(lore);
|
||||||
item.setItemMeta(meta);
|
item.setItemMeta(meta);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ItemStack loadingTile(String server) {
|
private ItemStack loadingTile(String server, boolean zh) {
|
||||||
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||||
ItemMeta meta = item.getItemMeta();
|
ItemMeta meta = item.getItemMeta();
|
||||||
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
|
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)));
|
.decoration(TextDecoration.ITALIC, false)));
|
||||||
item.setItemMeta(meta);
|
item.setItemMeta(meta);
|
||||||
return item;
|
return item;
|
||||||
@@ -259,27 +265,35 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
|||||||
.decoration(TextDecoration.ITALIC, false);
|
.decoration(TextDecoration.ITALIC, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String errorText(ControlFrame f) {
|
private static String errorText(ControlFrame f, boolean zh) {
|
||||||
String code = f.code();
|
String code = f.code();
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case "not_linked":
|
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":
|
case "quota_exceeded":
|
||||||
return "You've reached your server quota.";
|
return zh ? "你已达到服务器配额上限。"
|
||||||
|
: "You've reached your server quota.";
|
||||||
case "already_claimed":
|
case "already_claimed":
|
||||||
return "That server was just claimed by someone else.";
|
return zh ? "该服务器已被认领。"
|
||||||
|
: "That server is already claimed.";
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return f.message() != null && !f.message().isEmpty()
|
return f.message() != null && !f.message().isEmpty()
|
||||||
? f.message()
|
? f.message()
|
||||||
: (code != null ? code : "Request failed — please try again.");
|
: (code != null ? code : (zh ? "请求失败,请重试。" : "Request failed — please try again."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- 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) {
|
private void sendUpstream(Player player, ControlFrame frame) {
|
||||||
player.sendPluginMessage(this, Control.CHANNEL, Control.encode(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
|
* 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
|
* (spec §7, §9). Where {@link LinkClient} mints account-link codes, this client
|
||||||
* drives domain-autostart routing: it lists the registrable servers, resolves a
|
* drives domain-autostart routing: it lists the registrable servers, polls a
|
||||||
* connecting virtual host to its server, polls a server's lifecycle status, pulls
|
* server's lifecycle status, pulls the wake lever, and reports real player
|
||||||
* the wake lever, and reports real player joins. It shares the {@link LinkConfig}
|
* joins. It shares the {@link LinkConfig}
|
||||||
* (same internal base URL + service token) and the same zero-dependency JDK HTTP
|
* (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.
|
* stack, so it compiles straight into each loader jar with nothing to shade.
|
||||||
*
|
*
|
||||||
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
|
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
|
||||||
* and surfaces a non-success status as a {@link LinkException} carrying the HTTP
|
* 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
|
* status, so the proxy can branch on it without parsing human text. The one that
|
||||||
* matter for routing:
|
* matters most for routing: {@code wake} → 403 means the autostartPolicy gate
|
||||||
* <ul>
|
* refused this UUID (do not enqueue the player); 429 means a wake is already
|
||||||
* <li>{@code wake} → 403 means the autostartPolicy gate refused this UUID (do
|
* cooling down ("already waking, keep waiting"); 503 means the cluster is at
|
||||||
* not enqueue the player); 429 means a wake is already cooling down
|
* capacity (tell the player to try later — nothing is coming up).
|
||||||
* ("already waking, keep waiting"), not a failure.</li>
|
|
||||||
* <li>{@code serverByHost} → 404 means the host maps to no server.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
public final class FelisApiClient {
|
public final class FelisApiClient {
|
||||||
private final LinkConfig config;
|
private final LinkConfig config;
|
||||||
@@ -57,16 +54,6 @@ public final class FelisApiClient {
|
|||||||
return out;
|
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). */
|
/** serverStatus reads one server's current lifecycle view (internal status). */
|
||||||
public ServerView serverStatus(String name) throws LinkException {
|
public ServerView serverStatus(String name) throws LinkException {
|
||||||
return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200));
|
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
|
* 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
|
* 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)
|
* 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.
|
* 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 {
|
public ServerView wake(String name, UUID mcUuid) throws LinkException {
|
||||||
Objects.requireNonNull(name, "name");
|
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
|
* 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
|
* the Bind Code on {@code console.<root_domain>} the login limbo polls this until
|
||||||
* it flips true, then admits/transfers the player. {@code GET
|
* 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
|
* 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
|
* 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
|
* 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
|
* <li>header {@code Authorization: Bearer <serviceToken>} (constant-time
|
||||||
* compared server-side; an empty token fails closed)</li>
|
* compared server-side; an empty token fails closed)</li>
|
||||||
* <li>request body {@code {"mc_uuid":"<uuid>"}}</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>
|
* <li>failure: the {@code {"error":{"code","message"}}} envelope</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
@@ -94,7 +95,11 @@ public final class LinkClient {
|
|||||||
"success body missing 'code'");
|
"success body missing 'code'");
|
||||||
}
|
}
|
||||||
Object exp = obj.get("expires_at");
|
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) {
|
private LinkException parseError(int status, String text) {
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import java.util.Objects;
|
|||||||
public final class LinkCode {
|
public final class LinkCode {
|
||||||
private final String code;
|
private final String code;
|
||||||
private final String expiresAt;
|
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.code = Objects.requireNonNull(code, "code");
|
||||||
this.expiresAt = expiresAt;
|
this.expiresAt = expiresAt;
|
||||||
|
this.panelUrl = panelUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String code() {
|
public String code() {
|
||||||
@@ -27,4 +29,12 @@ public final class LinkCode {
|
|||||||
public String expiresAt() {
|
public String expiresAt() {
|
||||||
return 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
|
* ServerView is the proxy-side mirror of the felis-api lifecycle view of one
|
||||||
* MinecraftServer (the {@code ServerInfo} the internal face emits for
|
* MinecraftServer (the {@code ServerInfo} the internal face emits for
|
||||||
* {@code GET /servers}, {@code GET /servers/by-host/{host}} and the internal
|
* {@code GET /servers} and the internal status/wake replies). It is an immutable, dependency-free value object so the
|
||||||
* 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
|
* shared link core stays zero-dependency and source-shareable across all four
|
||||||
* loaders.
|
* 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),
|
// 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,
|
// 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.
|
// 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) {
|
private static ControlFrame errorFrame(LinkException e, String server) {
|
||||||
String code = e.errorCode() != null ? e.errorCode() : "error";
|
String code = e.errorCode() != null ? e.errorCode() : "error";
|
||||||
String message = e.statusCode() == 0
|
String message = e.statusCode() == 0
|
||||||
? "felis is temporarily unavailable — please try again."
|
? "Felis 暂时不可用,请稍后再试 / Felis is temporarily unavailable — please try again."
|
||||||
: e.getMessage();
|
: e.getMessage();
|
||||||
return ControlFrame.error(code, message, server);
|
return ControlFrame.error(code, message, server);
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-3
@@ -19,7 +19,8 @@ import java.util.Properties;
|
|||||||
* URL + service token (and its first-run template), so {@code /link} keeps working
|
* 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
|
* exactly as before; these extra keys are read from the same properties file (or
|
||||||
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOGIN_SERVER} /
|
* {@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
|
* <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
|
* 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_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
|
||||||
static final String ENV_LOGIN = "FELIS_LOGIN_SERVER";
|
static final String ENV_LOGIN = "FELIS_LOGIN_SERVER";
|
||||||
static final String ENV_LOBBY = "FELIS_LOBBY_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_ROOT_DOMAIN = "root-domain";
|
||||||
private static final String KEY_LOGIN = "login-server";
|
private static final String KEY_LOGIN = "login-server";
|
||||||
private static final String KEY_LOBBY = "lobby-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_LOGIN = "login";
|
||||||
private static final String DEFAULT_LOBBY = "lobby";
|
private static final String DEFAULT_LOBBY = "lobby";
|
||||||
|
|
||||||
@@ -43,13 +48,18 @@ final class FelisVelocityConfig {
|
|||||||
private final String rootDomain; // null → routing disabled
|
private final String rootDomain; // null → routing disabled
|
||||||
private final String loginServer;
|
private final String loginServer;
|
||||||
private final String lobbyServer;
|
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,
|
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain,
|
||||||
String loginServer, String lobbyServer) {
|
String loginServer, String lobbyServer,
|
||||||
|
String panelHostname, String adminHostname) {
|
||||||
this.linkConfig = linkConfig;
|
this.linkConfig = linkConfig;
|
||||||
this.rootDomain = rootDomain;
|
this.rootDomain = rootDomain;
|
||||||
this.loginServer = loginServer;
|
this.loginServer = loginServer;
|
||||||
this.lobbyServer = lobbyServer;
|
this.lobbyServer = lobbyServer;
|
||||||
|
this.panelHostname = panelHostname;
|
||||||
|
this.adminHostname = adminHostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
static FelisVelocityConfig load(Path file) throws IOException {
|
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 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 login = trimToNull(firstNonBlank(System.getenv(ENV_LOGIN), props.getProperty(KEY_LOGIN)));
|
||||||
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
|
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;
|
login = login == null ? DEFAULT_LOGIN : login;
|
||||||
lobby = lobby == null ? DEFAULT_LOBBY : lobby;
|
lobby = lobby == null ? DEFAULT_LOBBY : lobby;
|
||||||
if (login.equalsIgnoreCase(lobby)) {
|
if (login.equalsIgnoreCase(lobby)) {
|
||||||
throw new IOException("login-server and lobby-server must be different");
|
throw new IOException("login-server and lobby-server must be different");
|
||||||
}
|
}
|
||||||
return new FelisVelocityConfig(
|
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() {
|
LinkConfig linkConfig() {
|
||||||
@@ -95,6 +109,28 @@ final class FelisVelocityConfig {
|
|||||||
return lobbyServer;
|
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) {
|
private static String firstNonBlank(String a, String b) {
|
||||||
if (a != null && !a.trim().isEmpty()) {
|
if (a != null && !a.trim().isEmpty()) {
|
||||||
return a;
|
return a;
|
||||||
|
|||||||
+175
-72
@@ -28,6 +28,7 @@ import org.slf4j.Logger;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -180,20 +181,34 @@ public final class FelisVelocityPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void requestAndReply(Player player) {
|
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(() -> {
|
async(() -> {
|
||||||
try {
|
try {
|
||||||
LinkCode code = linkClient.requestCode(player.getUniqueId());
|
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(
|
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));
|
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) {
|
} catch (LinkException e) {
|
||||||
logger.warn("link code request failed for {} (status={}, code={}): {}",
|
logger.warn("link code request failed for {} (status={}, code={}): {}",
|
||||||
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
|
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.RED));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -210,6 +225,7 @@ public final class FelisVelocityPlugin {
|
|||||||
// /felis server the felis servers this proxy knows
|
// /felis server the felis servers this proxy knows
|
||||||
// /felis go <server> wake a server and move me in when it's ready
|
// /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 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 where the web consoles live
|
||||||
// /felis web op approve <code> vouch for a pending op.console staff login (§B)
|
// /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.
|
// login limbo (or not yet on any backend): they have not passed the front door.
|
||||||
// Fails closed on an unknown position.
|
// Fails closed on an unknown position.
|
||||||
private boolean ensureOutOfLimbo(Player player) {
|
private boolean ensureOutOfLimbo(Player player) {
|
||||||
|
boolean zh = zh(player);
|
||||||
Optional<ServerConnection> current = player.getCurrentServer();
|
Optional<ServerConnection> current = player.getCurrentServer();
|
||||||
if (current.isEmpty()) {
|
if (current.isEmpty()) {
|
||||||
player.sendMessage(Component.text(
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (config.loginServer().equalsIgnoreCase(current.get().getServerInfo().getName())) {
|
if (config.loginServer().equalsIgnoreCase(current.get().getServerInfo().getName())) {
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.YELLOW));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -328,53 +347,69 @@ public final class FelisVelocityPlugin {
|
|||||||
if (!gateInfo(source)) {
|
if (!gateInfo(source)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean zh = zh(source);
|
||||||
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
|
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
|
||||||
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
|
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
|
||||||
if (!routingActive) {
|
if (!routingActive) {
|
||||||
source.sendMessage(Component.text(
|
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));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
source.sendMessage(field("root-domain", config.rootDomain()));
|
source.sendMessage(field("root-domain", config.rootDomain()));
|
||||||
source.sendMessage(field("login", config.loginServer()));
|
source.sendMessage(field("login", config.loginServer()));
|
||||||
source.sendMessage(field("lobby", config.lobbyServer()));
|
source.sendMessage(field("lobby", config.lobbyServer()));
|
||||||
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
|
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) {
|
private void sendHelp(CommandSource source) {
|
||||||
source.sendMessage(Component.text("Felis commands", NamedTextColor.AQUA));
|
boolean zh = zh(source);
|
||||||
helpLine(source, "/felis", "proxy and routing status");
|
source.sendMessage(Component.text(zh ? "Felis 命令" : "Felis commands", NamedTextColor.AQUA));
|
||||||
helpLine(source, "/felis server", "the felis servers this proxy knows");
|
helpLine(source, "/felis",
|
||||||
helpLine(source, "/felis go <server>", "start a server and move you in when it's ready");
|
zh ? "代理与路由状态" : "proxy and routing status");
|
||||||
helpLine(source, "/felis claim", "take ownership of the server you're on");
|
helpLine(source, "/felis server",
|
||||||
helpLine(source, "/felis migrate", "move your servers to another account");
|
zh ? "此代理已知的 felis 服务器" : "the felis servers this proxy knows");
|
||||||
helpLine(source, "/felis web", "where the web consoles live");
|
helpLine(source, "/felis go <server>",
|
||||||
helpLine(source, "/felis web op approve <code>", "approve a pending operator sign-in");
|
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) {
|
private void sendServerList(CommandSource source) {
|
||||||
if (!gateInfo(source)) {
|
if (!gateInfo(source)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean zh = zh(source);
|
||||||
if (!routingActive) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
List<ServerView> servers = registry.all().stream()
|
List<ServerView> servers = registry.all().stream()
|
||||||
.filter(v -> !isSystemServer(v.name()))
|
.filter(v -> !isSystemServer(v.name()))
|
||||||
.toList();
|
.toList();
|
||||||
if (servers.isEmpty()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
|
source.sendMessage(Component.text(zh ? "Felis 服务器:" : "Felis servers:", NamedTextColor.AQUA));
|
||||||
for (ServerView v : servers) {
|
for (ServerView v : servers) {
|
||||||
String phase = v.phase() == null ? "?" : v.phase();
|
String phase = v.phase() == null ? "?" : v.phase();
|
||||||
|
String ready = v.ready() ? (zh ? ",就绪" : ", ready") : "";
|
||||||
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
|
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)));
|
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,8 +419,9 @@ public final class FelisVelocityPlugin {
|
|||||||
if (player == null || !ensureOutOfLimbo(player)) {
|
if (player == null || !ensureOutOfLimbo(player)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean zh = zh(player);
|
||||||
if (!routingActive) {
|
if (!routingActive) {
|
||||||
player.sendMessage(routingDisabled());
|
player.sendMessage(routingDisabled(zh));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String target = serverArg.trim();
|
String target = serverArg.trim();
|
||||||
@@ -398,12 +434,15 @@ public final class FelisVelocityPlugin {
|
|||||||
}
|
}
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
player.sendMessage(Component.text(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
Optional<ServerConnection> current = player.getCurrentServer();
|
Optional<ServerConnection> current = player.getCurrentServer();
|
||||||
if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Wake + park + transfer through the shared waiting queue; it reports its own
|
// 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)) {
|
if (player == null || !ensureOutOfLimbo(player)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean zh = zh(player);
|
||||||
if (!routingActive) {
|
if (!routingActive) {
|
||||||
player.sendMessage(routingDisabled());
|
player.sendMessage(routingDisabled(zh));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Optional<ServerConnection> current = player.getCurrentServer();
|
Optional<ServerConnection> current = player.getCurrentServer();
|
||||||
if (current.isEmpty()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
String name = current.get().getServerInfo().getName();
|
String name = current.get().getServerInfo().getName();
|
||||||
if (!registry.isManaged(name)) {
|
if (!registry.isManaged(name)) {
|
||||||
player.sendMessage(Component.text(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
UUID uuid = player.getUniqueId();
|
UUID uuid = player.getUniqueId();
|
||||||
player.sendMessage(Component.text("Claiming « " + name + " »…", NamedTextColor.GRAY));
|
player.sendMessage(Component.text(
|
||||||
|
zh ? "正在认领「" + name + "」……" : "Claiming « " + name + " »…", NamedTextColor.GRAY));
|
||||||
async(() -> {
|
async(() -> {
|
||||||
try {
|
try {
|
||||||
apiClient.claim(name, uuid);
|
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) {
|
} 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)) {
|
if (player == null || !ensureOutOfLimbo(player)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean zh = zh(player);
|
||||||
if (!routingActive) {
|
if (!routingActive) {
|
||||||
player.sendMessage(routingDisabled());
|
player.sendMessage(routingDisabled(zh));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UUID uuid = player.getUniqueId();
|
UUID uuid = player.getUniqueId();
|
||||||
String who = player.getUsername();
|
String who = player.getUsername();
|
||||||
player.sendMessage(Component.text("Starting account migration…", NamedTextColor.GRAY));
|
player.sendMessage(Component.text(
|
||||||
|
zh ? "正在发起账户迁移……" : "Starting account migration…", NamedTextColor.GRAY));
|
||||||
async(() -> {
|
async(() -> {
|
||||||
try {
|
try {
|
||||||
apiClient.migrateStart(uuid);
|
apiClient.migrateStart(uuid);
|
||||||
String root = config.rootDomain();
|
String panelHost = config.panelHostname();
|
||||||
player.sendMessage(Component.text(
|
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(
|
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));
|
NamedTextColor.WHITE));
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.GRAY));
|
||||||
logger.info("Felis: account migration started in-game by {} ({})", who, uuid);
|
logger.info("Felis: account migration started in-game by {} ({})", who, uuid);
|
||||||
} catch (LinkException e) {
|
} 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)) {
|
if (!gateInfo(source)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String root = config.rootDomain();
|
boolean zh = zh(source);
|
||||||
if (root == null) {
|
String panelHost = config.panelHostname();
|
||||||
|
String adminHost = config.adminHostname();
|
||||||
|
if (panelHost == null && adminHost == null) {
|
||||||
source.sendMessage(Component.text(
|
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;
|
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(
|
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));
|
NamedTextColor.GRAY));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,12 +564,20 @@ public final class FelisVelocityPlugin {
|
|||||||
if (!gateInfo(source)) {
|
if (!gateInfo(source)) {
|
||||||
return;
|
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(
|
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(" /felis web op approve <code>", NamedTextColor.WHITE));
|
||||||
source.sendMessage(Component.text(
|
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) {
|
private void doOpApprove(CommandSource source, String codeArg) {
|
||||||
@@ -516,36 +585,56 @@ public final class FelisVelocityPlugin {
|
|||||||
if (player == null || !ensureOutOfLimbo(player)) {
|
if (player == null || !ensureOutOfLimbo(player)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean zh = zh(player);
|
||||||
if (!routingActive) {
|
if (!routingActive) {
|
||||||
player.sendMessage(routingDisabled());
|
player.sendMessage(routingDisabled(zh));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String code = codeArg.trim();
|
String code = codeArg.trim();
|
||||||
if (!OP_LOGIN_CODE.matcher(code).matches()) {
|
if (!OP_LOGIN_CODE.matcher(code).matches()) {
|
||||||
player.sendMessage(Component.text(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
UUID approver = player.getUniqueId();
|
UUID approver = player.getUniqueId();
|
||||||
String who = player.getUsername();
|
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(() -> {
|
async(() -> {
|
||||||
try {
|
try {
|
||||||
apiClient.opLoginApprove(code, approver);
|
apiClient.opLoginApprove(code, approver);
|
||||||
player.sendMessage(Component.text(
|
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);
|
logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver);
|
||||||
} catch (LinkException e) {
|
} catch (LinkException e) {
|
||||||
player.sendMessage(Component.text(opApproveError(e), NamedTextColor.RED));
|
player.sendMessage(Component.text(opApproveError(e, zh), NamedTextColor.RED));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- 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(
|
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);
|
NamedTextColor.YELLOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,52 +644,66 @@ public final class FelisVelocityPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// claimError maps the felis-api claim refusals (spec §9.3) to player-safe text.
|
// 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()) {
|
switch (e.statusCode()) {
|
||||||
case 412:
|
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:
|
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:
|
case 409:
|
||||||
return "« " + server + " » is already owned.";
|
return zh ? "「" + server + "」已有主人。"
|
||||||
|
: "« " + server + " » is already owned.";
|
||||||
case 404:
|
case 404:
|
||||||
return "« " + server + " » is no longer available.";
|
return zh ? "「" + server + "」已不可用。"
|
||||||
|
: "« " + server + " » is no longer available.";
|
||||||
case 0:
|
case 0:
|
||||||
return "Felis is temporarily unavailable — please try again.";
|
return zh ? "Felis 暂时不可用——请稍后再试。"
|
||||||
|
: "Felis is temporarily unavailable — please try again.";
|
||||||
default:
|
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
|
// 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
|
// 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).
|
// 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()) {
|
switch (e.statusCode()) {
|
||||||
case 404:
|
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:
|
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:
|
case 0:
|
||||||
return "Felis is temporarily unavailable — please try again.";
|
return zh ? "Felis 暂时不可用——请稍后再试。"
|
||||||
|
: "Felis is temporarily unavailable — please try again.";
|
||||||
default:
|
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
|
// 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
|
// 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.
|
// 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()) {
|
switch (e.statusCode()) {
|
||||||
case 403:
|
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:
|
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:
|
case 0:
|
||||||
return "Felis is temporarily unavailable — please try again.";
|
return zh ? "Felis 暂时不可用——请稍后再试。"
|
||||||
|
: "Felis is temporarily unavailable — please try again.";
|
||||||
default:
|
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());
|
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) {
|
private static String statusLine(ServerView v) {
|
||||||
if (v.ready()) {
|
if (v.ready()) {
|
||||||
return "online";
|
return "在线 / online";
|
||||||
}
|
}
|
||||||
if ("Running".equals(v.desiredState())) {
|
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) {
|
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
|
* 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
|
* 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
|
* 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
|
* already in flight (we keep waiting), and a 503 means the cluster is at capacity
|
||||||
* the reaper sees activity and the player is auto-added to the allowlist.
|
* (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 {
|
public final class WaitingRouter {
|
||||||
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
|
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
|
||||||
@@ -132,7 +134,9 @@ public final class WaitingRouter {
|
|||||||
if (login.isEmpty()) {
|
if (login.isEmpty()) {
|
||||||
event.setInitialServer(null);
|
event.setInitialServer(null);
|
||||||
player.disconnect(Component.text(
|
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));
|
NamedTextColor.RED));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,7 +165,10 @@ public final class WaitingRouter {
|
|||||||
event.setResult(ServerPreConnectEvent.ServerResult.denied());
|
event.setResult(ServerPreConnectEvent.ServerResult.denied());
|
||||||
if (!serverNamed(event.getOriginalServer(), lobbyServer)) {
|
if (!serverNamed(event.getOriginalServer(), lobbyServer)) {
|
||||||
player.sendMessage(Component.text(
|
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 {}",
|
log.warn("Felis: denied login-gate transfer for {} to {}",
|
||||||
player.getUniqueId(), event.getOriginalServer().getServerInfo().getName());
|
player.getUniqueId(), event.getOriginalServer().getServerInfo().getName());
|
||||||
return null;
|
return null;
|
||||||
@@ -173,17 +180,20 @@ public final class WaitingRouter {
|
|||||||
private void authorizeLoginRelease(ServerPreConnectEvent event) {
|
private void authorizeLoginRelease(ServerPreConnectEvent event) {
|
||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
UUID id = player.getUniqueId();
|
UUID id = player.getUniqueId();
|
||||||
|
boolean zh = FelisVelocityPlugin.zh(player);
|
||||||
try {
|
try {
|
||||||
if (!api.linkStatus(id)) {
|
if (!api.linkStatus(id)) {
|
||||||
player.sendMessage(Component.text(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} catch (LinkException e) {
|
} catch (LinkException e) {
|
||||||
log.warn("Felis: could not verify login release for {} (status={}): {}",
|
log.warn("Felis: could not verify login release for {} (status={}): {}",
|
||||||
id, e.statusCode(), e.getMessage());
|
id, e.statusCode(), e.getMessage());
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.RED));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -202,7 +212,8 @@ public final class WaitingRouter {
|
|||||||
pendingTargets.remove(id, targetName);
|
pendingTargets.remove(id, targetName);
|
||||||
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
|
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
|
||||||
player.sendMessage(Component.text(
|
player.sendMessage(Component.text(
|
||||||
"« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
|
zh ? "「" + targetName + "」已不可用。"
|
||||||
|
: "« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Optional<RegisteredServer> backend = registry.registered(targetName);
|
Optional<RegisteredServer> backend = registry.registered(targetName);
|
||||||
@@ -260,10 +271,12 @@ public final class WaitingRouter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Player player = po.get();
|
Player player = po.get();
|
||||||
|
boolean zh = FelisVelocityPlugin.zh(player);
|
||||||
if (now > w.deadlineMillis) {
|
if (now > w.deadlineMillis) {
|
||||||
waiting.remove(id);
|
waiting.remove(id);
|
||||||
player.sendMessage(Component.text(
|
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));
|
+ "You can try again from the lobby later.", NamedTextColor.YELLOW));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -287,7 +300,8 @@ public final class WaitingRouter {
|
|||||||
if (!api.linkStatus(id)) {
|
if (!api.linkStatus(id)) {
|
||||||
waiting.remove(id);
|
waiting.remove(id);
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.RED));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -298,7 +312,8 @@ public final class WaitingRouter {
|
|||||||
}
|
}
|
||||||
waiting.remove(id);
|
waiting.remove(id);
|
||||||
player.sendMessage(Component.text(
|
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
|
// 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.
|
// it; the proxy still performs the actual Connect just below.
|
||||||
MenuTransferListener listener = menuListener;
|
MenuTransferListener listener = menuListener;
|
||||||
@@ -311,18 +326,21 @@ public final class WaitingRouter {
|
|||||||
|
|
||||||
private void authorizeAndWait(Player player, String serverName, boolean fromMenu) {
|
private void authorizeAndWait(Player player, String serverName, boolean fromMenu) {
|
||||||
UUID id = player.getUniqueId();
|
UUID id = player.getUniqueId();
|
||||||
|
boolean zh = FelisVelocityPlugin.zh(player);
|
||||||
plugin.async(() -> {
|
plugin.async(() -> {
|
||||||
try {
|
try {
|
||||||
if (!api.linkStatus(id)) {
|
if (!api.linkStatus(id)) {
|
||||||
player.sendMessage(Component.text(
|
player.sendMessage(Component.text(
|
||||||
"Finish signing in before joining a server.", NamedTextColor.YELLOW));
|
zh ? "请先完成登录,再加入服务器。"
|
||||||
|
: "Finish signing in before joining a server.", NamedTextColor.YELLOW));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (LinkException e) {
|
} catch (LinkException e) {
|
||||||
log.warn("Felis: could not verify queue entry for {} (status={}): {}",
|
log.warn("Felis: could not verify queue entry for {} (status={}): {}",
|
||||||
id, e.statusCode(), e.getMessage());
|
id, e.statusCode(), e.getMessage());
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.RED));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -335,26 +353,44 @@ public final class WaitingRouter {
|
|||||||
// both the account gate and the server-side autostart policy.
|
// both the account gate and the server-side autostart policy.
|
||||||
private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) {
|
private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) {
|
||||||
UUID id = player.getUniqueId();
|
UUID id = player.getUniqueId();
|
||||||
|
boolean zh = FelisVelocityPlugin.zh(player);
|
||||||
try {
|
try {
|
||||||
api.wake(serverName, id);
|
api.wake(serverName, id);
|
||||||
} catch (LinkException e) {
|
} catch (LinkException e) {
|
||||||
switch (e.statusCode()) {
|
switch (e.statusCode()) {
|
||||||
case 403:
|
case 403:
|
||||||
player.sendMessage(Component.text(
|
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;
|
return;
|
||||||
case 429:
|
case 429:
|
||||||
break; // a wake is already in flight → join the existing wait
|
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:
|
default:
|
||||||
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
|
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.RED));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.sendMessage(Component.text(
|
player.sendMessage(Component.text(
|
||||||
"Starting « " + serverName + " » — you'll be moved in automatically.",
|
zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。"
|
||||||
|
: "Starting « " + serverName + " » — you'll be moved in automatically.",
|
||||||
NamedTextColor.GRAY));
|
NamedTextColor.GRAY));
|
||||||
waiting.put(id, new Waiter(
|
waiting.put(id, new Waiter(
|
||||||
serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
|
serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
|
||||||
@@ -364,7 +400,9 @@ public final class WaitingRouter {
|
|||||||
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
|
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
|
||||||
if (err != null || (result != null && !result.isSuccessful())) {
|
if (err != null || (result != null && !result.isSuccessful())) {
|
||||||
player.sendMessage(Component.text(
|
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));
|
NamedTextColor.RED));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user