Skip to content

Commit b5b0db5

Browse files
authored
Merge pull request #267 from aojea/ui_fixes
UI fixes
2 parents bf10a47 + 0a0edf0 commit b5b0db5

15 files changed

Lines changed: 805 additions & 1283 deletions

File tree

.github/workflows/ui.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
name: console-ui
16+
17+
on:
18+
push:
19+
branches:
20+
- "main"
21+
tags:
22+
- "v*"
23+
pull_request:
24+
branches: [main]
25+
workflow_dispatch:
26+
27+
permissions:
28+
contents: read
29+
30+
env:
31+
GO_VERSION: "1.26"
32+
33+
jobs:
34+
console_ui_smoke:
35+
runs-on: ubuntu-latest
36+
name: Console UI smoke test
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40+
with:
41+
persist-credentials: false
42+
43+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
44+
with:
45+
go-version: ${{ env.GO_VERSION }}
46+
cache: false
47+
48+
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
49+
with:
50+
node-version: "22"
51+
cache: npm
52+
cache-dependency-path: tests/ui/package-lock.json
53+
54+
- name: Run console UI smoke test
55+
run: make ui-test
56+
57+
- name: Upload Playwright report
58+
if: failure()
59+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
60+
with:
61+
name: playwright-report-${{ github.run_id }}
62+
path: tests/ui/playwright-report

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ bin/
3838
/sam-box
3939
/sam-router
4040
/sam-console
41+
42+
# Playwright console UI tests
43+
node_modules/
44+
tests/ui/playwright-report/
45+
tests/ui/test-results/
4146
/sam-control-plane
4247
/mcp-client
4348
/nano-init

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ test-python-e2e: build docker-build
142142
e2e-test: build docker-build
143143
bats -j 10 --verbose-run $(if $(WHAT),--filter "$(WHAT)") tests/e2e/
144144

