diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..c7114451db --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5199", "--strictPort"], + "port": 5199 + } + ] +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 5c1b711a65..0c61aab56f 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -2,11 +2,16 @@ package httpapi import ( "crypto/subtle" + "encoding/json" "net/http" "strings" ) -// requireAuth gates protected endpoints behind a static bearer token. +const sessionCookieName = "dm_session" + +// requireAuth gates protected endpoints behind a static bearer token, supplied +// either as an Authorization header or as the HttpOnly session cookie set by +// the login endpoint. // // The token is supplied via the API_TOKEN environment variable. Local // development can explicitly disable auth through AUTH_DISABLED=true. @@ -14,7 +19,7 @@ func requireAuth(token string) func(http.Handler) http.Handler { expected := []byte(token) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !validBearerToken(r.Header.Get("Authorization"), expected) { + if !validBearerToken(r.Header.Get("Authorization"), expected) && !validSessionRequest(r, expected) { w.Header().Set("WWW-Authenticate", "Bearer") writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return @@ -35,3 +40,77 @@ func validBearerToken(header string, expected []byte) bool { } return subtle.ConstantTimeCompare(presented, expected) == 1 } + +// ponytail: the cookie stores the API token itself (HttpOnly, so scripts can't +// read it). Move to signed random session IDs if per-session revocation is needed. +func validSessionCookie(r *http.Request, expected []byte) bool { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(cookie.Value), expected) == 1 +} + +func validSessionRequest(r *http.Request, expected []byte) bool { + if !validSessionCookie(r, expected) { + return false + } + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + origin := strings.TrimSpace(r.Header.Get("Origin")) + return strings.HasPrefix(strings.ToLower(origin), "https://") && sameHostOrigin(origin, r.Host) + } +} + +func sessionCookie(r *http.Request, auth AuthConfig, value string, maxAge int) *http.Cookie { + return &http.Cookie{ + Name: sessionCookieName, + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: !auth.Disabled || r.TLS != nil, + SameSite: http.SameSiteLaxMode, + } +} + +// login exchanges the API token for the HttpOnly session cookie. +func login(auth AuthConfig) http.HandlerFunc { + expected := []byte(auth.Token) + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if !auth.Disabled && subtle.ConstantTimeCompare([]byte(body.Token), expected) != 1 { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"}) + return + } + http.SetCookie(w, sessionCookie(r, auth, auth.Token, 30*24*60*60)) + w.WriteHeader(http.StatusNoContent) + } +} + +func logout(auth AuthConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, sessionCookie(r, auth, "", -1)) + w.WriteHeader(http.StatusNoContent) + } +} + +// session reports whether the request is authenticated so the SPA can decide +// between the login page and the app shell without triggering a 401. +func session(auth AuthConfig) http.HandlerFunc { + expected := []byte(auth.Token) + return func(w http.ResponseWriter, r *http.Request) { + authenticated := auth.Disabled || + validBearerToken(r.Header.Get("Authorization"), expected) || + validSessionCookie(r, expected) + writeJSON(w, http.StatusOK, map[string]bool{"authenticated": authenticated}) + } +} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index 30481f4f89..a567082160 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -3,6 +3,7 @@ package httpapi import ( "net/http" "net/http/httptest" + "strings" "testing" ) @@ -90,6 +91,100 @@ func TestAuthRejectsQueryToken(t *testing.T) { } } +func TestLoginLogoutSessionFlow(t *testing.T) { + const token = "supersecrettoken123" + handler := New(nil, nil, nil, nil, nil, GitHubWebhookConfig{}, nil, "", AuthConfig{Token: token}) + + // Wrong token is rejected and sets no cookie. + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"token":"wrong"}`)) + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for wrong login token, got %d", response.Code) + } + if len(response.Result().Cookies()) != 0 { + t.Fatal("failed login must not set a cookie") + } + + // Correct token sets the HttpOnly session cookie. + response = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"token":"`+token+`"}`)) + handler.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("expected 204 for valid login, got %d", response.Code) + } + cookies := response.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != sessionCookieName || !cookies[0].HttpOnly || !cookies[0].Secure { + t.Fatalf("expected one HttpOnly Secure %s cookie, got %+v", sessionCookieName, cookies) + } + + // The session endpoint recognizes the cookie. + response = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodGet, "/api/session", nil) + request.AddCookie(cookies[0]) + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"authenticated":true`) { + t.Fatalf("expected authenticated session with cookie, got %d %s", response.Code, response.Body.String()) + } + + // Logout clears the cookie. + response = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPost, "/api/logout", nil) + handler.ServeHTTP(response, request) + cleared := response.Result().Cookies() + if len(cleared) != 1 || cleared[0].Value != "" || cleared[0].MaxAge >= 0 || !cleared[0].Secure { + t.Fatalf("expected logout to expire the session cookie, got %+v", cleared) + } + + // Without a cookie the session reports unauthenticated. + response = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodGet, "/api/session", nil) + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"authenticated":false`) { + t.Fatalf("expected unauthenticated session without cookie, got %d %s", response.Code, response.Body.String()) + } +} + +func TestCookieAuthRequiresSameOriginForUnsafeMethods(t *testing.T) { + const token = "supersecrettoken123" + protected := requireAuth(token)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + tests := []struct { + name string + method string + origin string + bearer bool + want int + }{ + {name: "safe cookie request", method: http.MethodGet, want: http.StatusNoContent}, + {name: "same-origin cookie request", method: http.MethodPost, origin: "https://deploy.internal.prosights.co", want: http.StatusNoContent}, + {name: "missing origin", method: http.MethodPost, want: http.StatusUnauthorized}, + {name: "insecure same-host origin", method: http.MethodPost, origin: "http://deploy.internal.prosights.co", want: http.StatusUnauthorized}, + {name: "cross-origin cookie request", method: http.MethodPost, origin: "https://other.prosights.co", want: http.StatusUnauthorized}, + {name: "bearer automation", method: http.MethodPost, bearer: true, want: http.StatusNoContent}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := httptest.NewRecorder() + request := httptest.NewRequest(test.method, "https://deploy.internal.prosights.co/api/protected", nil) + if test.bearer { + request.Header.Set("Authorization", "Bearer "+token) + } else { + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + } + if test.origin != "" { + request.Header.Set("Origin", test.origin) + } + protected.ServeHTTP(response, request) + if response.Code != test.want { + t.Fatalf("expected %d, got %d", test.want, response.Code) + } + }) + } +} + func TestValidBearerTokenConstantTime(t *testing.T) { expected := []byte("supersecrettoken123") cases := []struct { diff --git a/internal/httpapi/security.go b/internal/httpapi/security.go index 7c01da4a4b..3abb23af18 100644 --- a/internal/httpapi/security.go +++ b/internal/httpapi/security.go @@ -1,6 +1,9 @@ package httpapi -import "net/http" +import ( + "net/http" + "strings" +) func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -12,3 +15,8 @@ func securityHeaders(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func sameHostOrigin(origin string, host string) bool { + origin = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(origin, "http://"), "https://")) + return strings.EqualFold(origin, host) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 172308b85a..9e4a8d8b98 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -88,6 +88,10 @@ func New(queries *db.Queries, tx transactionStarter, queue DeploymentQueue, logs r.Get("/healthz", server.health) r.Get("/readyz", server.readyz) r.Get("/version", server.version) + // Tighter limit on login: it is the only credential-guessing surface. + r.With(rateLimit(20, 1*time.Minute)).Post("/login", login(auth)) + r.Post("/logout", logout(auth)) + r.Get("/session", session(auth)) r.Post("/webhooks/github", server.githubWebhook) r.Post("/github/webhook", server.githubWebhook) r.Get("/github/install/callback", server.githubInstallCallback) diff --git a/internal/httpapi/server_terminal.go b/internal/httpapi/server_terminal.go index ffb2197555..609fd76cec 100644 --- a/internal/httpapi/server_terminal.go +++ b/internal/httpapi/server_terminal.go @@ -273,11 +273,6 @@ func websocketUpgrader() websocket.Upgrader { } } -func sameHostOrigin(origin string, host string) bool { - origin = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(origin, "http://"), "https://")) - return strings.EqualFold(origin, host) -} - type terminalWriter struct { mu sync.Mutex conn *websocket.Conn diff --git a/package-lock.json b/package-lock.json index df89468a7a..5368bb4324 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "deploy-manager", "dependencies": { - "@fontsource/uncut-sans": "^5.2.5", + "@fontsource-variable/inter": "^5.3.0", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-dropdown-menu": "^2.1.20", "@radix-ui/react-select": "^2.3.3", @@ -143,6 +143,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -476,6 +477,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -524,6 +526,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1128,10 +1131,10 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@fontsource/uncut-sans": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@fontsource/uncut-sans/-/uncut-sans-5.2.5.tgz", - "integrity": "sha512-3T4+r2JHVpPsDLMCPR9uhgIxIEyvRmP0TQKerHiUUHJqgxlGfjjOs35gXHhkrXk3sbHGIUCJ6bFx47Ttk/7kvA==", + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -2977,8 +2980,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3065,6 +3067,7 @@ "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -3075,6 +3078,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3085,6 +3089,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3134,6 +3139,7 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -3486,6 +3492,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3536,7 +3543,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3547,7 +3553,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3651,6 +3656,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3882,8 +3888,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.377", @@ -3993,6 +3998,7 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -4500,6 +4506,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -4643,7 +4650,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4845,6 +4851,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4896,7 +4903,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4921,6 +4927,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4930,6 +4937,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4942,8 +4950,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5124,6 +5131,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -5378,6 +5386,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5514,6 +5523,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5827,6 +5837,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 49da0586a5..7bd29b8630 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint web/src" }, "dependencies": { - "@fontsource/uncut-sans": "^5.2.5", + "@fontsource-variable/inter": "^5.3.0", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-dropdown-menu": "^2.1.20", "@radix-ui/react-select": "^2.3.3", diff --git a/web/src/components/app-shell.test.tsx b/web/src/components/app-shell.test.tsx index 474f523a46..02b56d15d6 100644 --- a/web/src/components/app-shell.test.tsx +++ b/web/src/components/app-shell.test.tsx @@ -83,14 +83,14 @@ describe('AppShell', () => { expect(screen.queryByRole('link', { name: 'Back to projects' })).not.toBeInTheDocument() }) - it('opens the account menu with identity only', () => { + it('opens the account menu with identity and logout', () => { render() fireEvent.pointerDown(screen.getByRole('button', { name: / account$/ }), { button: 0, ctrlKey: false }) expect(screen.getByText('User')).toBeInTheDocument() expect(screen.getByText(/\S+@\S+\.\S+/)).toBeInTheDocument() - expect(screen.queryByText('Log Out')).not.toBeInTheDocument() + expect(screen.getByText('Log Out')).toBeInTheDocument() fireEvent.keyDown(document, { key: 'Escape' }) }) }) diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 1389b3e500..d9818cc8d1 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -1,13 +1,16 @@ import { Link, Outlet, useLocation } from '@tanstack/react-router' -import { Bell, Cable, Ellipsis, FileClock, FolderKanban, Monitor, Moon, PanelLeftClose, PanelLeftOpen, Rocket, Server, Sun } from 'lucide-react' +import { Bell, Cable, Ellipsis, FileClock, FolderKanban, LogOut, Monitor, Moon, PanelLeftClose, PanelLeftOpen, Rocket, Server, Sun } from 'lucide-react' import { useQuery, useSuspenseQueries } from '@tanstack/react-query' import { Suspense, useEffect, useState } from 'react' import { appVersionQuery, projectsQuery } from '../lib/queries' import { nextTheme, useUiStore } from '../store/ui' import { Button } from './ui/button' +import { logout } from '../lib/api' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu' import { @@ -64,8 +67,11 @@ export function AppShell() { }} className="text-prosights-text" > - - + {/* No right border and no header border inside the sidebar: the surface + color change divides chrome from content, so the main header's + border-b starts at the content column and never crosses the nav. */} + +
@@ -95,7 +101,7 @@ export function AppShell() { asChild isActive={active} tooltip={item.label} - className="group/sidebar-item h-8 rounded-prosights-md px-2 text-[13px] font-medium text-prosights-muted transition-colors hover:bg-prosights-surface-muted hover:text-prosights-text data-[active=true]:bg-prosights-surface-muted data-[active=true]:text-prosights-text [&>svg]:size-4" + className="group/sidebar-item h-8 rounded-prosights-md px-2 text-[13px] font-medium text-prosights-muted transition-colors hover:bg-sidebar-hover hover:text-prosights-text data-[active=true]:bg-sidebar-selected data-[active=true]:text-prosights-text [&>svg]:size-4" >
) } diff --git a/web/src/store/ui.ts b/web/src/store/ui.ts index 320c839374..3c2db36f90 100644 --- a/web/src/store/ui.ts +++ b/web/src/store/ui.ts @@ -11,9 +11,7 @@ export function nextTheme(current: Theme): Theme { type UiState = { sidebarCollapsed: boolean - searchQuery: string theme: Theme - setSearchQuery: (searchQuery: string) => void toggleSidebar: () => void setTheme: (theme: Theme) => void } @@ -55,9 +53,7 @@ applyTheme(initialTheme) export const useUiStore = create((set) => ({ sidebarCollapsed: false, - searchQuery: '', theme: initialTheme, - setSearchQuery: (searchQuery) => set({ searchQuery }), toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), setTheme: (theme) => { applyTheme(theme, true) diff --git a/web/src/styles.css b/web/src/styles.css index 94994e10cc..915c9e6cfb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,9 +1,9 @@ -@import "@fontsource/uncut-sans/latin.css"; +@import "@fontsource-variable/inter"; @import "tailwindcss"; @import "tw-animate-css"; @theme { - --font-sans: "Uncut Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-sans: "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; --color-background: #171716; --color-surface: #1d1d1b; @@ -32,12 +32,14 @@ } @theme inline { - --color-sidebar: var(--prosights-surface); + --color-sidebar: var(--prosights-sidebar); --color-sidebar-foreground: var(--prosights-text); - --color-sidebar-accent: var(--prosights-surface-muted); + --color-sidebar-accent: var(--prosights-sidebar-hover); --color-sidebar-accent-foreground: var(--prosights-text); --color-sidebar-border: var(--prosights-border); --color-sidebar-ring: var(--prosights-ring); + --color-sidebar-hover: var(--prosights-sidebar-hover); + --color-sidebar-selected: var(--prosights-sidebar-selected); --color-prosights-canvas: var(--prosights-canvas); --color-prosights-surface: var(--prosights-surface); --color-prosights-surface-muted: var(--prosights-surface-muted); @@ -53,16 +55,22 @@ --radius-prosights-xl: var(--prosights-radius-xl); } +/* Light palette: one cool blue-grey hue family (oklch hue 245) for every + neutral. The sidebar surface is a slightly violet-grey hex on purpose — + the temperature split separates chrome from content without any border. */ :root, html.light { - --prosights-canvas: #f6f6f7; + --prosights-canvas: oklch(0.966 0.004 245); --prosights-surface: #ffffff; - --prosights-surface-muted: #f1f1f0; - --prosights-border: #ddddda; - --prosights-text: #080808; - --prosights-muted: #4c4e4e; - --prosights-subtle: #9f9fa0; - --prosights-ring: rgba(8, 8, 8, 0.12); + --prosights-surface-muted: oklch(0.965 0.006 245); + --prosights-border: oklch(0.905 0.009 245); + --prosights-text: oklch(0.205 0.012 245); + --prosights-muted: oklch(0.52 0.014 245); + --prosights-subtle: oklch(0.71 0.012 245); + --prosights-ring: oklch(0.6 0.1 245 / 0.35); + --prosights-sidebar: #f9f9fb; + --prosights-sidebar-hover: #f4f3f7; + --prosights-sidebar-selected: #f0eff5; --prosights-radius-xs: 4px; --prosights-radius-sm: 6px; --prosights-radius-md: 8px; @@ -79,15 +87,18 @@ html.dark { --prosights-muted: #b9bbb3; --prosights-subtle: #85877f; --prosights-ring: rgba(245, 245, 242, 0.18); + --prosights-sidebar: #141413; + --prosights-sidebar-hover: #1f1f1d; + --prosights-sidebar-selected: #242421; } html.light { - --color-background: #f6f6f7; + --color-background: oklch(0.966 0.004 245); --color-surface: #ffffff; - --color-panel: #f1f1f0; - --color-border: #ddddda; - --color-ink: #080808; - --color-muted: #4c4e4e; + --color-panel: oklch(0.965 0.006 245); + --color-border: oklch(0.905 0.009 245); + --color-ink: oklch(0.205 0.012 245); + --color-muted: oklch(0.52 0.014 245); --color-accent-fg: #ffffff; --color-base: #ffffff; --color-coolgray-100: #f8f9fa; @@ -171,7 +182,7 @@ button:disabled { .architecture-grid { background-color: var(--prosights-canvas); - background-image: radial-gradient(color-mix(in srgb, var(--prosights-subtle) 42%, transparent) 1px, transparent 1px); + background-image: radial-gradient(color-mix(in srgb, var(--prosights-subtle) 30%, transparent) 1px, transparent 1px); background-size: 22px 22px; }