mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)
Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.
Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)
Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)
Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.
describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)
Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.
The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TASK-3866 done-when: a key made read-only through the same request the
|
||||
// web settings form sends can read and cannot write, both over REST and
|
||||
// over remote MCP. The MCP leg runs through wireMCP, the production
|
||||
// wiring, so the catalog, transport and dispatcher are the shipped ones.
|
||||
func TestTASK3866_ReadOnlyKeyRefusesWritesOverRESTAndMCP(t *testing.T) {
|
||||
f := newCapFixture(t, cfgHTTP, false, nil, "true")
|
||||
if err := f.store.SeedCollectionsFromTemplate(f.ws.ID, "startup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := f.store.CreateSession(f.member.ID, "web", "192.0.2.10", "", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
do := func(method, path, bearer string, body any, header map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Host = f.host
|
||||
req.RemoteAddr = "192.0.2.10:4242"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rr := newMCPRecorder()
|
||||
f.srv.ServeHTTP(rr, req)
|
||||
return rr.ResponseRecorder
|
||||
}
|
||||
mint := func(name, scopes string) string {
|
||||
t.Helper()
|
||||
rr := do("POST", "/api/v1/auth/tokens", session, map[string]any{"name": name, "scopes": scopes}, nil)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("mint %s: %d %s", name, rr.Code, rr.Body.String())
|
||||
}
|
||||
var tok struct {
|
||||
Token string `json:"token"`
|
||||
Scopes string `json:"scopes"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &tok); err != nil || tok.Token == "" {
|
||||
t.Fatalf("mint %s: %s", name, rr.Body.String())
|
||||
}
|
||||
if tok.Scopes != scopes {
|
||||
t.Fatalf("mint %s: stored %q, want %q", name, tok.Scopes, scopes)
|
||||
}
|
||||
return tok.Token
|
||||
}
|
||||
readKey := mint("bot read", `["read"]`)
|
||||
writeKey := mint("bot write", `["*"]`)
|
||||
|
||||
itemsPath := "/api/v1/workspaces/" + f.ws.Slug + "/collections/tasks/items"
|
||||
countTitled := func(title string) int {
|
||||
t.Helper()
|
||||
rr := do("GET", itemsPath, writeKey, nil, nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
return strings.Count(rr.Body.String(), `"title":`+mustJSON(t, title))
|
||||
}
|
||||
|
||||
t.Run("REST", func(t *testing.T) {
|
||||
if rr := do("GET", itemsPath, readKey, nil, nil); rr.Code != http.StatusOK {
|
||||
t.Errorf("read key, GET items: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rr := do("POST", itemsPath, readKey, map[string]any{"title": "rest by read key"}, nil)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("read key, POST item: %d %s, want 403", rr.Code, rr.Body.String())
|
||||
}
|
||||
if n := countTitled("rest by read key"); n != 0 {
|
||||
t.Errorf("the refused REST write created %d items", n)
|
||||
}
|
||||
if rr := do("POST", itemsPath, writeKey, map[string]any{"title": "rest by write key"}, nil); rr.Code != http.StatusCreated {
|
||||
t.Errorf("write key, POST item: %d %s, want 201", rr.Code, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP", func(t *testing.T) {
|
||||
const proto = "2025-11-25"
|
||||
mcpCall := func(key string, id int, method string, params map[string]any) string {
|
||||
t.Helper()
|
||||
body := map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}
|
||||
rr := do("POST", "/mcp", key, body, map[string]string{
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version": proto,
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("%s: %d %s", method, rr.Code, rr.Body.String())
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
tool := func(key, action string, args map[string]any) string {
|
||||
t.Helper()
|
||||
args["action"] = action
|
||||
args["workspace"] = f.ws.Slug
|
||||
mcpCall(key, 1, "initialize", map[string]any{
|
||||
"protocolVersion": proto, "capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "task3866", "version": "1"},
|
||||
})
|
||||
return mcpCall(key, 2, "tools/call", map[string]any{"name": "pad_item", "arguments": args})
|
||||
}
|
||||
|
||||
if out := tool(readKey, "list", map[string]any{"collection": "tasks"}); strings.Contains(out, `"isError":true`) {
|
||||
t.Errorf("read key, pad_item list: %s", out)
|
||||
}
|
||||
out := tool(readKey, "create", map[string]any{"collection": "tasks", "title": "mcp by read key"})
|
||||
if !strings.Contains(out, `"isError":true`) || !strings.Contains(out, "permission_denied") {
|
||||
t.Errorf("read key, pad_item create: want an isError permission_denied result, got %s", out)
|
||||
}
|
||||
if n := countTitled("mcp by read key"); n != 0 {
|
||||
t.Errorf("the refused MCP write created %d items", n)
|
||||
}
|
||||
if out := tool(writeKey, "create", map[string]any{"collection": "tasks", "title": "mcp by write key"}); strings.Contains(out, `"isError":true`) {
|
||||
t.Errorf("write key, pad_item create: %s", out)
|
||||
}
|
||||
if n := countTitled("mcp by write key"); n != 1 {
|
||||
t.Errorf("write key over MCP created %d items, want 1", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -121,6 +121,7 @@ One browser tab's content writes to an item are ordered by the server, also **pe
|
||||
- `POST /api/v1/auth/cli/sessions`, `GET /api/v1/auth/cli/sessions/{code}`, `POST /api/v1/auth/cli/sessions/{code}/approve`, `POST /api/v1/auth/cli/sessions/{code}/deny` — browser sign-in for `pad auth login`: the CLI creates a session and polls it, and a signed-in user approves it at `/auth/cli/{code}`, which mints a 30-day session the poll hands to the CLI. Since TASK-2253 the session records `requester_ip` (the server's view of the creating client) and `requester_user_agent` (what the creator sent, capped at 200 characters, so requester-controlled text), and a PENDING poll returns them with `created_at` (additive, omitempty) for the approval page, which renders the agent as text labelled "reported by the requester". `deny` checks no account, because the code is the authority (it is already a bearer secret: whoever holds it can poll and collect an approval's token); it moves pending → denied, answers 200 again on a denied session, and 409 `already_approved` / 410 `expired` otherwise. A denied session POLLS as **410 `cli_auth_denied`**, not as a 200 status: a CLI from before Deny has no case for an unknown status and would poll it for 20 minutes, whereas a non-2xx counts as a poll error there and it stops after five in a row, printing the message; a current CLI stops on the first. Approving a denied session answers 409 `cli_auth_denied`, and an approval that loses to a concurrent deny ends the session it minted
|
||||
- `POST /api/v1/auth/local-reset` — localhost-only account recovery (self-host, non-cloud). Loopback-gated, no auth — the bootstrap trust model. Returns a single-use reset link, or a temp password with `{"temp_password": true}`. Backs `pad auth reset-password`.
|
||||
- `GET/POST/DELETE /api/v1/auth/tokens` — user-scoped API tokens. **Minting and rotating require an INTERACTIVE SESSION** (BUG-2890): a call authenticated by a PAT is refused `403 session_required` on `POST /auth/tokens`, `POST /auth/tokens/{id}/rotate` and `POST /workspaces/{ws}/tokens`, because a token that can mint tokens outlives its own revocation. A session cookie and a `padsess_` CLI bearer both count as interactive; LIST and REVOKE stay PAT-reachable, deliberately — revocation is the compromised-credential response
|
||||
- **`scopes` on create** (TASK-3866), on both `POST /auth/tokens` and `POST /workspaces/{ws}/tokens`: a STRING holding a JSON array of `*`, `read`, `write`, `pad:read`, `pad:write`, `pad:admin` (exactly what the request-time check `tokenScopeAllows` recognizes). It is stored compact (`[ "read" ]` → `["read"]`). Only an omitted field keeps the default `["*"]`. Refused `400 validation_error`, with the message naming the bad value: a `scopes` field that is JSON `null` or not a string (an explicit null used to read as omitted and mint a full-access key), the empty string and the empty array (`""` and `[]` are the legacy unrestricted forms, and no new key is minted in them; omit `scopes` instead), a string that is not a JSON array of strings (including `"null"`), and any value outside the vocabulary. These used to be stored verbatim, minting a key that every request then refused. There are two levels: `read`/`pad:read` allow GET, HEAD and OPTIONS (plus the playbook `run` POST), and every other recognized value allows everything the user's workspace role allows. `pad:admin` grants nothing beyond `write` for an API key. The web settings form offers "Read and write" (`["*"]`) and "Read only" (`["read"]`), and the token list labels each key from `web/src/lib/settings/tokenScopes.ts`, which is held to the server by `internal/server/testdata/token_scope_levels.json`.
|
||||
- `GET/PATCH /api/v1/admin/settings` — platform settings (admin-only)
|
||||
- `GET/PUT /api/v1/admin/mcp` — MCP over HTTP (PLAN-2310, admin-only). GET returns `{enabled, source: setting|environment|cloud, locked, readiness}`, where readiness is the resolved origin, MCP URL and issuer, `https`, `auth_methods` (`["oauth","pat"]` on https, `["pat"]` on http, `[]` with no usable origin), `state` (`on` / `off` / `blocked` with the reason) and `resume` counts (OAuth connections and PATs that work again when it is turned on: turning it off revokes nothing). PUT `{enabled}` stores `platform_settings.mcp_enabled` (read per request, no restart); `409 set_by_environment` when `PAD_MCP_ENABLED` forces it, `403 managed_by_operator` on Pad Cloud. MCP is available when cloud, or the setting is on with a usable origin (`PAD_URL` / toml `url` / `--url`, else `PUBLIC_URL`; no `http://host:port` fallback); the MCP URL is `PAD_MCP_PUBLIC_URL` else `<origin>/mcp`, the issuer `PAD_AUTH_SERVER_URL` else the origin, all startup-time. Operator docs: `docs/deployment.md` "MCP for agents (self-hosted)"
|
||||
- `GET/PUT /api/v1/admin/decision-provider` — decision provider setting (admin-only, TASK-3121): enable toggle, model pin, and a WRITE-ONLY API key stored encrypted (the response carries `api_key_set`, never the key or a mask of it). Resolved config file < this setting < environment, per field; a PUT to a field the environment sets is refused `409 set_by_environment`. A change rebuilds the provider in place, with no restart. On Pad Cloud the environment is the only source and PUT is `403 managed_by_operator`. `pad server info` resolves only the config file and environment on the CLI host, and says so
|
||||
|
||||
@@ -2,9 +2,12 @@ package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -85,16 +88,20 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var input models.APITokenCreate
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
var req apiTokenCreateRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
input := req.APITokenCreate
|
||||
|
||||
if input.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "name is required")
|
||||
return
|
||||
}
|
||||
if !normalizeTokenScopesInput(w, &input, req.RawScopes) {
|
||||
return
|
||||
}
|
||||
|
||||
input.WorkspaceID = workspaceID
|
||||
defaultDays, maxDays := s.getTokenExpirySettings()
|
||||
@@ -206,16 +213,20 @@ func (s *Server) handleCreateUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var input models.APITokenCreate
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
var req apiTokenCreateRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
input := req.APITokenCreate
|
||||
|
||||
if input.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "name is required")
|
||||
return
|
||||
}
|
||||
if !normalizeTokenScopesInput(w, &input, req.RawScopes) {
|
||||
return
|
||||
}
|
||||
|
||||
// A token may be pinned only to a live workspace the caller belongs to
|
||||
// (BUG-3346). Anything else is the workspace 404, identical for a
|
||||
@@ -346,3 +357,76 @@ func setTokenExpiryWarning(w http.ResponseWriter, token *models.APIToken) {
|
||||
w.Header().Set("X-Token-Expires-At", token.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// apiTokenScopeVocabulary is every scope value tokenScopeAllows recognizes
|
||||
// (TASK-3866). A key is minted only with these: anything else is stored
|
||||
// as written and then refused on every request, so a create that names
|
||||
// one would hand back a key that can do nothing.
|
||||
var apiTokenScopeVocabulary = []string{"*", "read", "write", "pad:read", "pad:write", "pad:admin"}
|
||||
|
||||
// apiTokenCreateRequest is a create body with scopes kept raw, so an
|
||||
// explicit "scopes": null can be told apart from an omitted field (Codex
|
||||
// r1 on TASK-3866). Decoded into models.APITokenCreate alone, null and
|
||||
// absent both arrive as "" and both would mint a full-access key; the
|
||||
// shallower RawScopes field takes the "scopes" key from the embedded one.
|
||||
type apiTokenCreateRequest struct {
|
||||
models.APITokenCreate
|
||||
RawScopes json.RawMessage `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// normalizeTokenScopesInput checks a create request's scopes and rewrites
|
||||
// them to their compact JSON form, writing a 400 validation_error and
|
||||
// returning false when they cannot mint a working key. raw is the request's
|
||||
// "scopes" value as sent: only an absent field keeps the store's default,
|
||||
// ["*"]. Refused: a JSON null field, a value that is not a string, the
|
||||
// empty string and the empty array (the legacy unrestricted forms, which
|
||||
// no current caller sends and no new key should carry), a string that is
|
||||
// not a JSON array of strings (including "null"), and any value outside
|
||||
// the vocabulary.
|
||||
func normalizeTokenScopesInput(w http.ResponseWriter, input *models.APITokenCreate, raw json.RawMessage) bool {
|
||||
allowed := strings.Join(apiTokenScopeVocabulary, ", ")
|
||||
input.Scopes = ""
|
||||
if len(raw) == 0 {
|
||||
return true
|
||||
}
|
||||
// Into a pointer, so null is nil however it is spaced.
|
||||
var field *string
|
||||
if err := json.Unmarshal(raw, &field); err != nil || field == nil {
|
||||
writeError(w, http.StatusBadRequest, "validation_error",
|
||||
fmt.Sprintf("scopes must be a string holding a JSON array of: %s (got %s); omit scopes for full access", allowed, raw))
|
||||
return false
|
||||
}
|
||||
// "" is the stored legacy unrestricted form, refused for the reason []
|
||||
// is (Codex r2): only an omitted field gets the default.
|
||||
if *field == "" {
|
||||
writeError(w, http.StatusBadRequest, "validation_error",
|
||||
fmt.Sprintf("scopes must name at least one of: %s; omit scopes for full access", allowed))
|
||||
return false
|
||||
}
|
||||
input.Scopes = *field
|
||||
var scopes []string
|
||||
if err := json.Unmarshal([]byte(input.Scopes), &scopes); err != nil || scopes == nil {
|
||||
writeError(w, http.StatusBadRequest, "validation_error",
|
||||
fmt.Sprintf("scopes must be a JSON array of: %s (got %q)", allowed, input.Scopes))
|
||||
return false
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "validation_error",
|
||||
fmt.Sprintf("scopes must name at least one of: %s; omit scopes for full access", allowed))
|
||||
return false
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if !slices.Contains(apiTokenScopeVocabulary, scope) {
|
||||
writeError(w, http.StatusBadRequest, "validation_error",
|
||||
fmt.Sprintf("unknown scope %q; allowed values: %s", scope, allowed))
|
||||
return false
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(scopes)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return false
|
||||
}
|
||||
input.Scopes = string(encoded)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TASK-3866: both doors that mint an API key accept exactly the scope
|
||||
// values tokenScopeAllows recognizes, store them compact, and refuse
|
||||
// anything that would mint a key the server then refuses everywhere, or
|
||||
// one in the legacy unrestricted [] form.
|
||||
func TestTASK3866_CreateTokenScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := setupTokenAuthEnv(t)
|
||||
doors := map[string]string{
|
||||
"user": "/api/v1/auth/tokens",
|
||||
"workspace": "/api/v1/workspaces/" + env.ws.Slug + "/tokens",
|
||||
}
|
||||
|
||||
accepted := []struct {
|
||||
name string
|
||||
scopes any // omitted when nil
|
||||
stored string
|
||||
}{
|
||||
{"omitted keeps the default", nil, `["*"]`},
|
||||
{"read", `["read"]`, `["read"]`},
|
||||
{"wildcard", `["*"]`, `["*"]`},
|
||||
{"padded and mixed are stored compact", `[ "read" , "pad:write" ]`, `["read","pad:write"]`},
|
||||
{"every vocabulary value", `["*","read","write","pad:read","pad:write","pad:admin"]`, `["*","read","write","pad:read","pad:write","pad:admin"]`},
|
||||
}
|
||||
// Not strings: the field itself is null, an array or a number. A JSON
|
||||
// null used to decode as "" and mint a full-access key (Codex r1).
|
||||
refusedRaw := []struct {
|
||||
name string
|
||||
scopes json.RawMessage
|
||||
named string
|
||||
}{
|
||||
{"a JSON null field", json.RawMessage(`null`), "got null"},
|
||||
{"an array, not a string", json.RawMessage(`["read"]`), `got ["read"]`},
|
||||
{"a number", json.RawMessage(`1`), "got 1"},
|
||||
{"a spaced JSON null field", json.RawMessage(" null "), "got null"},
|
||||
{"an empty string", json.RawMessage(`""`), "omit scopes"},
|
||||
}
|
||||
refused := []struct {
|
||||
name, scopes, named string
|
||||
}{
|
||||
{"null", `null`, `"null"`},
|
||||
{"unparseable", `read`, `"read"`},
|
||||
{"an object", `{}`, `"{}"`},
|
||||
{"a bare string", `"read"`, `"\"read\""`},
|
||||
{"a non-string element", `["read",1]`, `"[\"read\",1]"`},
|
||||
{"empty array", `[]`, "omit scopes"},
|
||||
{"padded empty array", `[ ]`, "omit scopes"},
|
||||
{"unknown value", `["read-only"]`, `"read-only"`},
|
||||
{"case matters", `["Read"]`, `"Read"`},
|
||||
{"unknown beside a known one", `["read","docs"]`, `"docs"`},
|
||||
{"null element", `["read",null]`, `""`},
|
||||
}
|
||||
|
||||
for door, path := range doors {
|
||||
for i, tc := range accepted {
|
||||
body := map[string]any{"name": door + "-ok-" + string(rune('a'+i))}
|
||||
if tc.scopes != nil {
|
||||
body["scopes"] = tc.scopes
|
||||
}
|
||||
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok, body)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Errorf("%s door, %s: %d %s, want 201", door, tc.name, rr.Code, rr.Body.String())
|
||||
continue
|
||||
}
|
||||
var got struct {
|
||||
Scopes string `json:"scopes"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Scopes != tc.stored {
|
||||
t.Errorf("%s door, %s: stored %q, want %q", door, tc.name, got.Scopes, tc.stored)
|
||||
}
|
||||
}
|
||||
for _, tc := range refused {
|
||||
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok,
|
||||
map[string]any{"name": door + "-bad", "scopes": tc.scopes})
|
||||
if rr.Code != http.StatusBadRequest || errorCode(t, rr) != "validation_error" {
|
||||
t.Errorf("%s door, %s: %d %s, want 400 validation_error", door, tc.name, rr.Code, rr.Body.String())
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), jsonEscapedForBody(tc.named)) {
|
||||
t.Errorf("%s door, %s: message does not name %s: %s", door, tc.name, tc.named, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for door, path := range doors {
|
||||
for _, tc := range refusedRaw {
|
||||
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok,
|
||||
map[string]any{"name": door + "-bad", "scopes": tc.scopes})
|
||||
if rr.Code != http.StatusBadRequest || errorCode(t, rr) != "validation_error" {
|
||||
t.Errorf("%s door, %s: %d %s, want 400 validation_error", door, tc.name, rr.Code, rr.Body.String())
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), jsonEscapedForBody(tc.named)) {
|
||||
t.Errorf("%s door, %s: message does not name %s: %s", door, tc.name, tc.named, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing refused was minted.
|
||||
tokens, err := env.srv.store.ListUserAPITokens(env.user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tok := range tokens {
|
||||
if strings.HasSuffix(tok.Name, "-bad") {
|
||||
t.Errorf("a refused create minted %q with scopes %q", tok.Name, tok.Scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonEscapedForBody is s as it appears inside a JSON string value, the
|
||||
// form the error message takes in the response body.
|
||||
func jsonEscapedForBody(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"_comment": "TASK-3866: what each stored api_tokens.scopes value lets a key do. Read by TestTokenScopeLevelsFixture (Go, against tokenScopeAllows) and by web/src/lib/settings/tokenScopes.test.ts (against describeTokenScopes), so the web's label cannot drift from the server's check. level: write = reads and writes, read = reads only, none = every request refused. legacy marks the unrestricted pre-enforcement forms. unknown lists the values the server ignores.",
|
||||
"rows": [
|
||||
{ "scopes": "[\"*\"]", "level": "write" },
|
||||
{ "scopes": "[ \"*\" ]", "level": "write" },
|
||||
{ "scopes": "\u00a0[\"*\"]", "level": "write" },
|
||||
{ "scopes": "\u0085[\"*\"]", "level": "write" },
|
||||
{ "scopes": "\ufeff[\"*\"]", "level": "none" },
|
||||
{ "scopes": "[\"write\"]", "level": "write" },
|
||||
{ "scopes": "[\"pad:write\"]", "level": "write" },
|
||||
{ "scopes": "[\"pad:admin\"]", "level": "write" },
|
||||
{ "scopes": "[\"read\",\"write\"]", "level": "write" },
|
||||
{ "scopes": "[\"write\",\"read\"]", "level": "write" },
|
||||
{ "scopes": "[\"*\",\"pad:read\"]", "level": "write" },
|
||||
{ "scopes": "[\"read\"]", "level": "read" },
|
||||
{ "scopes": "[\"pad:read\"]", "level": "read" },
|
||||
{ "scopes": "[\"read\",\"pad:read\"]", "level": "read" },
|
||||
{ "scopes": "", "level": "write", "legacy": true },
|
||||
{ "scopes": "[]", "level": "write", "legacy": true },
|
||||
{ "scopes": "[ ]", "level": "write", "legacy": true },
|
||||
{ "scopes": "[\n]", "level": "write", "legacy": true },
|
||||
{ "scopes": "null", "level": "none" },
|
||||
{ "scopes": " ", "level": "none" },
|
||||
{ "scopes": "not-json", "level": "none" },
|
||||
{ "scopes": "{}", "level": "none" },
|
||||
{ "scopes": "\"read\"", "level": "none" },
|
||||
{ "scopes": "[\"read\",1]", "level": "none" },
|
||||
{ "scopes": "\u00a0[\"read\"]", "level": "none" },
|
||||
{ "scopes": "[\"docs\"]", "level": "none", "unknown": ["docs"] },
|
||||
{ "scopes": "[\"read-only\"]", "level": "none", "unknown": ["read-only"] },
|
||||
{ "scopes": "[\"Read\"]", "level": "none", "unknown": ["Read"] },
|
||||
{ "scopes": "[null]", "level": "none", "unknown": [""] },
|
||||
{ "scopes": "[\"docs\",\"read\"]", "level": "read", "unknown": ["docs"] },
|
||||
{ "scopes": "[\"read\",null]", "level": "read", "unknown": [""] },
|
||||
{ "scopes": "[\"docs\",\"*\"]", "level": "write", "unknown": ["docs"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTokenScopeLevelsFixture holds tokenScopeAllows to the level each row
|
||||
// of testdata/token_scope_levels.json claims (TASK-3866). The web's token
|
||||
// list labels keys from the same file (web/src/lib/settings/tokenScopes.test.ts),
|
||||
// so a change to the server's check that the label does not follow turns
|
||||
// one of the two red.
|
||||
func TestTokenScopeLevelsFixture(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/token_scope_levels.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
Rows []struct {
|
||||
Scopes string `json:"scopes"`
|
||||
Level string `json:"level"`
|
||||
} `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fixture.Rows) == 0 {
|
||||
t.Fatal("fixture has no rows")
|
||||
}
|
||||
for _, row := range fixture.Rows {
|
||||
read := tokenScopeAllows(row.Scopes, http.MethodGet, "/api/v1/workspaces")
|
||||
write := tokenScopeAllows(row.Scopes, http.MethodPatch, "/api/v1/workspaces")
|
||||
got := "none"
|
||||
switch {
|
||||
case read && write:
|
||||
got = "write"
|
||||
case read:
|
||||
got = "read"
|
||||
case write:
|
||||
got = "write-without-read"
|
||||
}
|
||||
if got != row.Level {
|
||||
t.Errorf("scopes %q: server grants %s, fixture says %s", row.Scopes, got, row.Level)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* TASK-3866: a key made Read only in console settings is listed as Read
|
||||
* only, and the key the form reveals can read the workspace and is refused
|
||||
* a write. The Go test TestTASK3866_ReadOnlyKeyRefusesWritesOverRESTAndMCP
|
||||
* covers the same key over remote MCP; this spec proves the form sends what
|
||||
* that test mints.
|
||||
*/
|
||||
import { request as playwrightRequest } from '@playwright/test';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('a Read only key made in settings is listed as Read only and cannot write', async ({ page, fixture }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'one project is enough for a form-to-server contract');
|
||||
|
||||
const name = `task-3866 read-only ${Date.now()}`;
|
||||
const admin = await playwrightRequest.newContext({
|
||||
baseURL: fixture.baseURL,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${fixture.adminSessionToken}` }
|
||||
});
|
||||
try {
|
||||
// Minting needs an interactive session (BUG-2890): the suite's
|
||||
// default PAT is refused session_required. A padsess_ bearer counts
|
||||
// as interactive and, unlike a cookie, is not bound to a User-Agent.
|
||||
await page.context().setExtraHTTPHeaders({ Authorization: `Bearer ${fixture.adminSessionToken}` });
|
||||
await page.goto('/console/settings#api-tokens');
|
||||
const section = page.locator('#api-tokens');
|
||||
await section.getByRole('textbox', { name: 'Token name' }).fill(name);
|
||||
// Read and write is the default; Read only is a deliberate choice.
|
||||
await expect(section.getByRole('radio', { name: /Read and write/ })).toBeChecked();
|
||||
await section.getByRole('radio', { name: /Read only/ }).check();
|
||||
await section.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
const secret = (await section.locator('.token-value').textContent())?.trim() ?? '';
|
||||
expect(secret).toMatch(/^pad_/);
|
||||
const row = section.locator('.token-row', { hasText: name });
|
||||
await expect(row.getByTestId('token-access')).toHaveText('Read only');
|
||||
// The form resets to the default for the next key.
|
||||
await expect(section.getByRole('radio', { name: /Read and write/ })).toBeChecked();
|
||||
|
||||
const key = await playwrightRequest.newContext({
|
||||
baseURL: fixture.baseURL,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${secret}` }
|
||||
});
|
||||
try {
|
||||
const items = `/api/v1/workspaces/${fixture.workspaceSlug}/collections/tasks/items`;
|
||||
const read = await key.get(items);
|
||||
expect(read.status(), await read.text()).toBe(200);
|
||||
const write = await key.post(items, { data: { title: 'written by a read-only key' } });
|
||||
expect(write.status(), await write.text()).toBe(403);
|
||||
} finally {
|
||||
await key.dispose();
|
||||
}
|
||||
} finally {
|
||||
const list = await admin.get('/api/v1/auth/tokens');
|
||||
for (const t of (await list.json()) as { id: string; name: string }[]) {
|
||||
if (t.name === name) await admin.delete(`/api/v1/auth/tokens/${t.id}`);
|
||||
}
|
||||
await admin.dispose();
|
||||
}
|
||||
});
|
||||
@@ -3158,10 +3158,12 @@ export const api = {
|
||||
},
|
||||
tokens: {
|
||||
list: () => request<APIToken[]>('/auth/tokens'),
|
||||
create: (name: string) =>
|
||||
/** scopes is the JSON-array string the server stores, e.g. '["read"]'
|
||||
* (TASK-3866); omitted means full access. */
|
||||
create: (name: string, scopes?: string) =>
|
||||
request<APITokenWithSecret>('/auth/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
body: JSON.stringify(scopes ? { name, scopes } : { name })
|
||||
}),
|
||||
delete: (tokenId: string) =>
|
||||
request<void>(`/auth/tokens/${tokenId}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fixture from '../../../../internal/server/testdata/token_scope_levels.json';
|
||||
import { describeTokenScopes } from './tokenScopes';
|
||||
|
||||
interface Row {
|
||||
scopes: string;
|
||||
level: string;
|
||||
legacy?: boolean;
|
||||
unknown?: string[];
|
||||
}
|
||||
|
||||
// The same rows TestTokenScopeLevelsFixture holds tokenScopeAllows to.
|
||||
describe('describeTokenScopes agrees with the server (TASK-3866)', () => {
|
||||
const rows = (fixture as { rows: Row[] }).rows;
|
||||
|
||||
it('has rows', () => {
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
it(`${JSON.stringify(row.scopes)} → ${row.level}`, () => {
|
||||
const got = describeTokenScopes(row.scopes);
|
||||
expect(got.level).toBe(row.level);
|
||||
expect(got.legacy).toBe(row.legacy ?? false);
|
||||
expect(got.unknown).toEqual(row.unknown ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
it('a missing or non-string value is No access, never full access', () => {
|
||||
for (const raw of [undefined, null, 0, ['*']]) {
|
||||
expect(describeTokenScopes(raw).level).toBe('none');
|
||||
}
|
||||
});
|
||||
|
||||
it('never labels a refused key as having access', () => {
|
||||
for (const raw of ['null', 'not-json', '["docs"]']) {
|
||||
expect(describeTokenScopes(raw).label).toBe('No access');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// What an API key's stored scopes let it do, in words (TASK-3866).
|
||||
//
|
||||
// This mirrors tokenScopeAllows (internal/server/middleware_auth.go)
|
||||
// rather than reading the scope names at face value. The server sees two
|
||||
// levels, read and write ("pad:admin" grants exactly what "write" does),
|
||||
// and some stored values deny everything. The label must never say a key
|
||||
// can do something the server would refuse. The shared fixture
|
||||
// internal/server/testdata/token_scope_levels.json holds both sides to
|
||||
// the same answers.
|
||||
|
||||
export type TokenAccessLevel = 'write' | 'read' | 'none';
|
||||
|
||||
export interface TokenAccess {
|
||||
level: TokenAccessLevel;
|
||||
label: string;
|
||||
/** "" or []: a key from before scopes were enforced, unrestricted. */
|
||||
legacy: boolean;
|
||||
/** Values the server ignores, in stored order. */
|
||||
unknown: string[];
|
||||
}
|
||||
|
||||
const WRITE_SCOPES = new Set(['*', 'write', 'pad:write', 'pad:admin']);
|
||||
const READ_SCOPES = new Set(['read', 'pad:read']);
|
||||
|
||||
// Go's unicode.IsSpace, which strings.TrimSpace uses for the server's
|
||||
// ["*"] fast path. JS trim() differs (it trims U+FEFF and not U+0085), and
|
||||
// the fast path is the only place the server trims anything, so a padded
|
||||
// ["*"] is full access while a padded ["read"] fails to parse.
|
||||
const GO_SPACE_CLASS = '[\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]';
|
||||
const GO_SPACE = new RegExp(`^${GO_SPACE_CLASS}+|${GO_SPACE_CLASS}+$`, 'g');
|
||||
|
||||
const LABELS: Record<TokenAccessLevel, string> = {
|
||||
write: 'Read and write',
|
||||
read: 'Read only',
|
||||
none: 'No access',
|
||||
};
|
||||
|
||||
function result(level: TokenAccessLevel, legacy = false, unknown: string[] = []): TokenAccess {
|
||||
return { level, label: LABELS[level], legacy, unknown };
|
||||
}
|
||||
|
||||
// parseScopes returns what Go's json.Unmarshal into []string yields:
|
||||
// undefined where it fails (deny), null for a JSON null (deny), else the
|
||||
// strings, with a null element read as "" as Go reads it.
|
||||
function parseScopes(raw: string): string[] | null | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed === null) return null;
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
const out: string[] = [];
|
||||
for (const v of parsed) {
|
||||
if (v === null) out.push('');
|
||||
else if (typeof v === 'string') out.push(v);
|
||||
else return undefined;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function describeTokenScopes(raw: unknown): TokenAccess {
|
||||
// The API always sends scopes as a string. Anything else is a value we
|
||||
// cannot read, and a key we cannot read is never shown with access.
|
||||
if (typeof raw !== 'string') return result('none');
|
||||
if (raw === '') return result('write', true);
|
||||
if (raw.replace(GO_SPACE, '') === '["*"]') return result('write');
|
||||
|
||||
const scopes = parseScopes(raw);
|
||||
if (!scopes) return result('none');
|
||||
if (scopes.length === 0) return result('write', true);
|
||||
|
||||
let level: TokenAccessLevel = 'none';
|
||||
const unknown: string[] = [];
|
||||
for (const s of scopes) {
|
||||
if (WRITE_SCOPES.has(s)) level = 'write';
|
||||
else if (READ_SCOPES.has(s)) {
|
||||
if (level === 'none') level = 'read';
|
||||
} else unknown.push(s);
|
||||
}
|
||||
return result(level, false, unknown);
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import CharacterShortcutsToggle from '$lib/components/common/CharacterShortcutsToggle.svelte';
|
||||
import type { User, APIToken, APITokenWithSecret, TOTPSetupResponse } from '$lib/types';
|
||||
import { describeTokenScopes } from '$lib/settings/tokenScopes';
|
||||
|
||||
// Profile
|
||||
let profile = $state<User | null>(null);
|
||||
@@ -79,6 +80,12 @@
|
||||
// Tokens
|
||||
let tokens = $state<APIToken[]>([]);
|
||||
let newTokenName = $state('');
|
||||
// TASK-3866: the access a new key gets. Read and write sends ["*"], the
|
||||
// store's own default, so a key made here is identical to one made with
|
||||
// no scopes. There is no Admin choice: the server grants pad:admin
|
||||
// exactly what write gets.
|
||||
let newTokenAccess = $state<'write' | 'read'>('write');
|
||||
const TOKEN_ACCESS_SCOPES = { write: '["*"]', read: '["read"]' } as const;
|
||||
let createdToken = $state<APITokenWithSecret | null>(null);
|
||||
let tokenCreating = $state(false);
|
||||
// TASK-2193: deleting a token used to fire on the first click and swallow
|
||||
@@ -413,12 +420,13 @@
|
||||
|
||||
tokenCreating = true;
|
||||
try {
|
||||
const token = await api.auth.tokens.create(newTokenName.trim());
|
||||
const token = await api.auth.tokens.create(newTokenName.trim(), TOKEN_ACCESS_SCOPES[newTokenAccess]);
|
||||
createdToken = token;
|
||||
rotatedFrom = null;
|
||||
tokenCopied = false;
|
||||
tokens = [...tokens, token];
|
||||
newTokenName = '';
|
||||
newTokenAccess = 'write';
|
||||
} catch (err) {
|
||||
if (isPlanLimitError(err)) {
|
||||
showPlanLimitToast(err);
|
||||
@@ -978,6 +986,23 @@
|
||||
{tokenCreating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
<fieldset class="token-access" disabled={tokenCreating}>
|
||||
<legend>Access</legend>
|
||||
<label class="token-access-option">
|
||||
<input type="radio" name="token-access" value="write" bind:group={newTokenAccess} />
|
||||
<span>
|
||||
<span class="token-access-label">Read and write</span>
|
||||
<span class="token-access-hint">Everything your role in each workspace allows.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="token-access-option">
|
||||
<input type="radio" name="token-access" value="read" bind:group={newTokenAccess} />
|
||||
<span>
|
||||
<span class="token-access-label">Read only</span>
|
||||
<span class="token-access-hint">Can view items and run playbooks. Any change is refused.</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{#if tokenError}
|
||||
<p class="error">{tokenError}</p>
|
||||
@@ -986,9 +1011,13 @@
|
||||
{#if tokens.length > 0}
|
||||
<div class="token-list">
|
||||
{#each tokens as token (token.id)}
|
||||
{@const access = describeTokenScopes(token.scopes)}
|
||||
<div class="token-row">
|
||||
<div class="token-info">
|
||||
<span class="token-name">{token.name}</span>
|
||||
<span class="token-access-badge" class:none={access.level === 'none'} title={`Stored scopes: ${token.scopes || '(empty)'}`} data-testid="token-access">
|
||||
{access.label}{#if access.legacy}<span class="token-access-note"> (older key, unrestricted)</span>{/if}{#if access.level === 'none'}<span class="token-access-note"> (its access can't be read, so every request is refused; delete it and make a new one)</span>{:else if access.unknown.length > 0}<span class="token-access-note"> (ignores: {access.unknown.map((u) => u || '(empty)').join(', ')})</span>{/if}
|
||||
</span>
|
||||
<span class="token-meta">
|
||||
{token.prefix}... · Created {formatDate(token.created_at)}
|
||||
{#if token.expires_at}
|
||||
@@ -1184,6 +1213,7 @@
|
||||
<p class="modal-body">
|
||||
It keeps its name, scopes and workspace and gets a new key. The current key stops working immediately, so update every agent, script or MCP connection that uses it.
|
||||
</p>
|
||||
<p class="modal-body">Access stays {describeTokenScopes(tokenToRotate.scopes).label}.</p>
|
||||
{#if tokenRotateError}
|
||||
<p class="modal-error" role="alert">{tokenRotateError}</p>
|
||||
{/if}
|
||||
@@ -1485,6 +1515,62 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.token-access {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.token-access legend {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.token-access-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.token-access-option input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.token-access-label {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.token-access-hint {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.token-access-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.token-access-badge.none {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.token-access-note {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.token-access-badge.none .token-access-note {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user