145+
.PHONY: ui-test
146+
ui-test: build
147+
chmod +x ./tests/ui/run.sh
148+
./tests/ui/run.sh $(if $(WHAT),--grep "$(WHAT)")
149+
145150
test-e2e: build docker-build
146151
@command -v bats >/dev/null 2>&1 || { \
147152
echo "bats not found; attempting install"; \

cmd/sam-console/public/app.js

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -360,13 +360,11 @@ function updateUIForRole(role, userId) {
360360
if (ownerInput) {
361361
const group = ownerInput.closest('.input-group');
362362
if (group) {
363-
if (role === 'admin') {
364-
group.style.display = 'flex';
365-
ownerInput.required = true;
366-
} else {
367-
group.style.display = 'none';
368-
ownerInput.required = false;
369-
ownerInput.value = userId; // Scoped to user implicitly
363+
// Only admins may set an owner; for everyone else the server infers it
364+
// from the session, so the field is hidden and left empty.
365+
group.style.display = role === 'admin' ? 'flex' : 'none';
366+
if (role !== 'admin') {
367+
ownerInput.value = '';
370368
}
371369
}
372370
}
@@ -467,23 +465,26 @@ function renderBootstrapTokensTable(tokens) {
467465

468466
window.generateBootstrapToken = async function() {
469467
const role = document.getElementById('token-role').value;
470-
const owner_id = document.getElementById('token-owner').value;
468+
const owner_id = document.getElementById('token-owner').value.trim();
471469
const max_usages = parseInt(document.getElementById('token-usages').value, 10);
472470
const ttl_hours = parseInt(document.getElementById('token-ttl').value, 10) || 24;
473471
const description = document.getElementById('token-desc').value;
474472

475473
const payload = {
476474
role,
477-
owner_id,
478475
max_usages,
479476
ttl_hours,
480477
description
481478
};
479+
// Omitted entirely so the server falls back to the authenticated session.
480+
if (owner_id) {
481+
payload.owner_id = owner_id;
482+
}
482483

483484
try {
484485
const res = await actionRequest('api/user/bootstrap-tokens', 'POST', payload);
485486
if (res && res.token) {
486-
alert('Bootstrap Token Generated Successfully!\n\nToken: ' + res.token + '\n\nCopy this token now. It will not be shown again.');
487+
showGeneratedToken(res);
487488
document.getElementById('form-generate-token').reset();
488489
loadData();
489490
}
@@ -492,6 +493,36 @@ window.generateBootstrapToken = async function() {
492493
}
493494
};
494495

496+
function showGeneratedToken(res) {
497+
const panel = document.getElementById('token-result');
498+
const input = document.getElementById('token-result-value');
499+
input.value = res.token;
500+
document.getElementById('token-result-owner').textContent = res.owner_id || 'you';
501+
document.getElementById('token-result-cmd').textContent =
502+
'sam-node join --bootstrap-token ' + res.token + ' <control-plane-url>';
503+
panel.hidden = false;
504+
input.focus();
505+
input.select();
506+
}
507+
508+
window.copyGeneratedToken = async function() {
509+
const input = document.getElementById('token-result-value');
510+
const btn = document.getElementById('token-copy-btn');
511+
input.select();
512+
try {
513+
// Only available on secure origins; execCommand covers plain-HTTP deployments.
514+
if (navigator.clipboard && window.isSecureContext) {
515+
await navigator.clipboard.writeText(input.value);
516+
} else if (!document.execCommand('copy')) {
517+
throw new Error('copy command rejected');
518+
}
519+
btn.textContent = 'Copied';
520+
} catch (err) {
521+
btn.textContent = 'Press Ctrl+C';
522+
}
523+
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
524+
};
525+
495526
function writeVarint(value) {
496527
const bytes = [];
497528
while (value > 127) {

cmd/sam-console/public/index.html

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,9 @@ <h3>Generate Bootstrap Token</h3>
262262
</select>
263263
</div>
264264
<div class="input-group" style="display: flex; flex-direction: column; gap: 4px;">
265-
<label for="token-owner" style="font-size: 0.85rem; color: var(--text-secondary);">Owner ID</label>
266-
<input type="text" id="token-owner" placeholder="e.g. system:serviceaccount:sam-kind:node-a-sa" style="background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; padding: 8px;" required>
265+
<label for="token-owner" style="font-size: 0.85rem; color: var(--text-secondary);">Owner ID <span style="opacity: 0.7;">(optional)</span></label>
266+
<input type="text" id="token-owner" placeholder="Leave blank to use your own identity" style="background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; padding: 8px;">
267+
<small id="token-owner-hint" style="font-size: 0.75rem; color: var(--text-secondary); opacity: 0.8;">Admins only: issue this token on behalf of another user. The user must have logged in at least once.</small>
267268
</div>
268269
<div class="input-group" style="display: flex; flex-direction: column; gap: 4px;">
269270
<label for="token-usages" style="font-size: 0.85rem; color: var(--text-secondary);">Max Usages</label>
@@ -279,6 +280,19 @@ <h3>Generate Bootstrap Token</h3>
279280
</div>
280281
<button type="submit" class="btn btn-primary" style="margin-top: 12px; max-width: 200px;">Generate Token</button>
281282
</form>
283+
<div id="token-result" hidden style="margin-top: 1.5rem; max-width: 500px; border: 1px solid var(--border-color); border-radius: 6px; padding: 1rem; background: rgba(0,0,0,0.2);">
284+
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 8px;">
285+
Token generated for owner <code id="token-result-owner"></code>. Copy it now &mdash; it is not stored and will not be shown again.
286+
</div>
287+
<div style="display: flex; gap: 8px; align-items: center;">
288+
<input type="text" id="token-result-value" readonly onfocus="this.select();"
289+
style="flex: 1; font-family: monospace; background: rgba(0,0,0,0.35); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; padding: 8px;">
290+
<button type="button" class="btn" id="token-copy-btn" onclick="copyGeneratedToken();">Copy</button>
291+
</div>
292+
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 8px;">
293+
Join a node with: <code id="token-result-cmd"></code>
294+
</div>
295+
</div>
282296
</div>
283297
<div class="card">
284298
<h3>Active Tokens</h3>

hack/lint.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,15 @@ REPO_ROOT=$(dirname "${BASH_SOURCE[0]}")/..
2222

2323
cd $REPO_ROOT
2424
docker run --rm -v $(pwd):/app -w /app golangci/golangci-lint:v2.11.4 golangci-lint run -v
25+
26+
# golangci-lint has no deadcode linter (removed upstream in v1.49) and its
27+
# replacement, "unused", ignores exported identifiers. This catches exported
28+
# code that is unreachable from every binary and test.
29+
# mobile/ is exported to Android over cgo/FFI and development/examples/ is sample code.
30+
DEADCODE_EXCLUDES='^(mobile/|development/examples/)'
31+
deadcode_report=$(go run golang.org/x/tools/cmd/deadcode@v0.40.0 -test ./... | grep -Ev "${DEADCODE_EXCLUDES}" || true)
32+
if [[ -n "${deadcode_report}" ]]; then
33+
echo "Dead code detected (unreachable from any binary or test):"
34+
echo "${deadcode_report}"
35+
exit 1
36+
fi

internal/controlplane/server.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,7 @@ func (s *Server) HandleEnroll(w http.ResponseWriter, r *http.Request) {
12101210
PublicKey: req.PublicKey,
12111211
Biscuit: biscuitBytes,
12121212
Role: tokenRecord.Role,
1213+
OwnerID: tokenRecord.OwnerID,
12131214
EnrollmentType: "BOOTSTRAP",
12141215
Labels: req.Labels,
12151216
EnrolledAt: time.Now(),
@@ -1557,6 +1558,7 @@ func (s *Server) HandleAdminEnrollmentAction(w http.ResponseWriter, r *http.Requ
15571558
PublicKey: enrollReq.PublicKey,
15581559
Biscuit: biscuitBytes,
15591560
Role: tokenRecord.Role,
1561+
OwnerID: tokenRecord.OwnerID,
15601562
EnrollmentType: "BOOTSTRAP",
15611563
Labels: enrollReq.Labels,
15621564
EnrolledAt: time.Now(),
@@ -1796,6 +1798,7 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques
17961798

17971799
var req struct {
17981800
Role string `json:"role"`
1801+
OwnerID string `json:"owner_id"`
17991802
TTLHours int `json:"ttl_hours"`
18001803
MaxUsages int `json:"max_usages"`
18011804
Description string `json:"description"`
@@ -1817,6 +1820,12 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques
18171820
return
18181821
}
18191822

1823+
ownerID, status, err := s.resolveTokenOwner(r.Context(), user, req.OwnerID)
1824+
if err != nil {
1825+
http.Error(w, err.Error(), status)
1826+
return
1827+
}
1828+
18201829
if req.TTLHours <= 0 {
18211830
req.TTLHours = 24
18221831
}
@@ -1836,7 +1845,7 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques
18361845
ID: tokenID,
18371846
TokenHash: tokenID,
18381847
Role: req.Role,
1839-
OwnerID: user.ID,
1848+
OwnerID: ownerID,
18401849
MaxUsages: req.MaxUsages,
18411850
UsagesCount: 0,
18421851
Description: req.Description,
@@ -1856,10 +1865,31 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques
18561865
"id": tokenRecord.ID,
18571866
"token": tokenVal,
18581867
"role": tokenRecord.Role,
1868+
"owner_id": tokenRecord.OwnerID,
18591869
"expires_at": tokenRecord.ExpiresAt.Format(time.RFC3339),
18601870
})
18611871
}
18621872

1873+
// resolveTokenOwner determines which user a bootstrap token is issued on behalf of.
1874+
// The owner defaults to the caller; only admins may override it, and only with a
1875+
// user that already exists. Returns the owner plus an HTTP status to use on error.
1876+
func (s *Server) resolveTokenOwner(ctx context.Context, caller *storage.User, requested string) (string, int, error) {
1877+
requested = strings.TrimSpace(requested)
1878+
if requested == "" || requested == caller.ID {
1879+
return caller.ID, 0, nil
1880+
}
1881+
if caller.Role != "admin" {
1882+
return "", http.StatusForbidden, errors.New("forbidden: only admins may issue tokens on behalf of another user")
1883+
}
1884+
if _, err := s.store.GetUser(ctx, requested); err != nil {
1885+
if err == storage.ErrNotFound {
1886+
return "", http.StatusBadRequest, fmt.Errorf("unknown owner_id %q: the user must have logged in at least once", requested)
1887+
}
1888+
return "", http.StatusInternalServerError, errors.New("failed to look up owner")
1889+
}
1890+
return requested, 0, nil
1891+
}
1892+
18631893
func (s *Server) HandleUserRevoke(w http.ResponseWriter, r *http.Request) {
18641894
user, err := s.authenticateUser(r)
18651895
if err != nil {

internal/controlplane/server_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,3 +1386,78 @@ func TestAuthDenialPaths(t *testing.T) {
13861386
}
13871387
})
13881388
}
1389+
1390+
// TestBootstrapTokenOwnerPropagatesToNode ensures a node enrolled with a user-owned
1391+
// bootstrap token is attributed to that user, which is what makes the console's
1392+
// per-user node listing work.
1393+
func TestBootstrapTokenOwnerPropagatesToNode(t *testing.T) {
1394+
issuer, mintToken := startCustomMockOIDC(t)
1395+
srv, store, baseURL := setupTestServer(t, issuer)
1396+
defer func() {
1397+
_ = srv.Close()
1398+
_ = store.Close()
1399+
}()
1400+
srv.config.AutoApproveEnrollment = true
1401+
1402+
ctx := context.Background()
1403+
client := &http.Client{Timeout: 5 * time.Second}
1404+
1405+
const ownerSub = "owner-sub-id"
1406+
userJWT := mintToken(map[string]interface{}{"sub": ownerSub, "email": "owner@example.com"})
1407+
1408+
req, _ := http.NewRequest(http.MethodPost, baseURL+"/user/bootstrap-tokens",
1409+
bytes.NewBufferString(`{"role":"`+api.RoleNode+`","max_usages":1}`))
1410+
req.Header.Set("Content-Type", "application/json")
1411+
req.Header.Set("Authorization", "Bearer "+userJWT)
1412+
resp, err := client.Do(req)
1413+
if err != nil {
1414+
t.Fatalf("failed to create bootstrap token: %v", err)
1415+
}
1416+
if resp.StatusCode != http.StatusCreated {
1417+
body, _ := io.ReadAll(resp.Body)
1418+
_ = resp.Body.Close()
1419+
t.Fatalf("unexpected status creating token: %s (%s)", resp.Status, body)
1420+
}
1421+
var tokenDetails struct {
1422+
Token string `json:"token"`
1423+
OwnerID string `json:"owner_id"`
1424+
}
1425+
_ = json.NewDecoder(resp.Body).Decode(&tokenDetails)
1426+
_ = resp.Body.Close()
1427+
1428+
if tokenDetails.OwnerID != ownerSub {
1429+
t.Fatalf("token owner = %q, want %q", tokenDetails.OwnerID, ownerSub)
1430+
}
1431+
1432+
privNode, pubNode, err := crypto.GenerateKeyPair(crypto.Ed25519, -1)
1433+
if err != nil {
1434+
t.Fatal(err)
1435+
}
1436+
pID, _ := peer.IDFromPrivateKey(privNode)
1437+
pubBytes, _ := crypto.MarshalPublicKey(pubNode)
1438+
1439+
enrollData, _ := proto.Marshal(&api.BootstrapEnrollRequest{
1440+
BootstrapToken: tokenDetails.Token,
1441+
PeerId: pID.String(),
1442+
PublicKey: pubBytes,
1443+
RequestedRole: api.RoleNode,
1444+
})
1445+
resp, err = client.Post(baseURL+"/enroll", "application/x-protobuf", bytes.NewBuffer(enrollData))
1446+
if err != nil {
1447+
t.Fatalf("failed to enroll: %v", err)
1448+
}
1449+
if resp.StatusCode != http.StatusOK {
1450+
body, _ := io.ReadAll(resp.Body)
1451+
_ = resp.Body.Close()
1452+
t.Fatalf("unexpected enroll status: %s (%s)", resp.Status, body)
1453+
}
1454+
_ = resp.Body.Close()
1455+
1456+
enrolled, err := store.GetNode(ctx, pID.String())
1457+
if err != nil {
1458+
t.Fatalf("failed to load enrolled node: %v", err)
1459+
}
1460+
if enrolled.OwnerID != ownerSub {
1461+
t.Errorf("enrolled node owner = %q, want %q", enrolled.OwnerID, ownerSub)
1462+
}
1463+
}

0 commit comments

Comments
 (0)