mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
Two unrelated UX improvements bundled:
1. Login pages (all of them) now use the TUI-matching dark style.
Swapped the shared monoStyle from white/black to:
- background #0a0a0a
- white body text
- Geist Mono via Google Fonts @import
- accent #7ed3fc on every occurrence of the word "zot"
Applies to: /apikey index, /apikey form, api-key success, oauth
success, oauth error. The three pages that were still white
(index, form, error) now match the TUI's dark look end-to-end.
Input focus ring and button hover flipped to white-on-dark.
2. /logout without an argument opens a picker.
New logout_dialog.go modelled on the existing small-list dialogs
(model picker shape, session picker size). Lists only the
providers the user is actually logged into, each with an
(apikey) or (oauth) tag. When both are logged in, an extra
"all" row is appended. When nothing is stored, /logout reports
"no credentials stored; already logged out" and doesn't open
an empty dialog.
/logout anthropic, /logout openai, /logout all still work
exactly as before (direct, no dialog).
Also includes the user's earlier edit to defaultIdentity:
"operating inside zot, a coding agent harness" rewording.
216 lines
6.7 KiB
Go
216 lines
6.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/patriceckhart/zot/internal/assets"
|
|
)
|
|
|
|
// CallbackResult is what an OAuth callback server returns once the
|
|
// browser hits the redirect URI.
|
|
type CallbackResult struct {
|
|
Code string
|
|
State string
|
|
Err error
|
|
RawPath string
|
|
}
|
|
|
|
// CallbackServer is a single-shot OAuth callback listener bound to the
|
|
// fixed port that the provider has whitelisted for its client.
|
|
type CallbackServer struct {
|
|
l net.Listener
|
|
srv *http.Server
|
|
provider OAuthProvider
|
|
state string
|
|
result chan CallbackResult
|
|
once sync.Once
|
|
}
|
|
|
|
// NewCallbackServer starts a listener on p.RedirectPort/p.RedirectPath.
|
|
// Returns an error if that port is already in use (e.g. another login
|
|
// flow already running, or the official CLI is running concurrently).
|
|
func NewCallbackServer(p OAuthProvider, expectedState string) (*CallbackServer, error) {
|
|
addr := fmt.Sprintf("%s:%d", p.RedirectHost, p.RedirectPort)
|
|
l, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bind %s: %w (is another zot/claude-code/codex login already running?)", addr, err)
|
|
}
|
|
cs := &CallbackServer{
|
|
l: l,
|
|
provider: p,
|
|
state: expectedState,
|
|
result: make(chan CallbackResult, 1),
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(p.RedirectPath, cs.handle)
|
|
mux.HandleFunc("/logo.png", serveLogo)
|
|
cs.srv = &http.Server{
|
|
Handler: mux,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Second,
|
|
}
|
|
go func() { _ = cs.srv.Serve(l) }()
|
|
return cs, nil
|
|
}
|
|
|
|
// URL returns the full redirect URI this server is listening on.
|
|
func (cs *CallbackServer) URL() string { return cs.provider.RedirectURI() }
|
|
|
|
// Result blocks until the callback is received or ctx is cancelled.
|
|
func (cs *CallbackServer) Result(ctx context.Context) (CallbackResult, error) {
|
|
select {
|
|
case r := <-cs.result:
|
|
return r, nil
|
|
case <-ctx.Done():
|
|
return CallbackResult{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Shutdown stops the server. Safe to call more than once.
|
|
func (cs *CallbackServer) Shutdown() {
|
|
cs.once.Do(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
_ = cs.srv.Shutdown(ctx)
|
|
cancel()
|
|
})
|
|
}
|
|
|
|
func (cs *CallbackServer) handle(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
w.Header().Set("content-type", "text/html; charset=utf-8")
|
|
if errParam := q.Get("error"); errParam != "" {
|
|
msg := errParam
|
|
if d := q.Get("error_description"); d != "" {
|
|
msg += ": " + d
|
|
}
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(oauthErrorHTML(msg)))
|
|
cs.deliver(CallbackResult{Err: fmt.Errorf(msg), RawPath: r.URL.RequestURI()})
|
|
return
|
|
}
|
|
code := q.Get("code")
|
|
state := q.Get("state")
|
|
if code == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(oauthErrorHTML("missing authorization code")))
|
|
cs.deliver(CallbackResult{Err: fmt.Errorf("missing code"), RawPath: r.URL.RequestURI()})
|
|
return
|
|
}
|
|
if cs.state != "" && state != cs.state {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(oauthErrorHTML("state mismatch")))
|
|
cs.deliver(CallbackResult{Err: fmt.Errorf("state mismatch"), RawPath: r.URL.RequestURI()})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(oauthSuccessHTML(cs.provider.Name)))
|
|
cs.deliver(CallbackResult{Code: code, State: state, RawPath: r.URL.RequestURI()})
|
|
}
|
|
|
|
func (cs *CallbackServer) deliver(r CallbackResult) {
|
|
select {
|
|
case cs.result <- r:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// ---- static HTML for the callback tab ----
|
|
|
|
// All zot-served pages share a single dark style matching the TUI:
|
|
// near-black background (#0a0a0a), white text, Geist Mono type, cyan
|
|
// accent on the word "zot". Serves every step of both the api-key
|
|
// flow and the oauth callback flow.
|
|
const monoStyle = `<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;600&display=swap');
|
|
:root { color-scheme: dark; }
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
background: #0a0a0a;
|
|
color: #ffffff;
|
|
max-width: 44rem;
|
|
margin: 0 auto;
|
|
padding: 3rem 1.5rem;
|
|
line-height: 1.55;
|
|
}
|
|
.logo {
|
|
display: block;
|
|
width: 120px;
|
|
height: auto;
|
|
image-rendering: pixelated;
|
|
image-rendering: crisp-edges;
|
|
margin: 0 0 1.5rem;
|
|
}
|
|
h1 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin: 0 0 0.25rem;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
h1 .mark { display: inline-block; width: 1.25rem; }
|
|
.zot { color: #7ed3fc; }
|
|
.rule { border: 0; border-top: 1px solid #ffffff; margin: 1.5rem 0; }
|
|
.muted { color: #9ca3af; }
|
|
.mono { font-family: inherit; word-break: break-all; }
|
|
.msg { padding: 0.75rem 0; }
|
|
a { color: #7ed3fc; }
|
|
input[type=password], input[type=text] {
|
|
width: 100%; padding: 0.5rem 0.6rem;
|
|
background: #0a0a0a; color: #ffffff;
|
|
border: 1px solid #ffffff;
|
|
font-family: inherit; font-size: 0.95rem;
|
|
}
|
|
input[type=password]:focus, input[type=text]:focus {
|
|
outline: none; border-color: #7ed3fc;
|
|
}
|
|
button {
|
|
padding: 0.5rem 1.25rem;
|
|
background: #ffffff; color: #0a0a0a;
|
|
border: 1px solid #ffffff; font-family: inherit; font-size: 0.95rem;
|
|
cursor: pointer;
|
|
}
|
|
button:hover { background: #0a0a0a; color: #ffffff; }
|
|
</style>`
|
|
|
|
// logoTag is the <img> element used at the top of every zot-served
|
|
// page. The image bytes are served from /logo.png by the same server.
|
|
const logoTag = `<img class="logo" src="/logo.png" alt="zot" />`
|
|
|
|
// serveLogo writes the embedded PNG with appropriate caching headers.
|
|
// Shared between the oauth callback server and the api-key login server.
|
|
func serveLogo(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("content-type", "image/png")
|
|
w.Header().Set("cache-control", "public, max-age=86400")
|
|
_, _ = w.Write(assets.LogoPNG)
|
|
}
|
|
|
|
func oauthSuccessHTML(provider string) string {
|
|
p := strings.ToLower(provider)
|
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
|
|
` + logoTag + `
|
|
<h1><span class="mark">✓</span> logged in to ` + p + `</h1>
|
|
<hr class="rule">
|
|
<p class="msg"><span class="zot">zot</span> received the callback. you can close this tab.</p>
|
|
</body></html>`
|
|
}
|
|
|
|
func oauthErrorHTML(msg string) string {
|
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
|
|
` + logoTag + `
|
|
<h1><span class="mark">✗</span> login failed</h1>
|
|
<hr class="rule">
|
|
<p class="msg mono">` + htmlEscape(msg) + `</p>
|
|
<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
|
|
</body></html>`
|
|
}
|
|
|
|
func htmlEscape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """)
|
|
return r.Replace(s)
|
|
}
|