Let her choose her own pair
Raised by the user, not by the plan: there was no way to change language in the mobile UI. There was no way anywhere. `users.pair_lang` has been readable since Phase 19 and writable by nobody — /api/me was GET-only and Upsert deliberately skips the column — which is also why "no pt-PT account exists yet" has stood through two phases. Nothing could create one. PATCH /api/me answers with the whole user rather than 204, so the client re-reads the pair from the server instead of trusting its own request. One write reaches everything: langpack, Hunspell dictionary, Piper voice, lexicon provider and prompt language all read the column at use time. The server refuses a pair it has no copy for, and auth.shippedPairs is deliberately not internal/llm's list. That one names pairs the prompts can talk about (fr and es, since Phase 19); this one names pairs Petal can render itself in, which needs a langpack. Storing fr today would strand her on Chinese with no way back except a lucky guess at a button she cannot read. The picker sits in the sidebar footer because the sidebar is the mobile drawer — always one tap away. The status bar exists only while a document is open, which is the wrong moment to find the app speaking a language you can't read. Each language names itself, 中文 and Português: the one place bilingual copy would get in the way. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// patchMe drives UpdateMeHandler as the given user would reach it: behind the
|
||||
// middleware, which is the only thing that puts an id in the context.
|
||||
func patchMe(t *testing.T, users *UserStore, id, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPatch, "/me", strings.NewReader(body))
|
||||
r = r.WithContext(WithUser(r.Context(), id))
|
||||
w := httptest.NewRecorder()
|
||||
users.UpdateMeHandler()(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestSetPairLang(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
if err := users.SetPairLang("bob", "pt-PT"); err != nil {
|
||||
t.Fatalf("set pt-PT: %v", err)
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "pt-PT" {
|
||||
t.Fatalf("pair_lang = %q, want pt-PT", u.PairLang)
|
||||
}
|
||||
|
||||
// And back — a writer who tries a pair and doesn't like it must be able to
|
||||
// return, which is the whole reason the picker exists.
|
||||
if err := users.SetPairLang("bob", "zh"); err != nil {
|
||||
t.Fatalf("set zh: %v", err)
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
||||
t.Fatalf("pair_lang = %q, want zh", u.PairLang)
|
||||
}
|
||||
}
|
||||
|
||||
// A pair the frontend has no langpack for must not be storable. Accepting it
|
||||
// would leave her looking at Chinese copy with no way back except a lucky guess.
|
||||
func TestSetPairLangRejectsUnshippedPairs(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
for _, lang := range []string{"fr", "es", "pt-BR", "klingon", "", " "} {
|
||||
if err := users.SetPairLang("bob", lang); err == nil {
|
||||
t.Fatalf("stored unshipped pair %q", lang)
|
||||
}
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
||||
t.Fatalf("a refused write still moved pair_lang to %q", u.PairLang)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPairLangUnknownUser(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
if err := users.SetPairLang("nobody", "pt-PT"); err == nil {
|
||||
t.Fatal("set a pair language on an account that does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMeHandler(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
w := patchMe(t, users, "bob", `{"pair_lang":"pt-PT"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// The whole user comes back, so the client can re-read the pair from the
|
||||
// server instead of assuming its request took.
|
||||
var got db.User
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.ID != "bob" || got.PairLang != "pt-PT" {
|
||||
t.Fatalf("response = %+v, want bob on pt-PT", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMeHandlerRejects(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"unshipped pair": `{"pair_lang":"fr"}`,
|
||||
"missing field": `{}`,
|
||||
"not json": `pt-PT`,
|
||||
} {
|
||||
if w := patchMe(t, users, "bob", body); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s: status = %d, want 400", name, w.Code)
|
||||
}
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
||||
t.Fatalf("a rejected request still moved pair_lang to %q", u.PairLang)
|
||||
}
|
||||
|
||||
// A caller the middleware never resolved (or whose row is gone) is a lapsed
|
||||
// session, not a bad request — the client turns 401 into the sign-in overlay.
|
||||
if w := patchMe(t, users, "nobody", `{"pair_lang":"pt-PT"}`); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unknown user: status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -68,6 +69,85 @@ func (u *UserStore) MeHandler() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// The pairs a writer may actually choose, in the order the picker offers them.
|
||||
//
|
||||
// This is deliberately *not* internal/llm's list of languages. That one names
|
||||
// every pair the prompts know how to talk about, which is a cheap thing to add;
|
||||
// this one names the pairs Petal can render itself in, which requires a langpack
|
||||
// on the frontend. Accepting a code with no pack would leave her looking at
|
||||
// Chinese with no way back except another guess, so the server refuses it. fr
|
||||
// and es join this list on the day their packs land, not before.
|
||||
var shippedPairs = []string{"zh", "pt-PT"}
|
||||
|
||||
func pairIsShipped(lang string) bool {
|
||||
for _, p := range shippedPairs {
|
||||
if p == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetPairLang moves an account to another (English + X) pair.
|
||||
func (u *UserStore) SetPairLang(id, lang string) error {
|
||||
if !pairIsShipped(lang) {
|
||||
return errors.New("auth: unshipped pair language " + lang)
|
||||
}
|
||||
res, err := u.db.Exec(`UPDATE users SET pair_lang = ? WHERE id = ?`, lang, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMeHandler changes the caller's own settings — today, the one setting
|
||||
// there is: which language Petal speaks alongside her English.
|
||||
//
|
||||
// It answers with the whole updated user rather than an empty 204 so the client
|
||||
// has one shape to trust: /api/me and this return the same thing, and the app
|
||||
// re-reads the pair from the response instead of assuming its request took.
|
||||
//
|
||||
// The pair language reaches further than the UI copy — it picks her Hunspell
|
||||
// dictionary, her read-aloud voice, which word-lookup provider answers, and the
|
||||
// language the prompts ask the model to explain in. All of those read
|
||||
// `users.pair_lang` at use time, so all of them follow from this one write.
|
||||
func (u *UserStore) UpdateMeHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
PairLang string `json:"pair_lang"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
httputil.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
lang := strings.TrimSpace(body.PairLang)
|
||||
if !pairIsShipped(lang) {
|
||||
// Name the ones that work. A writer who lands here has picked from a
|
||||
// stale client, and "not a language" tells her nothing.
|
||||
httputil.BadRequest(w, "unsupported language pair — Petal speaks "+strings.Join(shippedPairs, ", "))
|
||||
return
|
||||
}
|
||||
id := UserID(r.Context())
|
||||
if err := u.SetPairLang(id, lang); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
user, err := u.Get(id)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
|
||||
// Allowlist decides which of Authentik's users may write in this Petal.
|
||||
// Authentik fronts several applications; being a valid user there does not mean
|
||||
// being a user here.
|
||||
|
||||
Reference in New Issue
Block a user