feat(auth): add Owner authentication source settings
Manage Yggdrasil providers from the panel using durable platform settings, protected identity namespaces and atomic revisions. Apply changes to subsequent logins and profile lookups without restarting. Return operator-host logouts to the login method selection page.
This commit is contained in:
33 files changed
+1466
-18
No files matched your search
@@ -74,6 +74,8 @@ curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap
|
|||||||
|
|
||||||
* **设置向导**:先完成面板访问方式与存储配置,再由主机管理员创建首位 Owner,终端会给出一次性网页设置链接(30 分钟内有效)。用浏览器打开链接、登记邮箱并创建通行密钥,即可进入面板,无需启动 Minecraft。角色关联可稍后在“账户”页选择认证源、输入角色名或 UUID 并确认;普通玩家继续通过游戏绑定码验证身份。未完成网页登录或链接过期时,再次执行 `sudo felis setup` 会提供新链接;已设置登录凭证的账号不会被重置。登录服或大厅故障不会阻止面板初始化。“账户”页会解释世界树(Yggdrasil)认证、显示进服地址与登录服/大厅所需的 Java 版客户端版本;安装器会把实际构建版本写入 `[velocity].game_version`,自定义镜像需自行填写,未配置时不会猜测版本。标准世界树接口可直接查询角色;非标准 `hasJoined` 地址可通过 `[[auth_source]].api_url` 指定认证站 API 根地址,游戏绑定码仍可作为替代方式。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。
|
* **设置向导**:先完成面板访问方式与存储配置,再由主机管理员创建首位 Owner,终端会给出一次性网页设置链接(30 分钟内有效)。用浏览器打开链接、登记邮箱并创建通行密钥,即可进入面板,无需启动 Minecraft。角色关联可稍后在“账户”页选择认证源、输入角色名或 UUID 并确认;普通玩家继续通过游戏绑定码验证身份。未完成网页登录或链接过期时,再次执行 `sudo felis setup` 会提供新链接;已设置登录凭证的账号不会被重置。登录服或大厅故障不会阻止面板初始化。“账户”页会解释世界树(Yggdrasil)认证、显示进服地址与登录服/大厅所需的 Java 版客户端版本;安装器会把实际构建版本写入 `[velocity].game_version`,自定义镜像需自行填写,未配置时不会猜测版本。标准世界树接口可直接查询角色;非标准 `hasJoined` 地址可通过 `[[auth_source]].api_url` 指定认证站 API 根地址,游戏绑定码仍可作为替代方式。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。
|
||||||
|
|
||||||
|
* **认证源管理**:Owner 可在左侧“平台 → 认证源”添加、编辑、排序、停用第三方 Yggdrasil 认证站,并测试认证接口。保存后立即用于下一次登录和角色查询,无需重启;面板配置优先于安装配置。已保存的永久标识不能改名或删除,以保留玩家 UUID 与账号绑定;不再使用的源可停用。Mojang 始终优先验证。Nano 继续使用 TOML 配置。
|
||||||
|
|
||||||
* **支持的系统**:CentOS Stream 9(aarch64)已在实机上验证;Ubuntu 24.04(x86_64)在每次推送时由 CI 执行全新安装、重复安装、升级及上述安装命令(参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
|
* **支持的系统**:CentOS Stream 9(aarch64)已在实机上验证;Ubuntu 24.04(x86_64)在每次推送时由 CI 执行全新安装、重复安装、升级及上述安装命令(参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
|
||||||
|
|
||||||
* **安装前检查**:安装器在修改主机之前检查内存、磁盘、端口、网段冲突、已有的 Kubernetes 及外网连通性。发现问题时一次性列出全部问题并退出,主机保持原状(检查项参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
|
* **安装前检查**:安装器在修改主机之前检查内存、磁盘、端口、网段冲突、已有的 Kubernetes 及外网连通性。发现问题时一次性列出全部问题并退出,主机保持原状(检查项参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
|
||||||
|
|||||||
+2
-1
@@ -475,7 +475,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
// [[auth_source]] is configured, so an empty list has to mean a Mojang-only relay, the
|
// [[auth_source]] is configured, so an empty list has to mean a Mojang-only relay, the
|
||||||
// same as under `felis nano`. A nil list would 204 every login, premium ones included.
|
// same as under `felis nano`. A nil list would 204 every login, premium ones included.
|
||||||
a.AuthSources = authSourcesFromConfig(cfg.AuthSources)
|
a.AuthSources = authSourcesFromConfig(cfg.AuthSources)
|
||||||
fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources))
|
a.AuthSourceSettings = &api.AuthSourceSettings{Repo: repo, Defaults: a.AuthSources}
|
||||||
|
fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d default third-party source(s); panel settings take precedence\n", len(cfg.AuthSources))
|
||||||
|
|
||||||
// Passkey (WebAuthn) verifier (spec §14, Phase 6). One relying party spans BOTH
|
// Passkey (WebAuthn) verifier (spec §14, Phase 6). One relying party spans BOTH
|
||||||
// web faces: the RP id is the panel hostname (console.<root>), and because that is
|
// web faces: the RP id is the panel hostname (console.<root>), and because that is
|
||||||
|
|||||||
@@ -311,6 +311,42 @@ components:
|
|||||||
output: { type: string }
|
output: { type: string }
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
AuthSourceConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [tag, prefix, url, api_url, enabled]
|
||||||
|
properties:
|
||||||
|
tag:
|
||||||
|
type: string
|
||||||
|
description: Permanent UUID namespace; saved tags cannot be renamed or removed. mojang is reserved.
|
||||||
|
prefix:
|
||||||
|
type: string
|
||||||
|
pattern: '^[A-Za-z0-9]{1,4}$'
|
||||||
|
description: Unique case-insensitive display prefix.
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
description: hasJoined endpoint; HTTPS required except for localhost or private literal IP addresses. No query or fragment.
|
||||||
|
api_url:
|
||||||
|
type: string
|
||||||
|
description: Optional Yggdrasil API base for role lookup; empty infers it from the standard hasJoined suffix.
|
||||||
|
enabled: { type: boolean }
|
||||||
|
|
||||||
|
AuthSourcesSettings:
|
||||||
|
type: object
|
||||||
|
required: [sources, revision, managed]
|
||||||
|
properties:
|
||||||
|
sources:
|
||||||
|
type: array
|
||||||
|
maxItems: 32
|
||||||
|
description: Third-party sources in priority order; built-in Mojang always precedes them and is immutable.
|
||||||
|
items: { $ref: '#/components/schemas/AuthSourceConfig' }
|
||||||
|
revision:
|
||||||
|
type: string
|
||||||
|
description: Opaque revision to send unchanged when saving; stale or concurrent writes return 409.
|
||||||
|
managed:
|
||||||
|
type: boolean
|
||||||
|
description: True when stored in platform_settings; false while using installation TOML defaults.
|
||||||
|
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
description: Uniform error envelope emitted by every handler (internal/api/errors.go).
|
description: Uniform error envelope emitted by every handler (internal/api/errors.go).
|
||||||
@@ -4229,6 +4265,101 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
|
/api/v1/settings/auth-sources:
|
||||||
|
get:
|
||||||
|
tags: [account]
|
||||||
|
operationId: getAuthSources
|
||||||
|
summary: Read authentication sources (Owner).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current third-party sources and their revision.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthSourcesSettings' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
put:
|
||||||
|
tags: [account]
|
||||||
|
operationId: setAuthSources
|
||||||
|
summary: Save authentication sources (Owner, fresh reauthentication).
|
||||||
|
description: >-
|
||||||
|
Atomically persists an override in platform_settings. It applies to the next
|
||||||
|
login and role lookup on every API replica without restarting; existing
|
||||||
|
players stay online. Tags identify permanent UUID namespaces; retain every
|
||||||
|
saved tag and disable unwanted sources. Mojang remains built-in and trusted,
|
||||||
|
while configured sources always remain third-party. Nano remains TOML-only.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [sources, revision]
|
||||||
|
properties:
|
||||||
|
sources:
|
||||||
|
type: array
|
||||||
|
maxItems: 32
|
||||||
|
items: { $ref: '#/components/schemas/AuthSourceConfig' }
|
||||||
|
revision: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Saved configuration and new revision.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthSourcesSettings' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'409':
|
||||||
|
description: auth_sources_changed or auth_source_tag_locked; reload instead of overwriting another Owner's changes.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
|
||||||
|
/api/v1/settings/auth-sources/test:
|
||||||
|
post:
|
||||||
|
tags: [account]
|
||||||
|
operationId: testAuthSource
|
||||||
|
summary: Probe a hasJoined endpoint (Owner).
|
||||||
|
description: >-
|
||||||
|
Checks an unsaved source with a fresh random serverId. A healthy endpoint
|
||||||
|
returns 204 for a session that never joined. Uses a five-second timeout,
|
||||||
|
verified TLS and no redirects. This tests connectivity and hasJoined
|
||||||
|
behavior, not launcher login or profile lookup. Does not save configuration.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthSourceConfig' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Probe result; non-204 status has ok=false.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ok, status, elapsed_ms]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean }
|
||||||
|
status: { type: integer }
|
||||||
|
elapsed_ms: { type: integer, format: int64 }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
|
||||||
/api/v1/updates/window:
|
/api/v1/updates/window:
|
||||||
get:
|
get:
|
||||||
tags: [admin-updates]
|
tags: [admin-updates]
|
||||||
|
|||||||
@@ -874,3 +874,32 @@ the moved domain and left `check` clean; moving back restored every surface
|
|||||||
on every run, so a host upgraded from one can show that line once with the file already
|
on every run, so a host upgraded from one can show that line once with the file already
|
||||||
on the names; `sudo systemctl restart felis-velocity` clears it. The installer now leaves
|
on the names; `sudo systemctl restart felis-velocity` clears it. The installer now leaves
|
||||||
the file alone when its content is the same.
|
the file alone when its content is the same.
|
||||||
|
|
||||||
|
## 7. Authentication sources
|
||||||
|
|
||||||
|
On the operator console, the Owner's **Platform → Authentication sources** page
|
||||||
|
(`/admin/auth-sources`) manages third-party Yggdrasil providers. It imports the
|
||||||
|
installation's `[[auth_source]]` list on first use. Save stores the ordered list
|
||||||
|
in `platform_settings.auth_sources`; that override then takes precedence over TOML
|
||||||
|
and is read by every full-API replica for the next game login and role lookup.
|
||||||
|
No restart is required, and existing players stay connected. Nano continues to
|
||||||
|
use its TOML list. A database read failure refuses new authentication rather than
|
||||||
|
falling back to an obsolete or disabled provider.
|
||||||
|
|
||||||
|
Mojang remains enabled and first, retaining official UUIDs. Every third-party
|
||||||
|
provider uses a permanent tag as its UUID namespace; saved or imported tags cannot
|
||||||
|
be renamed or removed. Disable a provider to stop accepting its logins, or enable
|
||||||
|
it again to restore the same identities. Changing a provider's endpoint changes
|
||||||
|
who verifies identities in that namespace; keep it pointed at the same trusted
|
||||||
|
service. Prefixes are 1–4 letters/digits and must be unique regardless of case.
|
||||||
|
|
||||||
|
Set the full `hasJoined` URL. Standard paths infer the profile-query API root;
|
||||||
|
nonstandard paths need an explicit API root for role-name/UUID lookup. HTTPS is
|
||||||
|
required, except for localhost or literal private IPs; query strings and fragments
|
||||||
|
are rejected. Launchers must authenticate with the same provider. **Test connection**
|
||||||
|
probes an unused session and expects HTTP 204; it does not save, verify launcher
|
||||||
|
configuration, or test the profile-query API.
|
||||||
|
|
||||||
|
Saving requires Owner access on the operator host and recent reauthentication for
|
||||||
|
a local session. A revision conflict preserves the draft; discard it and reload
|
||||||
|
before editing the newer configuration.
|
||||||
+5
-1
@@ -201,7 +201,8 @@ type API struct {
|
|||||||
// first for 正版优先). Nil makes the session verifier reject every login (204);
|
// first for 正版优先). Nil makes the session verifier reject every login (204);
|
||||||
// cmd/felis always wires at least the Mojang source through authSourcesFromConfig.
|
// cmd/felis always wires at least the Mojang source through authSourcesFromConfig.
|
||||||
// Consumed by handleHasJoined (handlers_hasjoined.go).
|
// Consumed by handleHasJoined (handlers_hasjoined.go).
|
||||||
AuthSources []AuthSource
|
AuthSources []AuthSource
|
||||||
|
AuthSourceSettings *AuthSourceSettings
|
||||||
|
|
||||||
// AuthDoorLimit bounds how often one client address may call the public
|
// AuthDoorLimit bounds how often one client address may call the public
|
||||||
// pre-session auth doors (ratelimit.go). MailLimit bounds all mail the API
|
// pre-session auth doors (ratelimit.go). MailLimit bounds all mail the API
|
||||||
@@ -651,6 +652,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// Staff can designate their own game identity after panel setup. Players
|
// Staff can designate their own game identity after panel setup. Players
|
||||||
// retain the in-game proof flow above.
|
// retain the in-game proof flow above.
|
||||||
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
|
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
|
||||||
|
{Method: "GET", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources},
|
||||||
|
{Method: "PUT", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleSetAuthSources},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/settings/auth-sources/test", Owner: true, Admin: true, h: a.handleTestAuthSource},
|
||||||
{Method: "GET", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLookupProfile},
|
{Method: "GET", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLookupProfile},
|
||||||
{Method: "POST", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLinkProfile},
|
{Method: "POST", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLinkProfile},
|
||||||
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
|
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
|
||||||
|
|||||||
@@ -1231,6 +1231,14 @@ func (f *fakeRepo) SetSetting(_ context.Context, key string, value []byte) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) CompareAndSetSetting(ctx context.Context, key string, expected, value []byte) error {
|
||||||
|
current, exists := f.settings[key]
|
||||||
|
if (expected == nil && exists) || (expected != nil && (!exists || string(current) != string(expected))) {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return f.SetSetting(ctx, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- user admin fakes ----
|
// ---- user admin fakes ----
|
||||||
|
|
||||||
// seededUser is a test-only user row held in the fake repo.
|
// seededUser is a test-only user row held in the fake repo.
|
||||||
|
|||||||
@@ -37,8 +37,13 @@ func (a *API) handleLinkSources(w http.ResponseWriter, r *http.Request) {
|
|||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
LookupAvailable bool `json:"lookup_available"`
|
LookupAvailable bool `json:"lookup_available"`
|
||||||
}
|
}
|
||||||
sources := make([]sourceView, 0, len(a.AuthSources))
|
configured, err := a.currentAuthSources(r.Context())
|
||||||
for _, src := range a.AuthSources {
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sources := make([]sourceView, 0, len(configured))
|
||||||
|
for _, src := range configured {
|
||||||
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
|
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
|
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
|
||||||
@@ -105,7 +110,11 @@ func (a *API) lookupProfile(ctx context.Context, source, input string) (*linkedP
|
|||||||
}
|
}
|
||||||
var src AuthSource
|
var src AuthSource
|
||||||
found := false
|
found := false
|
||||||
for _, candidate := range a.AuthSources {
|
sources, err := a.currentAuthSources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, candidate := range sources {
|
||||||
if candidate.Tag == source {
|
if candidate.Tag == source {
|
||||||
src, found = candidate, true
|
src, found = candidate, true
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const authSourcesKey = "auth_sources"
|
||||||
|
|
||||||
|
type authSourceEntry struct {
|
||||||
|
config.AuthSourceConfig
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type authSourcesView struct {
|
||||||
|
Sources []authSourceEntry `json:"sources"`
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthSourceSettings reuses platform_settings for the full control plane. Nano
|
||||||
|
// keeps its TOML-only sources. A durable override is read for each login, so all
|
||||||
|
// API replicas see the same list and a DB failure never revives a disabled root.
|
||||||
|
type AuthSourceSettings struct {
|
||||||
|
Repo Repo
|
||||||
|
Defaults []AuthSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAuthSourceEntries(entries []authSourceEntry) error {
|
||||||
|
if entries == nil || len(entries) > 32 {
|
||||||
|
return fmt.Errorf("provide a sources array with at most 32 entries")
|
||||||
|
}
|
||||||
|
sources := make([]config.AuthSourceConfig, len(entries))
|
||||||
|
for i, entry := range entries {
|
||||||
|
sources[i] = entry.AuthSourceConfig
|
||||||
|
}
|
||||||
|
return config.ValidateAuthSources(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthSourceSettings) read(ctx context.Context) (authSourcesView, []byte, error) {
|
||||||
|
view := authSourcesView{Sources: []authSourceEntry{}}
|
||||||
|
raw, err := s.Repo.GetSetting(ctx, authSourcesKey)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
for _, source := range s.Defaults {
|
||||||
|
if !source.Identity {
|
||||||
|
view.Sources = append(view.Sources, authSourceEntry{AuthSourceConfig: config.AuthSourceConfig{
|
||||||
|
Tag: source.Tag, Prefix: source.Prefix, URL: source.URL, APIURL: source.APIURL,
|
||||||
|
}, Enabled: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw = nil
|
||||||
|
case err != nil:
|
||||||
|
return view, nil, err
|
||||||
|
default:
|
||||||
|
if err := json.Unmarshal(raw, &view.Sources); err != nil {
|
||||||
|
return view, nil, fmt.Errorf("auth sources: invalid stored configuration: %w", err)
|
||||||
|
}
|
||||||
|
view.Managed = true
|
||||||
|
}
|
||||||
|
if err := validateAuthSourceEntries(view.Sources); err != nil {
|
||||||
|
return view, nil, err
|
||||||
|
}
|
||||||
|
canonical, _ := json.Marshal(view.Sources)
|
||||||
|
sum := sha256.Sum256(canonical)
|
||||||
|
view.Revision = hex.EncodeToString(sum[:])
|
||||||
|
return view, raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) currentAuthSources(ctx context.Context) ([]AuthSource, error) {
|
||||||
|
if a.AuthSourceSettings == nil {
|
||||||
|
return a.AuthSources, nil
|
||||||
|
}
|
||||||
|
view, _, err := a.AuthSourceSettings.read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources := make([]AuthSource, 0, len(view.Sources)+1)
|
||||||
|
for _, source := range a.AuthSourceSettings.Defaults {
|
||||||
|
if source.Identity {
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entry := range view.Sources {
|
||||||
|
if entry.Enabled {
|
||||||
|
sources = append(sources, AuthSource{Tag: entry.Tag, Prefix: entry.Prefix, URL: entry.URL, APIURL: entry.APIURL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleGetAuthSources(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.AuthSourceSettings == nil {
|
||||||
|
writeError(w, r, newError(http.StatusServiceUnavailable, "auth_sources_unavailable", "authentication source settings are not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, _, err := a.AuthSourceSettings.read(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleSetAuthSources(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireReauth(w, r, principalFromContext(r.Context())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.AuthSourceSettings == nil {
|
||||||
|
writeError(w, r, newError(http.StatusServiceUnavailable, "auth_sources_unavailable", "authentication source settings are not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Sources []authSourceEntry `json:"sources"`
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateAuthSourceEntries(body.Sources); err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, expected, err := a.AuthSourceSettings.read(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Revision != view.Revision {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "auth_sources_changed", "authentication sources changed; reload before saving"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags := make(map[string]bool, len(body.Sources))
|
||||||
|
for _, source := range body.Sources {
|
||||||
|
tags[source.Tag] = true
|
||||||
|
}
|
||||||
|
for _, existing := range view.Sources {
|
||||||
|
if !tags[existing.Tag] {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "auth_source_tag_locked", "saved source tags are permanent; disable the source instead of removing or renaming it"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, _ := json.Marshal(body.Sources)
|
||||||
|
err = a.AuthSourceSettings.Repo.CompareAndSetSetting(r.Context(), authSourcesKey, expected, value)
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "auth_sources_changed", "authentication sources changed; reload before saving"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.Sources, view.Managed = body.Sources, true
|
||||||
|
sum := sha256.Sum256(value)
|
||||||
|
view.Revision = hex.EncodeToString(sum[:])
|
||||||
|
a.audit(r, "auth_sources.updated", "")
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleTestAuthSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var source authSourceEntry
|
||||||
|
if err := decodeJSON(w, r, &source); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateAuthSourceEntries([]authSourceEntry{source}); err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
probeID, err := newPasskeyID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A fresh random serverId has never joined: a healthy hasJoined endpoint
|
||||||
|
// answers 204. Reuse authentication's timeout, TLS and redirect policy.
|
||||||
|
started := time.Now()
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
|
||||||
|
source.URL+"?username=FelisProbe&serverId="+url.QueryEscape(probeID), nil)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := authHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the authentication endpoint could not be reached"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": resp.StatusCode == http.StatusNoContent, "status": resp.StatusCode, "elapsed_ms": time.Since(started).Milliseconds()})
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const authSourcesPath = "/api/v1/settings/auth-sources"
|
||||||
|
|
||||||
|
func sourceEntry(tag, prefix, endpoint string) authSourceEntry {
|
||||||
|
return authSourceEntry{AuthSourceConfig: config.AuthSourceConfig{Tag: tag, Prefix: prefix, URL: endpoint}, Enabled: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAuthSourcesAPI() (*API, *fakeRepo) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
a := newTestAPI(repo, newFakeCluster())
|
||||||
|
a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}}
|
||||||
|
a.AuthSources = []AuthSource{{Tag: "mojang", URL: "https://sessionserver.mojang.com/session/minecraft/hasJoined", Identity: true}, {Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined"}}
|
||||||
|
a.AuthSourceSettings = &AuthSourceSettings{Repo: repo, Defaults: a.AuthSources}
|
||||||
|
return a, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAuthSources(t *testing.T, a *API) authSourcesView {
|
||||||
|
t.Helper()
|
||||||
|
w := do(a.ExternalHandler(), "GET", authSourcesPath, "", nil)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("read = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var view authSourcesView
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &view); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAuthSources(a *API, view authSourcesView) *httptest.ResponseRecorder {
|
||||||
|
body, _ := json.Marshal(map[string]any{"sources": view.Sources, "revision": view.Revision})
|
||||||
|
return do(a.ExternalHandler(), "PUT", authSourcesPath, string(body), jsonHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSourcesSaveAndRuntime(t *testing.T) {
|
||||||
|
const native = "123456781234423482341234567890ab"
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/official" {
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/profile/"+native) {
|
||||||
|
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
a, repo := seedAuthSourcesAPI()
|
||||||
|
a.AuthSources[0].URL = upstream.URL + "/official"
|
||||||
|
a.AuthSources[1].URL = upstream.URL + "/sessionserver/session/minecraft/hasJoined"
|
||||||
|
before := readAuthSources(t, a)
|
||||||
|
if before.Managed || len(before.Sources) != 1 || before.Sources[0].Tag != "littleskin" {
|
||||||
|
t.Fatalf("defaults = %+v", before)
|
||||||
|
}
|
||||||
|
before.Sources = append(before.Sources, sourceEntry("other", "OT", upstream.URL+"/other-check"))
|
||||||
|
before.Sources[1].APIURL = upstream.URL
|
||||||
|
before.Sources[0], before.Sources[1] = before.Sources[1], before.Sources[0]
|
||||||
|
w := saveAuthSources(a, before)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("save = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
saved := readAuthSources(t, a)
|
||||||
|
if !saved.Managed || saved.Revision == before.Revision || saved.Sources[0].Tag != "other" {
|
||||||
|
t.Fatalf("saved = %+v", saved)
|
||||||
|
}
|
||||||
|
// Another API replica sees the same order and identity policy without restart.
|
||||||
|
replica := newTestAPI(repo, newFakeCluster())
|
||||||
|
replica.AuthSourceSettings = &AuthSourceSettings{Repo: repo, Defaults: a.AuthSources}
|
||||||
|
sources, err := replica.currentAuthSources(context.Background())
|
||||||
|
if err != nil || len(sources) != 3 || !sources[0].Identity || sources[1].Identity || sources[1].Tag != "other" {
|
||||||
|
t.Fatalf("active = %+v err = %v", sources, err)
|
||||||
|
}
|
||||||
|
profile, src, failed := replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
|
||||||
|
if profile == nil || failed || src.Tag != "other" {
|
||||||
|
t.Fatalf("login = %+v src=%+v failed=%v", profile, src, failed)
|
||||||
|
}
|
||||||
|
preview, err := a.lookupProfile(context.Background(), "other", native)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
canonical, _ := canonicalProfileUUID(src, native)
|
||||||
|
if preview.MCUUID != canonical.String() {
|
||||||
|
t.Fatal("role lookup and game identity disagree")
|
||||||
|
}
|
||||||
|
// Disabled sources disappear from login priority and role lookup immediately.
|
||||||
|
saved.Sources[0].Enabled = false
|
||||||
|
if w = saveAuthSources(a, saved); w.Code != 200 {
|
||||||
|
t.Fatalf("disable = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
_, src, _ = replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
|
||||||
|
if src.Tag != "littleskin" {
|
||||||
|
t.Fatalf("disabled source still used: %+v", src)
|
||||||
|
}
|
||||||
|
if _, err = a.lookupProfile(context.Background(), "other", native); err == nil {
|
||||||
|
t.Fatal("disabled source remains selectable")
|
||||||
|
}
|
||||||
|
list := do(a.ExternalHandler(), "GET", "/api/v1/account/link/sources", "", nil)
|
||||||
|
if list.Code != 200 || strings.Contains(list.Body.String(), `"other"`) || strings.Contains(list.Body.String(), upstream.URL) {
|
||||||
|
t.Fatalf("public source list = %d %s", list.Code, list.Body.String())
|
||||||
|
}
|
||||||
|
// Durable failure must never resurrect the installation defaults.
|
||||||
|
repo.failGetSetting = errors.New("database unavailable")
|
||||||
|
profile, _, failed = replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
|
||||||
|
if profile != nil || !failed {
|
||||||
|
t.Fatal("DB outage fell back to obsolete configuration")
|
||||||
|
}
|
||||||
|
if _, err = replica.currentAuthSources(context.Background()); err == nil {
|
||||||
|
t.Fatal("settings outage ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSourcesProtectNamespacesAndRevision(t *testing.T) {
|
||||||
|
a, repo := seedAuthSourcesAPI()
|
||||||
|
initial := readAuthSources(t, a)
|
||||||
|
for _, rename := range []bool{false, true} {
|
||||||
|
v := readAuthSources(t, a)
|
||||||
|
if rename {
|
||||||
|
v.Sources[0].Tag = "renamed"
|
||||||
|
} else {
|
||||||
|
v.Sources = []authSourceEntry{}
|
||||||
|
}
|
||||||
|
w := saveAuthSources(a, v)
|
||||||
|
if w.Code != 409 || decodeErr(t, w) != "auth_source_tag_locked" {
|
||||||
|
t.Fatalf("namespace change = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initial.Sources[0].Enabled = false
|
||||||
|
if w := saveAuthSources(a, initial); w.Code != 200 {
|
||||||
|
t.Fatalf("save = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
original := string(repo.settings[authSourcesKey])
|
||||||
|
initial.Sources[0].Prefix = "XX"
|
||||||
|
if w := saveAuthSources(a, initial); w.Code != 409 || decodeErr(t, w) != "auth_sources_changed" {
|
||||||
|
t.Fatalf("stale save = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if string(repo.settings[authSourcesKey]) != original {
|
||||||
|
t.Fatal("stale save overwrote durable config")
|
||||||
|
}
|
||||||
|
// Invalid stored data also fails closed.
|
||||||
|
repo.settings[authSourcesKey] = []byte(`[{"tag":"mojang","prefix":"M","url":"https://example.test/check","enabled":true}]`)
|
||||||
|
if _, err := a.currentAuthSources(context.Background()); err == nil {
|
||||||
|
t.Fatal("corrupt DB config trusted as Mojang")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type conflictSettingsRepo struct{ *fakeRepo }
|
||||||
|
|
||||||
|
func (r conflictSettingsRepo) CompareAndSetSetting(context.Context, string, []byte, []byte) error {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSourcesConcurrentWrite(t *testing.T) {
|
||||||
|
a, repo := seedAuthSourcesAPI()
|
||||||
|
v := readAuthSources(t, a)
|
||||||
|
a.AuthSourceSettings.Repo = conflictSettingsRepo{repo}
|
||||||
|
v.Sources[0].Enabled = false
|
||||||
|
w := saveAuthSources(a, v)
|
||||||
|
if w.Code != 409 || decodeErr(t, w) != "auth_sources_changed" || len(repo.settings) != 0 {
|
||||||
|
t.Fatalf("concurrent write = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSourcesValidation(t *testing.T) {
|
||||||
|
valid := sourceEntry("custom", "CS", "https://example.test/check")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
entries []authSourceEntry
|
||||||
|
}{
|
||||||
|
{"null", nil},
|
||||||
|
{"Mojang", []authSourceEntry{sourceEntry("MOJANG", "M", valid.URL)}},
|
||||||
|
{"empty tag", []authSourceEntry{sourceEntry("", "M", valid.URL)}},
|
||||||
|
{"colon", []authSourceEntry{sourceEntry("x:y", "M", valid.URL)}},
|
||||||
|
{"duplicate tag", []authSourceEntry{valid, valid}},
|
||||||
|
{"duplicate prefix", []authSourceEntry{valid, sourceEntry("other", "cs", valid.URL)}},
|
||||||
|
{"long prefix", []authSourceEntry{sourceEntry("x", "ABCDE", valid.URL)}},
|
||||||
|
{"public HTTP", []authSourceEntry{sourceEntry("x", "X", "http://example.test/check")}},
|
||||||
|
{"query", []authSourceEntry{sourceEntry("x", "X", valid.URL+"?secret=1")}},
|
||||||
|
{"fragment", []authSourceEntry{sourceEntry("x", "X", valid.URL+"#fragment")}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
a, repo := seedAuthSourcesAPI()
|
||||||
|
v := readAuthSources(t, a)
|
||||||
|
v.Sources = tc.entries
|
||||||
|
w := saveAuthSources(a, v)
|
||||||
|
if w.Code != 400 || len(repo.settings) != 0 {
|
||||||
|
t.Fatalf("validation = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a, _ := seedAuthSourcesAPI()
|
||||||
|
w := do(a.ExternalHandler(), "PUT", authSourcesPath, `{"sources":[],"revision":"x","identity":true}`, jsonHeader)
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Fatal("caller could set trust policy")
|
||||||
|
}
|
||||||
|
w = do(a.ExternalHandler(), "PUT", authSourcesPath, `{}`, map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
|
if w.Code != 415 {
|
||||||
|
t.Fatal("cross-site form accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSourcesOwnerGateAndReauth(t *testing.T) {
|
||||||
|
for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "user", Role: "user", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} {
|
||||||
|
a, repo := seedAuthSourcesAPI()
|
||||||
|
a.External = staticExternal{p: p}
|
||||||
|
for _, route := range []struct{ method, path string }{{"GET", authSourcesPath}, {"PUT", authSourcesPath}, {"POST", authSourcesPath + "/test"}} {
|
||||||
|
w := do(a.ExternalHandler(), route.method, route.path, `{}`, jsonHeader)
|
||||||
|
if w.Code != 401 && w.Code != 403 {
|
||||||
|
t.Fatalf("%+v reached %s: %d", p, route.path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(repo.settings) != 0 {
|
||||||
|
t.Fatal("unauthorized write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a, repo := seedAuthSourcesAPI()
|
||||||
|
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true, ViaSession: true, EmailVerified: true}
|
||||||
|
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
|
||||||
|
a.External = staticExternal{p: p}
|
||||||
|
v := readAuthSources(t, a)
|
||||||
|
v.Sources[0].Enabled = false
|
||||||
|
w := saveAuthSources(a, v)
|
||||||
|
if w.Code != 403 || decodeErr(t, w) != "reauth_required" || len(repo.settings) != 0 {
|
||||||
|
t.Fatalf("reauth = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
p.ReauthAt = a.now()
|
||||||
|
if w = saveAuthSources(a, v); w.Code != 200 {
|
||||||
|
t.Fatalf("fresh reauth = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSourceProbe(t *testing.T) {
|
||||||
|
for _, status := range []int{204, 200, 302, 503} {
|
||||||
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||||
|
ids := []string{}
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("username") != "FelisProbe" {
|
||||||
|
t.Error("missing probe user")
|
||||||
|
}
|
||||||
|
ids = append(ids, r.URL.Query().Get("serverId"))
|
||||||
|
w.Header().Set("Location", "http://127.0.0.1:1/no-redirect")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
a, repo := seedAuthSourcesAPI()
|
||||||
|
body, _ := json.Marshal(sourceEntry("probe", "P", upstream.URL))
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
w := do(a.ExternalHandler(), "POST", authSourcesPath+"/test", string(body), jsonHeader)
|
||||||
|
var result struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Elapsed int64 `json:"elapsed_ms"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != 200 || result.OK != (status == 204) || result.Status != status || result.Elapsed < 0 {
|
||||||
|
t.Fatalf("probe = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) != 2 || ids[0] == "" || ids[0] == ids[1] || !reflect.DeepEqual(repo.settings, map[string][]byte{}) {
|
||||||
|
t.Fatal("probe reused identity or saved config")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a, _ := seedAuthSourcesAPI()
|
||||||
|
body, _ := json.Marshal(sourceEntry("probe", "P", "http://127.0.0.1:1/check"))
|
||||||
|
w := do(a.ExternalHandler(), "POST", authSourcesPath+"/test", string(body), jsonHeader)
|
||||||
|
if w.Code != 503 || decodeErr(t, w) != "auth_source_unavailable" {
|
||||||
|
t.Fatalf("unreachable = %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,7 +333,12 @@ func lookupPremiumName(ctx context.Context, username string) (bool, error) {
|
|||||||
// as failed: otherwise a dead or mistyped source looks exactly like a player it does not
|
// as failed: otherwise a dead or mistyped source looks exactly like a player it does not
|
||||||
// know, and nobody finds out.
|
// know, and nobody finds out.
|
||||||
func (a *API) resolveHasJoined(ctx context.Context, username, serverID, ip string) (prof *sessionProfile, src AuthSource, failed bool) {
|
func (a *API) resolveHasJoined(ctx context.Context, username, serverID, ip string) (prof *sessionProfile, src AuthSource, failed bool) {
|
||||||
for _, src := range a.AuthSources {
|
sources, err := a.currentAuthSources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("hasJoined: read authentication sources: %v", err)
|
||||||
|
return nil, AuthSource{}, true
|
||||||
|
}
|
||||||
|
for _, src := range sources {
|
||||||
u := src.URL + "?username=" + url.QueryEscape(username) + "&serverId=" + url.QueryEscape(serverID)
|
u := src.URL + "?username=" + url.QueryEscape(username) + "&serverId=" + url.QueryEscape(serverID)
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
u += "&ip=" + url.QueryEscape(ip)
|
u += "&ip=" + url.QueryEscape(ip)
|
||||||
|
|||||||
@@ -1494,6 +1494,24 @@ func (p *PGRepo) SetSetting(ctx context.Context, key string, value []byte) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *PGRepo) CompareAndSetSetting(ctx context.Context, key string, expected, value []byte) error {
|
||||||
|
query := `UPDATE platform_settings SET value = $2::jsonb, updated_at = now() WHERE key = $1 AND value = $3::jsonb`
|
||||||
|
args := []any{key, string(value), string(expected)}
|
||||||
|
if expected == nil {
|
||||||
|
query = `INSERT INTO platform_settings (key, value) VALUES ($1, $2::jsonb) ON CONFLICT (key) DO NOTHING`
|
||||||
|
args = args[:2]
|
||||||
|
}
|
||||||
|
res, err := p.db.ExecContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err == nil && n == 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind, migration 0007) ----
|
// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind, migration 0007) ----
|
||||||
|
|
||||||
// CreatePasskeyChallenge supersedes any prior challenge for (user, purpose) and inserts
|
// CreatePasskeyChallenge supersedes any prior challenge for (user, purpose) and inserts
|
||||||
|
|||||||
@@ -750,6 +750,9 @@ type Repo interface {
|
|||||||
GetSetting(ctx context.Context, key string) ([]byte, error)
|
GetSetting(ctx context.Context, key string) ([]byte, error)
|
||||||
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
||||||
SetSetting(ctx context.Context, key string, value []byte) error
|
SetSetting(ctx context.Context, key string, value []byte) error
|
||||||
|
// CompareAndSetSetting refuses a concurrent edit with ErrConflict. A nil
|
||||||
|
// expected value creates only when the setting has never been written.
|
||||||
|
CompareAndSetSetting(ctx context.Context, key string, expected, value []byte) error
|
||||||
|
|
||||||
// ---- user admin (spec §7, admin-only) ----
|
// ---- user admin (spec §7, admin-only) ----
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ type Config struct {
|
|||||||
// the tag, which cannot know that "littleskin" is meant to read LS.
|
// the tag, which cannot know that "littleskin" is meant to read LS.
|
||||||
// No trusted/identity field, by design — see Config.AuthSources.
|
// No trusted/identity field, by design — see Config.AuthSources.
|
||||||
type AuthSourceConfig struct {
|
type AuthSourceConfig struct {
|
||||||
Tag string `toml:"tag"`
|
Tag string `toml:"tag" json:"tag"`
|
||||||
Prefix string `toml:"prefix"`
|
Prefix string `toml:"prefix" json:"prefix"`
|
||||||
URL string `toml:"url"`
|
URL string `toml:"url" json:"url"`
|
||||||
// APIURL is optional for sources whose hasJoined URL does not use the standard path.
|
// APIURL is optional for sources whose hasJoined URL does not use the standard path.
|
||||||
APIURL string `toml:"api_url"`
|
APIURL string `toml:"api_url" json:"api_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
|
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
|
||||||
@@ -672,6 +672,12 @@ func (o OffsiteConfig) validate() error {
|
|||||||
// leave a legible name behind after truncation.
|
// leave a legible name behind after truncation.
|
||||||
var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`)
|
var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`)
|
||||||
|
|
||||||
|
// ValidateAuthSources applies the same identity and endpoint rules to panel and
|
||||||
|
// TOML configuration. Mojang remains the code-owned first source.
|
||||||
|
func ValidateAuthSources(sources []AuthSourceConfig) error {
|
||||||
|
return (&Config{AuthSources: sources}).validateAuthSources()
|
||||||
|
}
|
||||||
|
|
||||||
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename
|
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename
|
||||||
// prefix, and a scheme-qualified hasJoined URL, and both tag and prefix must be unique. A
|
// prefix, and a scheme-qualified hasJoined URL, and both tag and prefix must be unique. A
|
||||||
// blank, duplicate or colon-bearing tag collapses two sources into one UUID namespace
|
// blank, duplicate or colon-bearing tag collapses two sources into one UUID namespace
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
//go:build pgint
|
||||||
|
|
||||||
|
package pgint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsCompareAndSet(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := "auth-sources-" + suffix(t)
|
||||||
|
defer db.ExecContext(ctx, "DELETE FROM platform_settings WHERE key=$1", key)
|
||||||
|
first := []byte(`[{"tag":"custom","enabled":true}]`)
|
||||||
|
next := []byte(`[{"tag":"custom","enabled":false}]`)
|
||||||
|
if err := repo.CompareAndSetSetting(ctx, key, first, next); !errors.Is(err, api.ErrConflict) {
|
||||||
|
t.Fatalf("missing update = %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.CompareAndSetSetting(ctx, key, nil, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.CompareAndSetSetting(ctx, key, nil, next); !errors.Is(err, api.ErrConflict) {
|
||||||
|
t.Fatalf("concurrent insert = %v", err)
|
||||||
|
}
|
||||||
|
// jsonb equality must survive PostgreSQL's different spacing and key order.
|
||||||
|
raw, err := repo.GetSetting(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); results <- repo.CompareAndSetSetting(ctx, key, raw, next) }()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
succeeded, conflicted := 0, 0
|
||||||
|
for err := range results {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
succeeded++
|
||||||
|
case errors.Is(err, api.ErrConflict):
|
||||||
|
conflicted++
|
||||||
|
default:
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if succeeded != 1 || conflicted != 1 {
|
||||||
|
t.Fatalf("concurrent update: success=%d conflict=%d", succeeded, conflicted)
|
||||||
|
}
|
||||||
|
if err := repo.CompareAndSetSetting(ctx, key, []byte(`[ { "enabled" : false, "tag" : "custom" } ]`), first); err != nil {
|
||||||
|
t.Fatalf("JSON equality = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import { gzipSync } from "node:zlib";
|
import { gzipSync } from "node:zlib";
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import type {
|
import type {
|
||||||
|
AuthSourceConfig,
|
||||||
|
AuthSourcesSettings,
|
||||||
AutostartPolicy,
|
AutostartPolicy,
|
||||||
BackupView,
|
BackupView,
|
||||||
Build,
|
Build,
|
||||||
@@ -89,6 +91,7 @@ interface MockState {
|
|||||||
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
updateWindow: { start: string | null; end: string | null };
|
updateWindow: { start: string | null; end: string | null };
|
||||||
|
authSources: AuthSourcesSettings;
|
||||||
// Each server's world volume, seeded on first visit.
|
// Each server's world volume, seeded on first visit.
|
||||||
files: Record<string, MockTree>;
|
files: Record<string, MockTree>;
|
||||||
}
|
}
|
||||||
@@ -452,6 +455,11 @@ function initialState(): MockState {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
updateWindow: { start: null, end: null },
|
updateWindow: { start: null, end: null },
|
||||||
|
authSources: {
|
||||||
|
sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }],
|
||||||
|
revision: "installation-defaults",
|
||||||
|
managed: false,
|
||||||
|
},
|
||||||
files: {},
|
files: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1008,6 +1016,38 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
|
|
||||||
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||||
switch (route(ctx)) {
|
switch (route(ctx)) {
|
||||||
|
case "GET settings/auth-sources":
|
||||||
|
case "PUT settings/auth-sources":
|
||||||
|
case "POST settings/auth-sources/test": {
|
||||||
|
if (!isOwner(ctx.account.role)) {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "Owner account required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (is("GET", ctx)) {
|
||||||
|
sendJSON(ctx.res, 200, ctx.state.authSources);
|
||||||
|
} else if (is("PUT", ctx)) {
|
||||||
|
const body = await readJSON<{ sources: AuthSourceConfig[]; revision: string }>(ctx.req);
|
||||||
|
if (body.revision !== ctx.state.authSources.revision) {
|
||||||
|
sendError(ctx.res, 409, "auth_sources_changed", "authentication sources changed; reload before saving");
|
||||||
|
} else if (ctx.state.authSources.sources.some((source) => !body.sources.some((s) => s.tag === source.tag))) {
|
||||||
|
sendError(ctx.res, 409, "auth_source_tag_locked", "disable saved sources instead of removing or renaming them");
|
||||||
|
} else {
|
||||||
|
ctx.state.authSources = { sources: body.sources, revision: createHash("sha256").update(JSON.stringify(body.sources)).digest("hex"), managed: true };
|
||||||
|
sendJSON(ctx.res, 200, ctx.state.authSources);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await readJSON<AuthSourceConfig>(ctx.req);
|
||||||
|
sendJSON(ctx.res, 200, { ok: true, status: 204, elapsed_ms: 20 });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "GET account/link/sources":
|
||||||
|
if (!isAdmin(ctx.account.role)) {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendJSON(ctx.res, 200, { sources: [{ tag: "mojang", prefix: "", lookup_available: true }, ...ctx.state.authSources.sources.filter((source) => source.enabled).map((source) => ({ tag: source.tag, prefix: source.prefix, lookup_available: !!source.api_url || source.url.endsWith("/sessionserver/session/minecraft/hasJoined") }))] });
|
||||||
|
return true;
|
||||||
case "GET platform/db-backup": {
|
case "GET platform/db-backup": {
|
||||||
if (!isAdmin(ctx.account.role)) {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||||
|
|
||||||
|
test("Owner manages durable authentication sources from the sidebar", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("link", { name: t("navigation:auth_sources"), exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/auth-sources$/);
|
||||||
|
await expect(page.getByLabel(t("authSources:tag"))).toHaveValue("littleskin");
|
||||||
|
await expect(page.getByLabel(t("authSources:tag"))).toHaveAttribute("readonly", "");
|
||||||
|
await page.getByRole("button", { name: t("authSources:add"), exact: true }).click();
|
||||||
|
await page.getByLabel(t("authSources:tag")).nth(1).fill("custom");
|
||||||
|
await page.getByLabel(t("authSources:prefix")).nth(1).fill("CS");
|
||||||
|
await page.getByLabel(t("authSources:url"), { exact: true }).nth(1).fill("https://custom.example/sessionserver/session/minecraft/hasJoined");
|
||||||
|
await page.getByRole("button", { name: t("authSources:test"), exact: true }).nth(1).click();
|
||||||
|
await expect(page.getByText(t("authSources:test_ok", { ms: 20 }), { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: t("authSources:move_up"), exact: true }).nth(1).click();
|
||||||
|
await page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1).uncheck();
|
||||||
|
await page.getByRole("button", { name: t("authSources:save"), exact: true }).click();
|
||||||
|
await expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel(t("authSources:tag")).nth(0)).toHaveValue("custom");
|
||||||
|
await expect(page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1)).not.toBeChecked();
|
||||||
|
await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toHaveCount(0);
|
||||||
|
const available = await page.request.get("/api/v1/account/link/sources");
|
||||||
|
expect((await available.json()).sources.map((source: { tag: string }) => source.tag)).toEqual(["mojang", "custom"]);
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await page.screenshot({ path: "/tmp/felis-auth-sources.png", fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("source fields stay visible while initial configuration is loading", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
await page.route("**/api/v1/settings/auth-sources", async (route) => { await pending; await route.continue(); });
|
||||||
|
await page.goto("/admin/auth-sources");
|
||||||
|
await expect(page.getByLabel(t("authSources:tag"))).toBeVisible();
|
||||||
|
await expect(page.getByLabel(t("authSources:tag"))).toBeDisabled();
|
||||||
|
await expect(page.getByText(t("authSources:loading"), { exact: true }).first()).toBeVisible();
|
||||||
|
release();
|
||||||
|
await expect(page.getByLabel(t("authSources:tag"))).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a player has neither an authentication source entry nor access to its API", async ({ page, signIn }) => {
|
||||||
|
await signIn("linked");
|
||||||
|
await page.goto("/admin/auth-sources");
|
||||||
|
await expect(page.getByText(t("common:not_authorized_title"))).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: t("navigation:auth_sources"), exact: true })).toHaveCount(0);
|
||||||
|
const response = await page.request.get("/api/v1/settings/auth-sources");
|
||||||
|
expect(response.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of ["/servers", "/account"]) {
|
||||||
|
test(`logout from ${path} on the operator host returns to login methods`, async ({ page, signIn }) => {
|
||||||
|
await page.route("**/config.json", async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
await route.fulfill({ response, json: { ...await response.json(), adminHostname: "localhost" } });
|
||||||
|
});
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto(path);
|
||||||
|
const logout = page.getByRole("button", { name: t("account:sign_out"), exact: true });
|
||||||
|
await (path === "/account" ? logout.last() : logout.first()).click();
|
||||||
|
await expect(page).toHaveURL(/\/login\?/);
|
||||||
|
await expect(page.getByRole("button", { name: t("auth:passkey_btn"), exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: t("auth:tab_op_btn"), exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: t("auth:op_start_btn"), exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: t("auth:tab_op_btn"), exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: t("auth:op_start_btn"), exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Chinese authentication settings render in both themes", async ({ page, signIn }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await page.addInitScript(() => localStorage.setItem("felis-lang", "zh-CN"));
|
||||||
|
await signIn("owner");
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
await page.goto("/admin/auth-sources");
|
||||||
|
await expect(page.getByRole("heading", { name: "认证源", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("永久标识")).toHaveValue("littleskin");
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await page.screenshot({ path: "/tmp/felis-auth-sources-zh-dark.png", fullPage: true });
|
||||||
|
await page.getByRole("button", { name: "切换主题", exact: true }).click();
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await page.screenshot({ path: "/tmp/felis-auth-sources-zh-light.png", fullPage: true });
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ for (const [account, path] of [
|
|||||||
["linked", "/account"],
|
["linked", "/account"],
|
||||||
["owner", "/servers"],
|
["owner", "/servers"],
|
||||||
["owner", "/admin/users"],
|
["owner", "/admin/users"],
|
||||||
|
["owner", "/admin/auth-sources"],
|
||||||
["owner", "/servers/survival/luckperms"],
|
["owner", "/servers/survival/luckperms"],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`${path} fits a 375px screen for ${account}`, async ({ page, signIn }) => {
|
test(`${path} fits a 375px screen for ${account}`, async ({ page, signIn }) => {
|
||||||
@@ -61,3 +62,12 @@ test("login customization fits a phone and retains a saved title", async ({ page
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel(t("lobby:bookTitle"))).toHaveValue("Our Network");
|
await expect(page.getByLabel(t("lobby:bookTitle"))).toHaveValue("Our Network");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("authentication source drafts with long IDs fit a phone", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/admin/auth-sources");
|
||||||
|
await page.getByRole("button", { name: t("authSources:add"), exact: true }).click();
|
||||||
|
await page.getByLabel(t("authSources:tag")).nth(1).fill("a".repeat(128));
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
+3
-1
@@ -65,6 +65,7 @@ const LobbyPage = lazyWithReload(() =>
|
|||||||
const UpdatesPage = lazyWithReload(() =>
|
const UpdatesPage = lazyWithReload(() =>
|
||||||
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
|
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
|
||||||
);
|
);
|
||||||
|
const AuthSourcesPage = lazyWithReload(() => import("@/pages/admin/AuthSourcesPage").then((m) => ({ default: m.AuthSourcesPage })));
|
||||||
|
|
||||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||||
// / User-Side — app-tier, every authenticated principal
|
// / User-Side — app-tier, every authenticated principal
|
||||||
@@ -129,8 +130,9 @@ export default function App() {
|
|||||||
<Route path="builds" element={<ImageBuildPage />} />
|
<Route path="builds" element={<ImageBuildPage />} />
|
||||||
<Route path="submissions" element={<SubmissionsPage />} />
|
<Route path="submissions" element={<SubmissionsPage />} />
|
||||||
<Route path="updates" element={<UpdatesPage />} />
|
<Route path="updates" element={<UpdatesPage />} />
|
||||||
{/* Owner-gated: user management (one level above admin). */}
|
{/* Owner-gated platform settings and user management. */}
|
||||||
<Route element={<RequireOwner />}>
|
<Route element={<RequireOwner />}>
|
||||||
|
<Route path="auth-sources" element={<AuthSourcesPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}>
|
<Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}>
|
||||||
<Route index element={<UserDetailPage />} />
|
<Route index element={<UserDetailPage />} />
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import enNavigation from "./resources/en-US/navigation.json";
|
|||||||
import enBackups from "./resources/en-US/backups.json";
|
import enBackups from "./resources/en-US/backups.json";
|
||||||
import enSubmissions from "./resources/en-US/submissions.json";
|
import enSubmissions from "./resources/en-US/submissions.json";
|
||||||
import enFiles from "./resources/en-US/files.json";
|
import enFiles from "./resources/en-US/files.json";
|
||||||
|
import enAuthSources from "./resources/en-US/authSources.json";
|
||||||
|
import zhAuthSources from "./resources/zh-CN/authSources.json";
|
||||||
import enLobby from "./resources/en-US/lobby.json";
|
import enLobby from "./resources/en-US/lobby.json";
|
||||||
import zhLobby from "./resources/zh-CN/lobby.json";
|
import zhLobby from "./resources/zh-CN/lobby.json";
|
||||||
import enSchedules from "./resources/en-US/schedules.json";
|
import enSchedules from "./resources/en-US/schedules.json";
|
||||||
@@ -49,6 +51,7 @@ export const i18nOptions: InitOptions = {
|
|||||||
files: enFiles,
|
files: enFiles,
|
||||||
schedules: enSchedules,
|
schedules: enSchedules,
|
||||||
lobby: enLobby,
|
lobby: enLobby,
|
||||||
|
authSources: enAuthSources,
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
@@ -65,6 +68,7 @@ export const i18nOptions: InitOptions = {
|
|||||||
files: zhFiles,
|
files: zhFiles,
|
||||||
schedules: zhSchedules,
|
schedules: zhSchedules,
|
||||||
lobby: zhLobby,
|
lobby: zhLobby,
|
||||||
|
authSources: zhAuthSources,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"title": "Authentication sources",
|
||||||
|
"subtitle": "Manage Minecraft identity providers and their verification priority.",
|
||||||
|
"builtin_title": "Mojang · built in, checked first",
|
||||||
|
"builtin_hint": "Official accounts keep their original UUIDs. This source is always enabled and cannot be replaced or reordered.",
|
||||||
|
"custom_title": "Third-party sources",
|
||||||
|
"priority_hint": "Enabled sources are checked from top to bottom after Mojang.",
|
||||||
|
"reload": "Reload",
|
||||||
|
"add": "Add source",
|
||||||
|
"new_source": "New source",
|
||||||
|
"loading": "Loading authentication sources…",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"move_up": "Move source up",
|
||||||
|
"move_down": "Move source down",
|
||||||
|
"remove": "Remove unsaved source",
|
||||||
|
"tag": "Permanent source ID",
|
||||||
|
"tag_hint": "A unique ID without colons or surrounding spaces. It becomes permanent when saved.",
|
||||||
|
"tag_locked": "This ID is fixed to preserve player UUIDs and account links.",
|
||||||
|
"prefix": "Name collision prefix",
|
||||||
|
"prefix_hint": "1–4 letters or digits, used when a player has an official account’s name.",
|
||||||
|
"url": "Authentication endpoint (hasJoined)",
|
||||||
|
"url_hint": "Use HTTPS. HTTP is allowed only for localhost or private IPs. Do not include a query or fragment.",
|
||||||
|
"api_url": "Role lookup API root (optional)",
|
||||||
|
"api_hint": "Leave empty for a standard hasJoined URL; otherwise provide the Yggdrasil API root for role lookup.",
|
||||||
|
"test": "Test connection",
|
||||||
|
"testing": "Testing…",
|
||||||
|
"test_hint": "Checks whether the login endpoint rejects an unused session correctly.",
|
||||||
|
"test_ok": "Authentication endpoint passed ({{ms}} ms).",
|
||||||
|
"test_status": "Endpoint returned HTTP {{status}}; expected 204 for an unused session.",
|
||||||
|
"empty": "Only official accounts are enabled. Add a source to accept another provider.",
|
||||||
|
"disable_hint": "Disable a saved source to stop accepting its logins. Its ID stays reserved so identities can be restored later. Launchers must use the same provider.",
|
||||||
|
"invalid": "Check source IDs, unique prefixes and complete HTTP(S) addresses without queries or fragments.",
|
||||||
|
"unsaved": "Unsaved changes · saving applies to the next login and role lookup",
|
||||||
|
"active": "Saved sources are active; no restart is required.",
|
||||||
|
"defaults": "Using installation defaults. Saved panel configuration takes precedence.",
|
||||||
|
"discard": "Discard changes",
|
||||||
|
"save": "Save & apply",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saved": "Saved and active. New logins and role lookups use this configuration; online players stay connected."
|
||||||
|
}
|
||||||
@@ -141,5 +141,8 @@
|
|||||||
"auth_source_unknown": "Select a configured authentication source.",
|
"auth_source_unknown": "Select a configured authentication source.",
|
||||||
"auth_source_unavailable": "The authentication source is unavailable or returned an invalid role. Try again later.",
|
"auth_source_unavailable": "The authentication source is unavailable or returned an invalid role. Try again later.",
|
||||||
"auth_source_lookup_unsupported": "Role lookup is not configured for this authentication source.",
|
"auth_source_lookup_unsupported": "Role lookup is not configured for this authentication source.",
|
||||||
"minecraft_profile_not_found": "No role matched in this source. Check the role name or UUID."
|
"minecraft_profile_not_found": "No role matched in this source. Check the role name or UUID.",
|
||||||
|
"auth_sources_unavailable": "Authentication source management is unavailable.",
|
||||||
|
"auth_sources_changed": "Authentication sources changed. Discard your edits and reload before saving.",
|
||||||
|
"auth_source_tag_locked": "Saved source IDs cannot be renamed or removed. Disable the source instead."
|
||||||
}
|
}
|
||||||
@@ -10,5 +10,6 @@
|
|||||||
"admin_builds": "Build Pipeline",
|
"admin_builds": "Build Pipeline",
|
||||||
"admin_submissions": "Submissions",
|
"admin_submissions": "Submissions",
|
||||||
"admin_updates": "Maintenance & Backups",
|
"admin_updates": "Maintenance & Backups",
|
||||||
"admin_lobby": "Login & lobby"
|
"admin_lobby": "Login & lobby",
|
||||||
|
"auth_sources": "Authentication sources"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"title": "认证源",
|
||||||
|
"subtitle": "管理 Minecraft 身份认证服务及验证顺序。",
|
||||||
|
"builtin_title": "Mojang · 内置,优先验证",
|
||||||
|
"builtin_hint": "正版账号保留原有 UUID。此认证源始终启用,不能替换或调整顺序。",
|
||||||
|
"custom_title": "第三方认证源",
|
||||||
|
"priority_hint": "正版验证之后,按下方顺序查询已启用的认证源。",
|
||||||
|
"reload": "重新读取",
|
||||||
|
"add": "添加认证源",
|
||||||
|
"new_source": "新认证源",
|
||||||
|
"loading": "正在加载认证源…",
|
||||||
|
"enabled": "启用",
|
||||||
|
"move_up": "上移认证源",
|
||||||
|
"move_down": "下移认证源",
|
||||||
|
"remove": "移除未保存的认证源",
|
||||||
|
"tag": "永久标识",
|
||||||
|
"tag_hint": "标识须唯一,不能包含冒号或首尾空格。保存后将固定。",
|
||||||
|
"tag_locked": "此标识已固定,用于保留玩家 UUID 和账号绑定。",
|
||||||
|
"prefix": "重名处理前缀",
|
||||||
|
"prefix_hint": "1–4 位字母或数字。第三方角色与正版角色重名时使用。",
|
||||||
|
"url": "认证接口(hasJoined)",
|
||||||
|
"url_hint": "使用 HTTPS;仅本机或私有 IP 允许 HTTP。地址不能带查询参数或片段。",
|
||||||
|
"api_url": "角色查询 API 根地址(选填)",
|
||||||
|
"api_hint": "标准 hasJoined 地址可留空;使用自定义认证路径时,填写 Yggdrasil API 根地址,以便按角色名或 UUID 查询。",
|
||||||
|
"test": "测试连接",
|
||||||
|
"testing": "正在测试…",
|
||||||
|
"test_hint": "检查认证接口能否正确拒绝一个未登录的会话。",
|
||||||
|
"test_ok": "认证接口检查通过({{ms}} 毫秒)。",
|
||||||
|
"test_status": "接口返回 HTTP {{status}};未登录会话应返回 204,检查未通过。",
|
||||||
|
"empty": "当前仅接受正版 Minecraft 账号。添加认证源后可接入其他认证服务。",
|
||||||
|
"disable_hint": "停用已保存的认证源后,将不再接受该源的登录;标识仍保留,方便之后恢复玩家身份。玩家启动器也须配置同一个认证站。",
|
||||||
|
"invalid": "请检查永久标识、不能重复的重名前缀,以及不含查询参数或片段的完整 HTTP(S) 地址。",
|
||||||
|
"unsaved": "有未保存更改 · 保存后用于下一次登录和角色查询",
|
||||||
|
"active": "已保存的认证源正在生效,无需重启。",
|
||||||
|
"defaults": "当前使用安装配置。保存后将持久保存在面板中,并优先于安装配置。",
|
||||||
|
"discard": "放弃更改",
|
||||||
|
"save": "保存并生效",
|
||||||
|
"saving": "正在保存…",
|
||||||
|
"saved": "已保存并生效。下一次登录和角色查询使用新配置;在线玩家不会断开。"
|
||||||
|
}
|
||||||
@@ -141,5 +141,8 @@
|
|||||||
"auth_source_unknown": "请选择已配置的认证源。",
|
"auth_source_unknown": "请选择已配置的认证源。",
|
||||||
"auth_source_unavailable": "认证源暂时不可用或返回了无效角色,请稍后重试。",
|
"auth_source_unavailable": "认证源暂时不可用或返回了无效角色,请稍后重试。",
|
||||||
"auth_source_lookup_unsupported": "此认证源尚未配置角色查询地址。",
|
"auth_source_lookup_unsupported": "此认证源尚未配置角色查询地址。",
|
||||||
"minecraft_profile_not_found": "在所选认证源中没有找到这个角色,请检查角色名或 UUID。"
|
"minecraft_profile_not_found": "在所选认证源中没有找到这个角色,请检查角色名或 UUID。",
|
||||||
|
"auth_sources_unavailable": "认证源管理暂不可用。",
|
||||||
|
"auth_sources_changed": "认证源已被其他操作修改。请放弃当前更改并重新读取后再保存。",
|
||||||
|
"auth_source_tag_locked": "已保存的认证源标识不能改名或移除,请使用停用。"
|
||||||
}
|
}
|
||||||
@@ -10,5 +10,6 @@
|
|||||||
"admin_builds": "构建流水线",
|
"admin_builds": "构建流水线",
|
||||||
"admin_submissions": "审核提交",
|
"admin_submissions": "审核提交",
|
||||||
"admin_updates": "维护与备份",
|
"admin_updates": "维护与备份",
|
||||||
"admin_lobby": "登录与大厅"
|
"admin_lobby": "登录与大厅",
|
||||||
|
"auth_sources": "认证源"
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
KickResult,
|
KickResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
MinecraftAuthSource,
|
MinecraftAuthSource,
|
||||||
|
AuthSourceConfig,
|
||||||
|
AuthSourcesSettings,
|
||||||
MinecraftProfile,
|
MinecraftProfile,
|
||||||
LinkStatus,
|
LinkStatus,
|
||||||
BindResult,
|
BindResult,
|
||||||
@@ -950,6 +952,10 @@ export const api = rejectingSync({
|
|||||||
|
|
||||||
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
||||||
|
|
||||||
|
getAuthSources: () => request<AuthSourcesSettings>("GET", "/settings/auth-sources"),
|
||||||
|
setAuthSources: (sources: AuthSourceConfig[], revision: string) => request<AuthSourcesSettings>("PUT", "/settings/auth-sources", { sources, revision }),
|
||||||
|
testAuthSource: (source: AuthSourceConfig) => request<{ ok: boolean; status: number; elapsed_ms: number }>("POST", "/settings/auth-sources/test", source),
|
||||||
|
|
||||||
lookupProfile: (source: string, profile: string) =>
|
lookupProfile: (source: string, profile: string) =>
|
||||||
request<MinecraftProfile>("GET", `/account/link/profile?${new URLSearchParams({ source, profile })}`),
|
request<MinecraftProfile>("GET", `/account/link/profile?${new URLSearchParams({ source, profile })}`),
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Upload,
|
Upload,
|
||||||
Clock,
|
Clock,
|
||||||
|
ShieldCheck,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
ownerOnly: true,
|
ownerOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/admin/users", key: "admin_users", icon: Users },
|
{ to: "/admin/users", key: "admin_users", icon: Users },
|
||||||
|
{ to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1243,6 +1243,47 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/settings/auth-sources": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Read authentication sources (Owner). */
|
||||||
|
get: operations["getAuthSources"];
|
||||||
|
/**
|
||||||
|
* Save authentication sources (Owner, fresh reauthentication).
|
||||||
|
* @description Atomically persists an override in platform_settings. It applies to the next login and role lookup on every API replica without restarting; existing players stay online. Tags identify permanent UUID namespaces; retain every saved tag and disable unwanted sources. Mojang remains built-in and trusted, while configured sources always remain third-party. Nano remains TOML-only.
|
||||||
|
*/
|
||||||
|
put: operations["setAuthSources"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/settings/auth-sources/test": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Probe a hasJoined endpoint (Owner).
|
||||||
|
* @description Checks an unsaved source with a fresh random serverId. A healthy endpoint returns 204 for a session that never joined. Uses a five-second timeout, verified TLS and no redirects. This tests connectivity and hasJoined behavior, not launcher login or profile lookup. Does not save configuration.
|
||||||
|
*/
|
||||||
|
post: operations["testAuthSource"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/updates/window": {
|
"/api/v1/updates/window": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2863,6 +2904,25 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
AuthSourceConfig: {
|
||||||
|
/** @description Permanent UUID namespace; saved tags cannot be renamed or removed. mojang is reserved. */
|
||||||
|
tag: string;
|
||||||
|
/** @description Unique case-insensitive display prefix. */
|
||||||
|
prefix: string;
|
||||||
|
/** @description hasJoined endpoint; HTTPS required except for localhost or private literal IP addresses. No query or fragment. */
|
||||||
|
url: string;
|
||||||
|
/** @description Optional Yggdrasil API base for role lookup; empty infers it from the standard hasJoined suffix. */
|
||||||
|
api_url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
AuthSourcesSettings: {
|
||||||
|
/** @description Third-party sources in priority order; built-in Mojang always precedes them and is immutable. */
|
||||||
|
sources: components["schemas"]["AuthSourceConfig"][];
|
||||||
|
/** @description Opaque revision to send unchanged when saving; stale or concurrent writes return 409. */
|
||||||
|
revision: string;
|
||||||
|
/** @description True when stored in platform_settings; false while using installation TOML defaults. */
|
||||||
|
managed: boolean;
|
||||||
|
};
|
||||||
/** @description Uniform error envelope emitted by every handler (internal/api/errors.go). */
|
/** @description Uniform error envelope emitted by every handler (internal/api/errors.go). */
|
||||||
Error: {
|
Error: {
|
||||||
error: {
|
error: {
|
||||||
@@ -6669,6 +6729,102 @@ export interface operations {
|
|||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getAuthSources: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Current third-party sources and their revision. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AuthSourcesSettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
setAuthSources: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
sources: components["schemas"]["AuthSourceConfig"][];
|
||||||
|
revision: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Saved configuration and new revision. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AuthSourcesSettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
/** @description auth_sources_changed or auth_source_tag_locked; reload instead of overwriting another Owner's changes. */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
testAuthSource: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AuthSourceConfig"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Probe result; non-204 status has ok=false. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
elapsed_ms: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getUpdateWindow: {
|
getUpdateWindow: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -362,6 +362,20 @@ export interface MinecraftAuthSource {
|
|||||||
lookup_available: boolean;
|
lookup_available: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthSourceConfig {
|
||||||
|
tag: string;
|
||||||
|
prefix: string;
|
||||||
|
url: string;
|
||||||
|
api_url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSourcesSettings {
|
||||||
|
sources: AuthSourceConfig[];
|
||||||
|
revision: string;
|
||||||
|
managed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MinecraftProfile {
|
export interface MinecraftProfile {
|
||||||
source: string;
|
source: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Login", () => {
|
describe("Login", () => {
|
||||||
|
it("starts with login methods on the operator host after signing out", async () => {
|
||||||
|
config.value.adminHostname = window.location.hostname;
|
||||||
|
await renderLogin();
|
||||||
|
expect(screen.getByRole("button", { name: t("auth:passkey_btn") })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: t("auth:tab_op_btn") })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: t("auth:op_start_btn") })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("reads out why the code could not be sent", async () => {
|
it("reads out why the code could not be sent", async () => {
|
||||||
calls.authEmailStart.mockRejectedValue({ status: 429, code: "otp_resend_cooldown", message: "" });
|
calls.authEmailStart.mockRejectedValue({ status: 429, code: "otp_resend_cooldown", message: "" });
|
||||||
await renderLogin();
|
await renderLogin();
|
||||||
|
|||||||
@@ -68,14 +68,13 @@ 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 operator hostname changes the copy, while every visit starts with the
|
||||||
// (the player doors refuse staff accounts anyway).
|
// login methods (including passkeys). Staff explicitly choose vouched login.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadConfig().then((cfg) => {
|
void loadConfig().then((cfg) => {
|
||||||
setJoinAddr(entryAddress(cfg));
|
setJoinAddr(entryAddress(cfg));
|
||||||
if (cfg.adminHostname && window.location.hostname === cfg.adminHostname) {
|
if (cfg.adminHostname && window.location.hostname === cfg.adminHostname) {
|
||||||
setIsOpHost(true);
|
setIsOpHost(true);
|
||||||
setActiveTab("op");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { AuthSourcesPage } from "./AuthSourcesPage";
|
||||||
|
import type { AuthSourceConfig, AuthSourcesSettings } from "@/lib/types";
|
||||||
|
|
||||||
|
const calls = vi.hoisted(() => ({ getAuthSources: vi.fn(), setAuthSources: vi.fn(), testAuthSource: vi.fn() }));
|
||||||
|
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
|
||||||
|
const source = (tag = "littleskin", prefix = "LS"): AuthSourceConfig => ({ tag, prefix, url: "https://example.org/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true });
|
||||||
|
const settings = (sources = [source()]): AuthSourcesSettings => ({ sources, revision: "original", managed: false });
|
||||||
|
function page() { render(<MemoryRouter><AuthSourcesPage /></MemoryRouter>); }
|
||||||
|
const button = (name: string) => screen.getByRole("button", { name });
|
||||||
|
const field = (label: string, index = 0) => screen.getAllByLabelText(label)[index] as HTMLInputElement;
|
||||||
|
async function ready() { await waitFor(() => expect(field("Permanent source ID").disabled).toBe(false)); }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
calls.getAuthSources.mockResolvedValue(settings());
|
||||||
|
calls.setAuthSources.mockImplementation(async (sources) => ({ sources, revision: "saved", managed: true }));
|
||||||
|
calls.testAuthSource.mockResolvedValue({ ok: true, status: 204, elapsed_ms: 18 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AuthSourcesPage", () => {
|
||||||
|
it("shows disabled fields while loading, then protects saved IDs", async () => {
|
||||||
|
let resolve!: (settings: AuthSourcesSettings) => void;
|
||||||
|
calls.getAuthSources.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||||
|
page();
|
||||||
|
expect(field("Permanent source ID").disabled).toBe(true);
|
||||||
|
expect(field("Authentication endpoint (hasJoined)").value).toBe("");
|
||||||
|
expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect(screen.getByRole("status").textContent).toContain("Loading authentication sources");
|
||||||
|
await act(async () => resolve(settings()));
|
||||||
|
await ready();
|
||||||
|
expect(field("Permanent source ID").readOnly).toBe(true);
|
||||||
|
expect(screen.queryByRole("button", { name: "Remove unsaved source" })).toBeNull();
|
||||||
|
});
|
||||||
|
it("adds a source, fixes a duplicate ID, saves and locks it without sending UI state", async () => {
|
||||||
|
page(); await ready();
|
||||||
|
await userEvent.click(button("Add source"));
|
||||||
|
fireEvent.change(field("Permanent source ID", 1), { target: { value: "littleskin" } });
|
||||||
|
expect(field("Permanent source ID", 1).readOnly).toBe(false);
|
||||||
|
expect(button("Remove unsaved source")).toBeTruthy();
|
||||||
|
fireEvent.change(field("Permanent source ID", 1), { target: { value: "custom" } });
|
||||||
|
fireEvent.change(field("Name collision prefix", 1), { target: { value: "CS" } });
|
||||||
|
fireEvent.change(field("Authentication endpoint (hasJoined)", 1), { target: { value: "https://custom.example/check" } });
|
||||||
|
fireEvent.change(field("Role lookup API root (optional)", 1), { target: { value: "https://custom.example" } });
|
||||||
|
await userEvent.click(button("Save & apply"));
|
||||||
|
await screen.findByText("Saved and active. New logins and role lookups use this configuration; online players stay connected.");
|
||||||
|
expect(calls.setAuthSources).toHaveBeenCalledWith([source(), { tag: "custom", prefix: "CS", url: "https://custom.example/check", api_url: "https://custom.example", enabled: true }], "original");
|
||||||
|
expect(field("Permanent source ID", 1).readOnly).toBe(true);
|
||||||
|
expect(screen.queryByRole("button", { name: "Remove unsaved source" })).toBeNull();
|
||||||
|
expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
it("reorders and disables a saved source, while discard restores its original fields and lock", async () => {
|
||||||
|
calls.getAuthSources.mockResolvedValue(settings([source(), source("custom", "CS")]));
|
||||||
|
page(); await ready();
|
||||||
|
await userEvent.click(screen.getAllByRole("button", { name: "Move source up" })[1]);
|
||||||
|
expect(field("Permanent source ID").value).toBe("custom");
|
||||||
|
await userEvent.click(screen.getAllByRole("checkbox", { name: "Enabled" })[0]);
|
||||||
|
await userEvent.click(button("Save & apply"));
|
||||||
|
await waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source("custom", "CS"), enabled: false }, source()], "original"));
|
||||||
|
await waitFor(() => expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true));
|
||||||
|
fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } });
|
||||||
|
await userEvent.click(button("Discard changes"));
|
||||||
|
expect(field("Name collision prefix").value).toBe("CS");
|
||||||
|
expect(field("Permanent source ID").readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
it("preserves a draft on conflict and lets the Owner discard then reload", async () => {
|
||||||
|
calls.setAuthSources.mockRejectedValue({ status: 409, code: "auth_sources_changed" });
|
||||||
|
page(); await ready();
|
||||||
|
fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } });
|
||||||
|
await userEvent.click(button("Save & apply"));
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
expect(field("Name collision prefix").value).toBe("NEW");
|
||||||
|
expect((button("Reload") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
calls.getAuthSources.mockResolvedValue({ ...settings(), sources: [source("littleskin", "UP")], revision: "newest", managed: true });
|
||||||
|
await userEvent.click(button("Discard changes"));
|
||||||
|
await userEvent.click(button("Reload"));
|
||||||
|
await waitFor(() => expect(field("Name collision prefix").value).toBe("UP"));
|
||||||
|
});
|
||||||
|
it("tests unsaved endpoints without saving, reports unexpected status and clears stale results on edit", async () => {
|
||||||
|
page(); await ready();
|
||||||
|
fireEvent.change(field("Authentication endpoint (hasJoined)"), { target: { value: "https://new.example/check" } });
|
||||||
|
calls.testAuthSource.mockResolvedValueOnce({ ok: false, status: 200, elapsed_ms: 10 });
|
||||||
|
await userEvent.click(button("Test connection"));
|
||||||
|
await screen.findByText("Endpoint returned HTTP 200; expected 204 for an unused session.");
|
||||||
|
expect(calls.testAuthSource).toHaveBeenLastCalledWith({ ...source(), url: "https://new.example/check" });
|
||||||
|
expect(calls.setAuthSources).not.toHaveBeenCalled();
|
||||||
|
fireEvent.change(field("Authentication endpoint (hasJoined)"), { target: { value: "https://valid.example/check" } });
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
await userEvent.click(button("Test connection"));
|
||||||
|
await screen.findByText("Authentication endpoint passed (18 ms).");
|
||||||
|
});
|
||||||
|
it("retries a failed initial load with the fields still visible and disabled", async () => {
|
||||||
|
calls.getAuthSources.mockRejectedValueOnce({ status: 503, code: "auth_sources_unavailable" });
|
||||||
|
page(); await screen.findByRole("alert");
|
||||||
|
expect(field("Permanent source ID").disabled).toBe(true);
|
||||||
|
await userEvent.click(button("Try again"));
|
||||||
|
await ready();
|
||||||
|
expect(field("Permanent source ID").value).toBe("littleskin");
|
||||||
|
});
|
||||||
|
it("supports installations with only the built-in source", async () => {
|
||||||
|
calls.getAuthSources.mockResolvedValue(settings([]));
|
||||||
|
page(); await screen.findByText("Only official accounts are enabled. Add a source to accept another provider.");
|
||||||
|
expect(screen.queryByLabelText("Permanent source ID")).toBeNull();
|
||||||
|
await userEvent.click(button("Add source"));
|
||||||
|
expect(field("Permanent source ID").readOnly).toBe(false);
|
||||||
|
expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
await userEvent.click(button("Remove unsaved source"));
|
||||||
|
expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ArrowDown, ArrowUp, Check, Loader2, Plus, RefreshCw, Save, ShieldCheck, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||||
|
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
import { useAsync, useUnsavedGuard } from "@/lib/hooks";
|
||||||
|
import type { AuthSourceConfig, AuthSourcesSettings } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const emptySource = (): AuthSourceConfig => ({ tag: "", prefix: "", url: "", api_url: "", enabled: true });
|
||||||
|
|
||||||
|
function validSource(source: AuthSourceConfig): boolean {
|
||||||
|
const endpoint = (value: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return value.trim() === value && ["http:", "https:"].includes(url.protocol) && !!url.hostname && !/[?#]/.test(value);
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
return !!source.tag && source.tag.trim() === source.tag && !source.tag.includes(":") && source.tag.toLowerCase() !== "mojang" &&
|
||||||
|
/^[a-z0-9]{1,4}$/i.test(source.prefix) && endpoint(source.url) && (!source.api_url || endpoint(source.api_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceDraft = AuthSourceConfig & { locked?: boolean };
|
||||||
|
type Draft = Omit<AuthSourcesSettings, "sources"> & { sources: SourceDraft[]; original: string };
|
||||||
|
const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, ...source }) => source);
|
||||||
|
const loadedDraft = (settings: AuthSourcesSettings): Draft => ({ ...settings, sources: settings.sources.map((source) => ({ ...source, locked: true })), original: JSON.stringify(settings.sources) });
|
||||||
|
|
||||||
|
export function AuthSourcesPage() {
|
||||||
|
const { t } = useTranslation("authSources");
|
||||||
|
const query = useAsync(api.getAuthSources, []);
|
||||||
|
const reauth = useReauth();
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState<number | null>(null);
|
||||||
|
const [checks, setChecks] = useState<Record<number, { kind: "success" | "error"; text: string }>>({});
|
||||||
|
const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.data) setDraft((current) => current && JSON.stringify(wireSources(current.sources)) !== current.original ? current : loadedDraft(query.data!));
|
||||||
|
}, [query.data]);
|
||||||
|
const dirty = draft !== null && JSON.stringify(wireSources(draft.sources)) !== draft.original;
|
||||||
|
useUnsavedGuard(dirty);
|
||||||
|
const busy = saving || testing !== null || query.loading;
|
||||||
|
const valid = draft !== null && draft.sources.every(validSource) &&
|
||||||
|
new Set(draft.sources.map((s) => s.tag)).size === draft.sources.length &&
|
||||||
|
new Set(draft.sources.map((s) => s.prefix.toLowerCase())).size === draft.sources.length;
|
||||||
|
|
||||||
|
function edit(sources: SourceDraft[]) {
|
||||||
|
setDraft((current) => current && { ...current, sources });
|
||||||
|
setChecks({});
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard() {
|
||||||
|
if (!draft) return;
|
||||||
|
setDraft(loadedDraft({ ...draft, sources: JSON.parse(draft.original) as AuthSourceConfig[] }));
|
||||||
|
setChecks({});
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!draft || !dirty || !valid || busy) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const saved = await reauth.guard(() => api.setAuthSources(wireSources(draft.sources), draft.revision));
|
||||||
|
setDraft(loadedDraft(saved));
|
||||||
|
setMessage({ kind: "success", text: t("saved") });
|
||||||
|
} catch (error) {
|
||||||
|
if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) });
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSource(index: number) {
|
||||||
|
if (!draft || busy || !validSource(draft.sources[index])) return;
|
||||||
|
setTesting(index);
|
||||||
|
try {
|
||||||
|
const result = await api.testAuthSource(wireSources(draft.sources)[index]);
|
||||||
|
setChecks((current) => ({ ...current, [index]: { kind: result.ok ? "success" : "error", text: result.ok ? t("test_ok", { ms: result.elapsed_ms }) : t("test_status", { status: result.status }) } }));
|
||||||
|
} catch (error) {
|
||||||
|
setChecks((current) => ({ ...current, [index]: { kind: "error", text: humanizeError(error) } }));
|
||||||
|
} finally { setTesting(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="mx-auto w-full max-w-4xl space-y-5">
|
||||||
|
<PageHeader icon={ShieldCheck} title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
<Card className="rounded-xl shadow-none"><CardContent className="flex items-start gap-3 pt-5">
|
||||||
|
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
|
||||||
|
<div className="space-y-1"><p className="text-sm font-semibold">{t("builtin_title")}</p><p className="text-xs leading-relaxed text-muted-foreground">{t("builtin_hint")}</p></div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div><h2 className="text-sm font-semibold">{t("custom_title")}</h2><p className="mt-1 text-xs text-muted-foreground">{t("priority_hint")}</p></div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={!draft || dirty || busy || query.loading} onClick={query.reload}><RefreshCw className={cn(query.loading && "animate-spin")} />{t("reload")}</Button>
|
||||||
|
<Button size="sm" disabled={!draft || busy || draft.sources.length >= 32} onClick={() => draft && edit([...draft.sources, emptySource()])}><Plus />{t("add")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("loading")}</p>}
|
||||||
|
{query.error != null && <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3"><InlineError message={humanizeError(query.error)} /><Button variant="outline" size="sm" disabled={dirty || busy} onClick={query.reload}>{t("common:try_again")}</Button></div>}
|
||||||
|
<div aria-busy={query.loading} className="space-y-4">
|
||||||
|
{(draft?.sources ?? [emptySource()]).map((source, index) => {
|
||||||
|
const locked = "locked" in source && source.locked === true;
|
||||||
|
const set = (patch: Partial<AuthSourceConfig>) => draft && edit(draft.sources.map((s, i) => i === index ? { ...s, ...patch } : s));
|
||||||
|
const move = (offset: number) => {
|
||||||
|
if (!draft) return;
|
||||||
|
const sources = [...draft.sources];
|
||||||
|
[sources[index], sources[index + offset]] = [sources[index + offset], sources[index]];
|
||||||
|
edit(sources);
|
||||||
|
};
|
||||||
|
return <Card key={index} className="overflow-hidden rounded-xl shadow-none">
|
||||||
|
<CardHeader className="flex-row flex-wrap items-center justify-between gap-3 space-y-0 border-b border-border/60 bg-muted/20 py-4">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">{index + 2}</span><CardTitle className="break-all text-sm">{source.tag || t(draft ? "new_source" : "loading")}</CardTitle></div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={draft !== null && source.enabled} onChange={(e) => set({ enabled: e.target.checked })} disabled={!draft || busy} className="h-4 w-4 accent-primary" />{t("enabled")}</label>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("move_up")} disabled={!draft || busy || index === 0} onClick={() => move(-1)}><ArrowUp /></Button>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("move_down")} disabled={!draft || busy || index === draft.sources.length - 1} onClick={() => move(1)}><ArrowDown /></Button>
|
||||||
|
{!locked && <Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("remove")} disabled={!draft || busy} onClick={() => draft && edit(draft.sources.filter((_, i) => i !== index))}><Trash2 /></Button>}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pt-5">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2"><Label htmlFor={`source-tag-${index}`}>{t("tag")}</Label><Input id={`source-tag-${index}`} value={source.tag} readOnly={locked} disabled={!draft || busy} placeholder="littleskin" maxLength={128} onChange={(e) => set({ tag: e.target.value })} /><p className="text-xs text-muted-foreground">{t(locked ? "tag_locked" : "tag_hint")}</p></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor={`source-prefix-${index}`}>{t("prefix")}</Label><Input id={`source-prefix-${index}`} value={source.prefix} disabled={!draft || busy} placeholder="LS" maxLength={4} onChange={(e) => set({ prefix: e.target.value })} /><p className="text-xs text-muted-foreground">{t("prefix_hint")}</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2"><Label htmlFor={`source-url-${index}`}>{t("url")}</Label><Input id={`source-url-${index}`} type="url" value={source.url} disabled={!draft || busy} placeholder="https://example.org/api/yggdrasil/sessionserver/session/minecraft/hasJoined" onChange={(e) => set({ url: e.target.value })} /><p className="text-xs text-muted-foreground">{t("url_hint")}</p></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor={`source-api-${index}`}>{t("api_url")}</Label><Input id={`source-api-${index}`} type="url" value={source.api_url} disabled={!draft || busy} placeholder="https://example.org/api/yggdrasil" onChange={(e) => set({ api_url: e.target.value })} /><p className="text-xs text-muted-foreground">{t("api_hint")}</p></div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3"><Button variant="outline" size="sm" disabled={!draft || busy || !validSource(source)} onClick={() => void testSource(index)}>{testing === index ? <Loader2 className="animate-spin" /> : <Check />}{t(testing === index ? "testing" : "test")}</Button><p className="text-xs text-muted-foreground">{t("test_hint")}</p></div>
|
||||||
|
{checks[index] && <MessageLine kind={checks[index].kind} message={checks[index].text} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>;
|
||||||
|
})}
|
||||||
|
{draft?.sources.length === 0 && <Card className="border-dashed shadow-none"><CardContent className="py-8 text-center text-sm text-muted-foreground">{t("empty")}</CardContent></Card>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">{t("disable_hint")}</p>
|
||||||
|
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||||
|
{draft && !valid && <InlineError message={t("invalid")} />}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{draft ? t(dirty ? "unsaved" : draft.managed ? "active" : "defaults") : query.loading ? t("loading") : ""}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{dirty && <Button variant="outline" disabled={busy} onClick={discard}>{t("discard")}</Button>}
|
||||||
|
<Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t(saving ? "saving" : "save")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{reauth.dialog}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user