Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #83.
This commit is contained in:
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
# Optional: a SearXNG instance the Settings screen suggests as the recipe
|
||||
# search URL until one is saved.
|
||||
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
@@ -53,8 +56,9 @@ make dev
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR) and
|
||||
`MEALQUEST_DEFAULT_TZ`, and
|
||||
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
|
||||
(an absolute `http://` or `https://` URL without credentials, a query or a
|
||||
fragment) and `MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
|
||||
+2
-2
@@ -79,9 +79,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
search, err := recipes.NewSearchSettingsService(db, box)
|
||||
search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err)
|
||||
}
|
||||
|
||||
ai, err := aisettings.New(db, box, cfg.PublicURL.String())
|
||||
|
||||
+4
-4
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# The Authentik provider's client secret.
|
||||
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||
# The SearXNG recipe-search sidecar on the compose network. Planned; not
|
||||
# read by the service yet. The app takes its recipe-search provider and
|
||||
# base URL from PATCH /api/v1/settings/recipe-search; see
|
||||
# deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
|
||||
+16
-8
@@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG
|
||||
instance for recipe search. On the compose network it is reachable at
|
||||
`http://searxng:8080`; that is the address to configure in the app.
|
||||
|
||||
Starting the sidecar does not connect the app to it. The service does not
|
||||
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future
|
||||
wiring, and today it does nothing. The recipe-search provider and base URL
|
||||
live in the `recipe_search_settings` row and are set through
|
||||
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings
|
||||
screen's recipe search; the screen itself is not built yet). Until that row
|
||||
exists, recipe search answers 409 `not_configured`.
|
||||
Starting the sidecar does not connect the app to it. The recipe-search
|
||||
provider and base URL live in the `recipe_search_settings` row and are set
|
||||
through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
|
||||
recipe search card on Settings → AI assistant). Until that row exists,
|
||||
recipe search answers 409 `not_configured`.
|
||||
|
||||
After the first deploy, from a signed-in session (every non-`GET` request
|
||||
`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`.
|
||||
The service only suggests it: `GET` returns it as `default_base_url`, and
|
||||
the recipe search card pre-fills the SearXNG URL with it while no URL is
|
||||
saved. Nothing is stored until the card is saved, and a saved `base_url`
|
||||
always wins. A set value must be an absolute `http://` or `https://` URL
|
||||
without credentials, a query or a fragment, or the service refuses to
|
||||
start; unset, `default_base_url` is `null`.
|
||||
|
||||
To set it up, open Settings → AI assistant, check that the recipe search
|
||||
card shows SearXNG with `http://searxng:8080`, select **Save recipe search**
|
||||
and then **Test**. Or, from a signed-in session (every non-`GET` request
|
||||
needs the `X-MealQuest-CSRF` header set to `csrfToken` from
|
||||
`GET /api/v1/me`):
|
||||
|
||||
|
||||
@@ -149,6 +149,9 @@ automatic** (D-021).
|
||||
the Brave key (`api_key`, write-only: responses carry only
|
||||
`api_key_set` and `api_key_last4`; an empty string removes it). The
|
||||
result must be usable: SearXNG needs a base_url, Brave a key.
|
||||
Responses also carry the read-only `default_base_url`, the deployment's
|
||||
`MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no
|
||||
base_url is saved; only a PATCH of base_url stores a URL.
|
||||
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
|
||||
masala" and returns `{query, count}`, 409 `not_configured`, or 502
|
||||
`search_failed` with the provider's error.
|
||||
|
||||
@@ -35,6 +35,8 @@ const (
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
|
||||
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||
|
||||
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -73,6 +75,13 @@ type Config struct {
|
||||
// validates it only while the settings do not exist yet, and ignores it
|
||||
// once they do. Empty when unset.
|
||||
DefaultTZ string
|
||||
|
||||
// SearXNGURL is the deployment's SearXNG instance, which the Settings
|
||||
// screen suggests while no recipe-search URL is saved. It is optional
|
||||
// and validated by recipes.NewSearchSettingsService: the compose
|
||||
// network's http://searxng:8080 is neither https nor loopback, so the
|
||||
// secure-URL rule does not apply. Empty when unset.
|
||||
SearXNGURL string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
|
||||
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||
|
||||
SearXNGURL: r.optional(EnvSearXNGURL),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
|
||||
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.DefaultTZ != "Europe/London" {
|
||||
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
|
||||
}
|
||||
if cfg.SearXNGURL != "http://searxng:8080" {
|
||||
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
|
||||
type SearchSettings struct {
|
||||
// Provider is "searxng" or "brave", nil until first chosen.
|
||||
Provider *string `json:"provider"`
|
||||
// BaseURL is the SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// BaseURL is the saved SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
// DefaultBaseURL is the deployment's SearXNG URL
|
||||
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
|
||||
// not set. It is never saved by itself: only a PATCH of base_url saves
|
||||
// a URL. Nil when the deployment names none.
|
||||
DefaultBaseURL *string `json:"default_base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// UpdatedAt is nil until the settings are first saved.
|
||||
UpdatedAt *string `json:"updated_at"`
|
||||
}
|
||||
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
|
||||
type SearchSettingsService struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
|
||||
defaultBaseURL string
|
||||
// newProvider is NewSearchProvider; tests point Brave elsewhere.
|
||||
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
|
||||
}
|
||||
|
||||
// NewSearchSettingsService returns the service over st, sealing keys with
|
||||
// box.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
|
||||
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
|
||||
// one must be a valid SearXNG URL.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("recipes: nil store")
|
||||
}
|
||||
if box == nil {
|
||||
return nil, errors.New("recipes: nil secret box")
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
|
||||
if defaultBaseURL != "" {
|
||||
if _, err := NewSearXNG(defaultBaseURL); err != nil {
|
||||
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
|
||||
}
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
|
||||
}
|
||||
|
||||
// Get returns the settings; before any are saved every field is unset.
|
||||
// Get returns the settings; before any are saved every field but
|
||||
// DefaultBaseURL is unset.
|
||||
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
|
||||
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SearchSettings{}, nil
|
||||
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
|
||||
}
|
||||
return searchSettingsFrom(row), nil
|
||||
return s.settingsFrom(row), nil
|
||||
}
|
||||
|
||||
func (s *SearchSettingsService) defaultBase() *string {
|
||||
if s.defaultBaseURL == "" {
|
||||
return nil
|
||||
}
|
||||
u := s.defaultBaseURL
|
||||
return &u
|
||||
}
|
||||
|
||||
// Update applies patch and returns the settings it leaves. The first update
|
||||
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
|
||||
if err != nil {
|
||||
return SearchSettings{}, err
|
||||
}
|
||||
return searchSettingsFrom(saved), nil
|
||||
return s.settingsFrom(saved), nil
|
||||
}
|
||||
|
||||
// apply returns row with patch applied, validated.
|
||||
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
|
||||
return len(cands), nil
|
||||
}
|
||||
|
||||
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
provider, updated := row.Provider, row.UpdatedAt
|
||||
return SearchSettings{
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
DefaultBaseURL: s.defaultBase(),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
const testBraveKey = "BSA-secret-brave-key-9f3c"
|
||||
|
||||
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
t.Helper()
|
||||
return newTestSearchSettingsWithDefault(t, "")
|
||||
}
|
||||
|
||||
// newTestSearchSettingsWithDefault is newTestSearchSettings with
|
||||
// defaultBaseURL as the deployment's SearXNG URL.
|
||||
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
|
||||
t.Helper()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewSearchSettingsService(st, box)
|
||||
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
|
||||
got, err := svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
|
||||
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
|
||||
}
|
||||
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
|
||||
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
|
||||
}
|
||||
|
||||
// The default is a suggestion: SearXNG still needs a saved URL.
|
||||
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
|
||||
t.Error("choosing SearXNG without a URL succeeded with a default set")
|
||||
}
|
||||
|
||||
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
|
||||
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
|
||||
}
|
||||
got, err = svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" {
|
||||
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
|
||||
}
|
||||
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.BaseUrl.String != "https://search.example" {
|
||||
t.Errorf("stored base_url = %q", row.BaseUrl.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
|
||||
if _, err := NewSearchSettingsService(st, box, u); err == nil {
|
||||
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
|
||||
} else if strings.Contains(err.Error(), "pw") {
|
||||
t.Errorf("error for %q echoes the URL: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsTestSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
rec, body := call(http.MethodGet, recipeSearchPath, "")
|
||||
if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false {
|
||||
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
|
||||
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
|
||||
|
||||
@@ -132,7 +132,7 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
|
||||
// configured, over a migrated temp database and a random secret box.
|
||||
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
||||
t.Helper()
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t))
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -80,6 +80,7 @@ describe('App routes', () => {
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
})
|
||||
renderAt('/settings/ai')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
|
||||
|
||||
@@ -251,3 +251,33 @@
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Recipe search card, below the AI assistant's actions. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cardLoading {
|
||||
height: 160px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cardActions > button {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { AI_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { AISettingsPage, formatVerifiedDate, maskedKey } from './AISettingsPage.tsx'
|
||||
import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx'
|
||||
import { maskedKey } from './api.ts'
|
||||
|
||||
const MODELS = {
|
||||
models: [
|
||||
@@ -30,6 +31,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
@@ -422,6 +424,277 @@ describe('AISettingsPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// apiError is an API error response carrying message.
|
||||
function apiError(status: number, code: string, message: string): Response {
|
||||
return jsonResponse(status, { error: { code, message, retryable: false } })
|
||||
}
|
||||
|
||||
const BRAVE_SETTINGS = { ...RECIPE_SEARCH_SETTINGS, provider: 'brave', api_key_set: true, api_key_last4: 'b7c2' }
|
||||
|
||||
describe('Recipe search card', () => {
|
||||
const card = () => screen.getByRole('region', { name: 'Recipe search' })
|
||||
const searxng = () => within(card()).getByRole('radio', { name: 'SearXNG' })
|
||||
const brave = () => within(card()).getByRole('radio', { name: 'Brave' })
|
||||
const url = () => within(card()).getByRole('textbox', { name: 'SearXNG URL' })
|
||||
const saveSearch = () => within(card()).getByRole('button', { name: 'Save recipe search' })
|
||||
const testSearch = () => within(card()).getByRole('button', { name: 'Test' })
|
||||
|
||||
async function renderCard() {
|
||||
await renderPage()
|
||||
await within(card()).findByRole('radiogroup', { name: 'Recipe search provider' })
|
||||
}
|
||||
|
||||
// addKey enters key in the Brave key sheet.
|
||||
function addKey(button: string, key: string) {
|
||||
fireEvent.click(within(card()).getByRole('button', { name: button }))
|
||||
const sheet = screen.getByRole('dialog')
|
||||
fireEvent.change(within(sheet).getByLabelText('Brave API key'), { target: { value: key } })
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Use this key' }))
|
||||
}
|
||||
|
||||
it('shows the saved SearXNG URL', async () => {
|
||||
stubAPI()
|
||||
await renderCard()
|
||||
expect(searxng()).toHaveAttribute('aria-checked', 'true')
|
||||
expect(url()).toHaveValue('http://searxng:8080')
|
||||
expect(within(card()).queryByText('Brave API key')).not.toBeInTheDocument()
|
||||
expect(saveSearch()).toBeDisabled()
|
||||
expect(testSearch()).toBeEnabled()
|
||||
})
|
||||
|
||||
const UNCONFIGURED = {
|
||||
provider: null,
|
||||
base_url: null,
|
||||
default_base_url: 'http://searxng:8080',
|
||||
api_key_set: false,
|
||||
api_key_last4: null,
|
||||
updated_at: null,
|
||||
}
|
||||
|
||||
it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => {
|
||||
const fetchMock = stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED),
|
||||
'PATCH /api/v1/settings/recipe-search': (body) =>
|
||||
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
|
||||
})
|
||||
await renderCard()
|
||||
expect(searxng()).toHaveAttribute('aria-checked', 'true')
|
||||
expect(url()).toHaveValue('http://searxng:8080')
|
||||
expect(testSearch()).toBeDisabled()
|
||||
expect(saveSearch()).toBeEnabled()
|
||||
fireEvent.click(saveSearch())
|
||||
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{
|
||||
request: 'PATCH /api/v1/settings/recipe-search',
|
||||
body: { provider: 'searxng', base_url: 'http://searxng:8080' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('starts with an empty URL when the deployment names no SearXNG', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }),
|
||||
})
|
||||
await renderCard()
|
||||
expect(url()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('shows the saved URL, not the deployment\'s', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () =>
|
||||
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }),
|
||||
})
|
||||
await renderCard()
|
||||
expect(url()).toHaveValue('https://search.example')
|
||||
expect(saveSearch()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('switches providers, keeping each provider\'s field', async () => {
|
||||
stubAPI()
|
||||
await renderCard()
|
||||
fireEvent.change(url(), { target: { value: 'https://search.example' } })
|
||||
fireEvent.click(brave())
|
||||
expect(brave()).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(card()).queryByRole('textbox', { name: 'SearXNG URL' })).not.toBeInTheDocument()
|
||||
expect(within(card()).getByText('No key stored')).toBeInTheDocument()
|
||||
expect(within(card()).getByRole('button', { name: 'Add key' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(searxng())
|
||||
expect(url()).toHaveValue('https://search.example')
|
||||
})
|
||||
|
||||
it('saves a new SearXNG URL', async () => {
|
||||
const fetchMock = stubAPI({
|
||||
'PATCH /api/v1/settings/recipe-search': (body) =>
|
||||
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.change(url(), { target: { value: 'https://search.example' } })
|
||||
expect(testSearch()).toBeDisabled()
|
||||
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
|
||||
fireEvent.click(saveSearch())
|
||||
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{ request: 'PATCH /api/v1/settings/recipe-search', body: { base_url: 'https://search.example' } },
|
||||
])
|
||||
expect(saveSearch()).toBeDisabled()
|
||||
expect(testSearch()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('saves a switch to Brave with its key, then shows only the key\'s last four characters', async () => {
|
||||
const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
|
||||
await renderCard()
|
||||
fireEvent.click(brave())
|
||||
addKey('Add key', 'BSA-secret-key-b7c2')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
|
||||
expect(within(card()).getByText('New key not saved yet')).toBeInTheDocument()
|
||||
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
|
||||
|
||||
fireEvent.click(saveSearch())
|
||||
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{ request: 'PATCH /api/v1/settings/recipe-search', body: { provider: 'brave', api_key: 'BSA-secret-key-b7c2' } },
|
||||
])
|
||||
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
|
||||
expect(within(card()).queryByText('New key not saved yet')).not.toBeInTheDocument()
|
||||
expect(within(card()).getByRole('button', { name: 'Replace' })).toBeInTheDocument()
|
||||
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
|
||||
})
|
||||
|
||||
it('masks a stored Brave key and replaces it', async () => {
|
||||
const fetchMock = stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS),
|
||||
'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, { ...BRAVE_SETTINGS, api_key_last4: '9d0e' }),
|
||||
})
|
||||
await renderCard()
|
||||
expect(brave()).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
|
||||
expect(saveSearch()).toBeDisabled()
|
||||
|
||||
addKey('Replace', 'BSA-other-key-9d0e')
|
||||
fireEvent.click(within(card()).getByRole('button', { name: 'Discard' }))
|
||||
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
|
||||
expect(saveSearch()).toBeDisabled()
|
||||
|
||||
addKey('Replace', 'BSA-other-key-9d0e')
|
||||
fireEvent.click(saveSearch())
|
||||
expect(await within(card()).findByText('••••••••9d0e')).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{ request: 'PATCH /api/v1/settings/recipe-search', body: { api_key: 'BSA-other-key-9d0e' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a save the server refuses', async () => {
|
||||
stubAPI({
|
||||
'PATCH /api/v1/settings/recipe-search': () =>
|
||||
apiError(400, 'invalid_settings', 'invalid recipe search settings: api_key: must be at least 8 characters'),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.click(brave())
|
||||
addKey('Add key', 'short')
|
||||
fireEvent.click(saveSearch())
|
||||
expect(await within(card()).findByRole('alert')).toHaveTextContent(
|
||||
'Not saved: invalid recipe search settings: api_key: must be at least 8 characters.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows how many recipes the test found', async () => {
|
||||
const fetchMock = stubAPI({
|
||||
'POST /api/v1/settings/recipe-search/test': () =>
|
||||
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.click(testSearch())
|
||||
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/recipe-search/test', body: undefined }])
|
||||
})
|
||||
|
||||
it('shows the error when the test fails', async () => {
|
||||
stubAPI({
|
||||
'POST /api/v1/settings/recipe-search/test': () =>
|
||||
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.click(testSearch())
|
||||
expect(await within(card()).findByRole('alert')).toHaveTextContent(
|
||||
'Test failed: recipes: SearXNG answered 403 Forbidden',
|
||||
)
|
||||
})
|
||||
|
||||
it('clears a test result when the settings are edited', async () => {
|
||||
stubAPI({
|
||||
'POST /api/v1/settings/recipe-search/test': () =>
|
||||
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.click(testSearch())
|
||||
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
|
||||
fireEvent.change(url(), { target: { value: 'https://search.example' } })
|
||||
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
|
||||
expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears a test error when the settings are edited', async () => {
|
||||
stubAPI({
|
||||
'POST /api/v1/settings/recipe-search/test': () =>
|
||||
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.click(testSearch())
|
||||
expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed')
|
||||
fireEvent.click(brave())
|
||||
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
|
||||
expect(within(card()).queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks before leaving with an unsaved recipe search change', async () => {
|
||||
stubAPI()
|
||||
await renderCard()
|
||||
fireEvent.change(url(), { target: { value: 'https://search.example' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||
expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not count the suggested SearXNG URL as an unsaved change until it is edited', async () => {
|
||||
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
|
||||
await renderCard()
|
||||
const unload = () => {
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
act(() => void window.dispatchEvent(event))
|
||||
return event.defaultPrevented
|
||||
}
|
||||
expect(saveSearch()).toBeEnabled()
|
||||
expect(unload()).toBe(false)
|
||||
fireEvent.change(url(), { target: { value: 'https://search.example' } })
|
||||
expect(unload()).toBe(true)
|
||||
fireEvent.change(url(), { target: { value: 'http://searxng:8080' } })
|
||||
expect(unload()).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the untouched first-use card without asking', async () => {
|
||||
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
|
||||
await renderCard()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||
})
|
||||
|
||||
it('keeps 44 px tap targets', async () => {
|
||||
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
|
||||
await renderCard()
|
||||
for (const control of [
|
||||
searxng(),
|
||||
brave(),
|
||||
within(card()).getByRole('button', { name: 'Replace' }),
|
||||
testSearch(),
|
||||
saveSearch(),
|
||||
]) {
|
||||
expectMinHitArea(control)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('maskedKey', () => {
|
||||
it('shows only the last four characters', () => {
|
||||
expect(maskedKey('3f9a')).toBe('••••••••3f9a')
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
aiKeyPolicy,
|
||||
aiProviderLabels,
|
||||
maskedKey,
|
||||
providersWithBaseURL,
|
||||
useAIModels,
|
||||
useAISettings,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type AISettingsPatch,
|
||||
} from './api.ts'
|
||||
import styles from './AISettingsPage.module.css'
|
||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||
|
||||
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
|
||||
const TYPE_MODEL = '\u0000type'
|
||||
@@ -33,9 +35,12 @@ const TYPE_MODEL = '\u0000type'
|
||||
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
|
||||
// system prompt are edited as a draft and saved together; the API key is
|
||||
// stored (and verified) on its own, straight away. The client only ever
|
||||
// sees the key's last four characters.
|
||||
// sees the key's last four characters. Recipe search is its own card, saved
|
||||
// on its own.
|
||||
export function AISettingsPage() {
|
||||
const settings = useAISettings()
|
||||
const [aiDirty, setAIDirty] = useState(false)
|
||||
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
@@ -45,11 +50,38 @@ export function AISettingsPage() {
|
||||
AI settings could not load: {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{settings.isSuccess && <AISettingsForm saved={settings.data} />}
|
||||
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={setAIDirty} />}
|
||||
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
|
||||
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// LeaveGuard asks before leaving the screen with unsaved changes.
|
||||
function LeaveGuard({ dirty }: { dirty: boolean }) {
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}, [dirty])
|
||||
return (
|
||||
<Confirm
|
||||
open={blocker.state === 'blocked'}
|
||||
title="Leave without saving?"
|
||||
confirmLabel="Leave"
|
||||
cancelLabel="Keep editing"
|
||||
onCancel={() => blocker.reset?.()}
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
>
|
||||
Your changes on this screen have not been saved.
|
||||
</Confirm>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
@@ -95,7 +127,13 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
|
||||
return patch
|
||||
}
|
||||
|
||||
function AISettingsForm({ saved }: { saved: AISettings }) {
|
||||
type AISettingsFormProps = {
|
||||
saved: AISettings
|
||||
// Called with whether the form holds unsaved changes.
|
||||
onDirtyChange: (dirty: boolean) => void
|
||||
}
|
||||
|
||||
function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
|
||||
const [draft, setDraft] = useState(() => draftOf(saved))
|
||||
const [typingModel, setTypingModel] = useState(false)
|
||||
const [confirmingReset, setConfirmingReset] = useState(false)
|
||||
@@ -116,15 +154,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
|
||||
const listed = canList && models.isSuccess ? models.data : []
|
||||
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
|
||||
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}, [dirty])
|
||||
onDirtyChange(dirty)
|
||||
}, [dirty, onDirtyChange])
|
||||
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||
|
||||
function edit(change: Partial<Draft>) {
|
||||
save.reset()
|
||||
@@ -285,26 +318,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
|
||||
>
|
||||
This restores MealQuest's default system prompt for everyone in the household.
|
||||
</Confirm>
|
||||
<Confirm
|
||||
open={blocker.state === 'blocked'}
|
||||
title="Leave without saving?"
|
||||
confirmLabel="Leave"
|
||||
cancelLabel="Keep editing"
|
||||
onCancel={() => blocker.reset?.()}
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
>
|
||||
Your changes to the AI assistant have not been saved.
|
||||
</Confirm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// maskedKey shows the stored key as dots and its last four characters, all
|
||||
// the client ever knows of it.
|
||||
export function maskedKey(last4: string): string {
|
||||
return `••••••••${last4}`
|
||||
}
|
||||
|
||||
// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1".
|
||||
export function formatVerifiedDate(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { ApiError } from '../api/index.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
maskedKey,
|
||||
recipeSearchProviderLabels,
|
||||
useRecipeSearchSettings,
|
||||
useSaveRecipeSearchSettings,
|
||||
useTestRecipeSearch,
|
||||
type RecipeSearchProvider,
|
||||
type RecipeSearchSettings,
|
||||
type RecipeSearchSettingsPatch,
|
||||
} from './api.ts'
|
||||
import styles from './AISettingsPage.module.css'
|
||||
|
||||
const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({
|
||||
value: value as RecipeSearchProvider,
|
||||
label,
|
||||
}))
|
||||
|
||||
type RecipeSearchCardProps = {
|
||||
// Called with whether the card holds unsaved changes.
|
||||
onDirtyChange: (dirty: boolean) => void
|
||||
}
|
||||
|
||||
// RecipeSearchCard is where recipes are searched for: a SearXNG instance or
|
||||
// Brave. The provider, SearXNG URL and a new Brave key are edited as a draft
|
||||
// and saved together; the client only ever sees the stored key's last four
|
||||
// characters. Test searches with the saved settings.
|
||||
export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) {
|
||||
const settings = useRecipeSearchSettings()
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Card className={styles.card} aria-labelledby={titleId} role="region">
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
Recipe search
|
||||
</h2>
|
||||
<p className={styles.hint}>Where MealQuest looks for real recipes behind each Side quest.</p>
|
||||
{settings.isPending && <Skeleton className={styles.cardLoading} />}
|
||||
{settings.isError && (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Recipe search settings could not load: {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{settings.isSuccess && <RecipeSearchForm saved={settings.data} onDirtyChange={onDirtyChange} />}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
provider: RecipeSearchProvider
|
||||
base_url: string
|
||||
// A new Brave key to store, null when the stored one stays.
|
||||
api_key: string | null
|
||||
}
|
||||
|
||||
// draftOf starts a household that has not set up recipe search on SearXNG,
|
||||
// the sidecar every deployment runs. With no URL saved the field holds the
|
||||
// deployment's SearXNG URL, which saving stores.
|
||||
function draftOf(saved: RecipeSearchSettings): Draft {
|
||||
return {
|
||||
provider: saved.provider ?? 'searxng',
|
||||
base_url: saved.base_url ?? saved.default_base_url ?? '',
|
||||
api_key: null,
|
||||
}
|
||||
}
|
||||
|
||||
// patchOf is the PATCH body for what draft changes. Only the chosen
|
||||
// provider's field is sent: the other provider's is left as it is.
|
||||
function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch {
|
||||
const patch: RecipeSearchSettingsPatch = {}
|
||||
if (draft.provider !== saved.provider) patch.provider = draft.provider
|
||||
if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url
|
||||
if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key
|
||||
return patch
|
||||
}
|
||||
|
||||
function sameDraft(a: Draft, b: Draft): boolean {
|
||||
return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key
|
||||
}
|
||||
|
||||
function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) {
|
||||
const [draft, setDraft] = useState(() => draftOf(saved))
|
||||
const save = useSaveRecipeSearchSettings()
|
||||
const test = useTestRecipeSearch()
|
||||
const patch = patchOf(saved, draft)
|
||||
const dirty = Object.keys(patch).length > 0
|
||||
// Only the household's own edits are unsaved changes. Before recipe search
|
||||
// is set up the untouched draft already differs from what is saved — it
|
||||
// suggests SearXNG and the deployment's URL — but leaving it loses nothing.
|
||||
const edited = dirty && !sameDraft(draft, draftOf(saved))
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(edited)
|
||||
}, [edited, onDirtyChange])
|
||||
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||
|
||||
// An edit clears the last save and test results: they describe the saved
|
||||
// settings, not the draft.
|
||||
function edit(change: Partial<Draft>) {
|
||||
save.reset()
|
||||
test.reset()
|
||||
setDraft((current) => ({ ...current, ...change }))
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save.mutate(patch, {
|
||||
onSuccess: (settings) => {
|
||||
setDraft(draftOf(settings))
|
||||
test.reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Segmented
|
||||
label="Recipe search provider"
|
||||
options={providerOptions}
|
||||
value={draft.provider}
|
||||
onValueChange={(provider) => edit({ provider })}
|
||||
/>
|
||||
{draft.provider === 'searxng' ? (
|
||||
<TextInput
|
||||
label="SearXNG URL"
|
||||
type="url"
|
||||
className={styles.mono}
|
||||
value={draft.base_url}
|
||||
placeholder="https://"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => edit({ base_url: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<BraveKey
|
||||
last4={saved.api_key_last4}
|
||||
newKey={draft.api_key}
|
||||
onKey={(api_key) => edit({ api_key })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.status} role="status">
|
||||
{save.isSuccess && !dirty && 'Recipe search saved.'}
|
||||
{test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
|
||||
</div>
|
||||
{save.isError && (
|
||||
<p className={styles.error} role="alert">
|
||||
{saveErrorMessage(save.error)}
|
||||
</p>
|
||||
)}
|
||||
{test.isError && (
|
||||
<p className={styles.error} role="alert">
|
||||
{testErrorMessage(test.error)}
|
||||
</p>
|
||||
)}
|
||||
{dirty && saved.provider !== null && <p className={styles.note}>Save your changes to test them.</p>}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={dirty || saved.provider === null || test.isPending}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{test.isPending ? 'Testing…' : 'Test'}
|
||||
</Button>
|
||||
<Button disabled={!dirty || save.isPending} onClick={onSave}>
|
||||
{save.isPending ? 'Saving…' : 'Save recipe search'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type BraveKeyProps = {
|
||||
last4: string | null
|
||||
newKey: string | null
|
||||
onKey: (key: string | null) => void
|
||||
}
|
||||
|
||||
// BraveKey shows the stored key masked, or the new one waiting to be saved,
|
||||
// and takes a new key in a sheet. The key is sent only when the card is
|
||||
// saved.
|
||||
function BraveKey({ last4, newKey, onKey }: BraveKeyProps) {
|
||||
const [replacing, setReplacing] = useState(false)
|
||||
const [key, setKey] = useState('')
|
||||
const labelId = useId()
|
||||
const sheetTitleId = useId()
|
||||
|
||||
function close() {
|
||||
setReplacing(false)
|
||||
setKey('')
|
||||
}
|
||||
|
||||
let shown = 'No key stored'
|
||||
if (newKey !== null) shown = maskedKey(newKey.slice(-4))
|
||||
else if (last4 !== null) shown = maskedKey(last4)
|
||||
|
||||
return (
|
||||
<div className={styles.key}>
|
||||
<span id={labelId} className={styles.label}>
|
||||
Brave API key
|
||||
</span>
|
||||
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
|
||||
<span className={styles.keyValue}>{shown}</span>
|
||||
<button type="button" className={styles.replace} onClick={() => setReplacing(true)}>
|
||||
{last4 === null && newKey === null ? 'Add key' : 'Replace'}
|
||||
</button>
|
||||
</div>
|
||||
{newKey !== null && (
|
||||
<div className={styles.keyStatus}>
|
||||
<p className={styles.note}>New key not saved yet</p>
|
||||
<button type="button" className={styles.link} onClick={() => onKey(null)}>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
|
||||
<form
|
||||
className={styles.sheet}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onKey(key)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<h2 id={sheetTitleId} className={styles.sheetTitle}>
|
||||
{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}
|
||||
</h2>
|
||||
<p className={styles.sheetText}>
|
||||
The key is stored on the server when you save recipe search. This device only ever sees its last four
|
||||
characters.
|
||||
</p>
|
||||
<TextInput
|
||||
label="Brave API key"
|
||||
type="password"
|
||||
className={styles.mono}
|
||||
value={key}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
/>
|
||||
<div className={styles.sheetActions}>
|
||||
<Button variant="outline" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={key === ''}>
|
||||
Use this key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function saveErrorMessage(error: Error): string {
|
||||
if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.`
|
||||
return `Not saved: ${error.message}`
|
||||
}
|
||||
|
||||
// testErrorMessage says why POST /settings/recipe-search/test failed, by
|
||||
// the error code the server names (internal/server/recipesearch.go).
|
||||
function testErrorMessage(error: Error): string {
|
||||
if (error instanceof ApiError && error.code === 'not_configured') {
|
||||
return 'Test failed: recipe search is not set up yet.'
|
||||
}
|
||||
return `Test failed: ${error.message}`
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export const AI_MODELS_PATH = '/api/v1/settings/ai/models'
|
||||
export const AI_KEY_PATH = '/api/v1/settings/ai/api-key'
|
||||
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
|
||||
export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt'
|
||||
export const RECIPE_SEARCH_PATH = '/api/v1/settings/recipe-search'
|
||||
export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
|
||||
// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's
|
||||
// dinners read API. Until it lands the server answers 404 and the Regular
|
||||
// dinners row says the count is unavailable.
|
||||
@@ -192,6 +194,111 @@ export function useResetAIPrompt() {
|
||||
return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined)
|
||||
}
|
||||
|
||||
// recipeSearchProviderLabels names every recipe search provider the server
|
||||
// accepts (internal/recipes/searchsettings.go).
|
||||
export const recipeSearchProviderLabels = {
|
||||
searxng: 'SearXNG',
|
||||
brave: 'Brave',
|
||||
} as const
|
||||
|
||||
export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels
|
||||
|
||||
// RecipeSearchSettings is GET /api/v1/settings/recipe-search. The Brave API
|
||||
// key itself is never sent: only whether one is stored and its last four
|
||||
// characters.
|
||||
export type RecipeSearchSettings = {
|
||||
// Null until recipe search is first set up.
|
||||
provider: RecipeSearchProvider | null
|
||||
// The saved SearXNG instance's URL, null when none is set.
|
||||
base_url: string | null
|
||||
// The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while
|
||||
// base_url is null; null when the deployment names none. Only saving
|
||||
// base_url stores a URL.
|
||||
default_base_url: string | null
|
||||
api_key_set: boolean
|
||||
api_key_last4: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
// RecipeSearchSettingsPatch is the PATCH /api/v1/settings/recipe-search
|
||||
// body: only the fields that change. api_key is the Brave key in plain text.
|
||||
export type RecipeSearchSettingsPatch = {
|
||||
provider?: RecipeSearchProvider
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
}
|
||||
|
||||
// RecipeSearchTest is POST /api/v1/settings/recipe-search/test: how many
|
||||
// recipes the saved provider found for query.
|
||||
export type RecipeSearchTest = { query: string; count: number }
|
||||
|
||||
function isRecipeSearchProvider(value: string): value is RecipeSearchProvider {
|
||||
return Object.hasOwn(recipeSearchProviderLabels, value)
|
||||
}
|
||||
|
||||
function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSettings {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
!isStringOrNull(body.provider) ||
|
||||
!isStringOrNull(body.base_url) ||
|
||||
!isStringOrNull(body.default_base_url) ||
|
||||
typeof body.api_key_set !== 'boolean' ||
|
||||
!isStringOrNull(body.api_key_last4) ||
|
||||
!isStringOrNull(body.updated_at)
|
||||
) {
|
||||
throw new Error(`${where}: response is not the recipe search settings`)
|
||||
}
|
||||
if (body.provider !== null && !isRecipeSearchProvider(body.provider)) {
|
||||
throw new Error(`${where}: unknown recipe search provider "${body.provider}"`)
|
||||
}
|
||||
return {
|
||||
provider: body.provider,
|
||||
base_url: body.base_url,
|
||||
default_base_url: body.default_base_url,
|
||||
api_key_set: body.api_key_set,
|
||||
api_key_last4: body.api_key_last4,
|
||||
updated_at: body.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
const RECIPE_SEARCH_KEY = ['settings', 'recipe-search'] as const
|
||||
|
||||
export function useRecipeSearchSettings() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: RECIPE_SEARCH_KEY,
|
||||
queryFn: async () =>
|
||||
parseRecipeSearchSettings(`GET ${RECIPE_SEARCH_PATH}`, await apiFetch<unknown>(RECIPE_SEARCH_PATH)),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveRecipeSearchSettings() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (patch: RecipeSearchSettingsPatch) =>
|
||||
parseRecipeSearchSettings(
|
||||
`PATCH ${RECIPE_SEARCH_PATH}`,
|
||||
await apiFetch<unknown>(RECIPE_SEARCH_PATH, { method: 'PATCH', body: patch }),
|
||||
),
|
||||
onSuccess: (settings) => queryClient.setQueryData(RECIPE_SEARCH_KEY, settings),
|
||||
})
|
||||
}
|
||||
|
||||
// useTestRecipeSearch searches with the saved settings.
|
||||
export function useTestRecipeSearch() {
|
||||
const { apiFetch } = useApi()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = await apiFetch<unknown>(RECIPE_SEARCH_TEST_PATH, { method: 'POST' })
|
||||
if (!isRecord(body) || typeof body.query !== 'string' || typeof body.count !== 'number') {
|
||||
throw new Error(`POST ${RECIPE_SEARCH_TEST_PATH}: response is not a search test result`)
|
||||
}
|
||||
return { query: body.query, count: body.count } satisfies RecipeSearchTest
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
|
||||
@@ -321,6 +428,12 @@ export function notificationsStatus(status: NotificationsStatus | null): string
|
||||
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
|
||||
}
|
||||
|
||||
// maskedKey shows a stored key as dots and its last four characters, all
|
||||
// the client ever knows of it.
|
||||
export function maskedKey(last4: string): string {
|
||||
return `••••••••${last4}`
|
||||
}
|
||||
|
||||
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
|
||||
export function formatClockTime(time: string): string {
|
||||
const match = clockTime.exec(time)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
@@ -25,11 +25,11 @@ function renderAt(path: string, width: number) {
|
||||
const resize = mockViewport(width)
|
||||
stubFetchByPath(SETTINGS_API)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<InDataRouter path={path}>
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
return resize
|
||||
}
|
||||
|
||||
@@ -16,6 +16,17 @@ export const AI_SETTINGS = {
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
}
|
||||
|
||||
// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the
|
||||
// SearXNG sidecar, no Brave key stored.
|
||||
export const RECIPE_SEARCH_SETTINGS = {
|
||||
provider: 'searxng',
|
||||
base_url: 'http://searxng:8080',
|
||||
default_base_url: 'http://searxng:8080',
|
||||
api_key_set: false,
|
||||
api_key_last4: null,
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
}
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// today's server does: the dinners, Google Calendar and notifications status
|
||||
// endpoints do not exist yet.
|
||||
@@ -25,6 +36,7 @@ export const SETTINGS_API = {
|
||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
}
|
||||
|
||||
// ApiProviders gives the pages under test what App gives them: an API client
|
||||
|
||||
Reference in New Issue
Block a user