From 195731ba17bd433503fdb4892718ded94c0d3674 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 17:32:20 +0200 Subject: [PATCH 01/59] chore(deps): adopt authkit 0.10.0 --- go.mod | 2 +- go.sum | 4 ++-- internal/server/graphql.go | 2 +- internal/server/server.go | 3 ++- internal/server/spa.go | 2 +- internal/server/streams.go | 3 ++- internal/server/tokens.go | 4 ++-- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 5df5ccb2..055041cf 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-chi/chi/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/gopherium/gouncer/authkit v0.9.0 + github.com/gopherium/gouncer/authkit v0.10.0 github.com/gopherium/gouncer/authkit/postgres v0.7.0 github.com/gopherium/gouncer/authkit/ratelimit v0.3.0 github.com/gopherium/pluginkit v0.5.0 diff --git a/go.sum b/go.sum index 975b91d3..df896bde 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherium/gouncer v0.3.0 h1:57DDOJ/GYYCEtcSScPOBYZ06TabGR164Cw7edhT3ME8= github.com/gopherium/gouncer v0.3.0/go.mod h1:WwPOu8LyRikot0EODySSu46zaHcWTPbl5nOes4I3H2U= -github.com/gopherium/gouncer/authkit v0.9.0 h1:/HnNLb4cVoQWZCSCDyOn0HWgWHKglS/GScvy3eqa3ME= -github.com/gopherium/gouncer/authkit v0.9.0/go.mod h1:aQtcQewsZa/DJK7HBa3Jh0lCRLKOo8JMFtJAeC2Rzh4= +github.com/gopherium/gouncer/authkit v0.10.0 h1:K1+xOxVH4+3ThCi6QnHsfpzAz5RLK9DZeHX+3xrllxw= +github.com/gopherium/gouncer/authkit v0.10.0/go.mod h1:aQtcQewsZa/DJK7HBa3Jh0lCRLKOo8JMFtJAeC2Rzh4= github.com/gopherium/gouncer/authkit/postgres v0.7.0 h1:FiraBa4X5bfNPpKpGa/CTAXRaXn02+Qd7qOeiWnMgMI= github.com/gopherium/gouncer/authkit/postgres v0.7.0/go.mod h1:ceZbaPTkuMQ1aYEQT5LtgsiXnl+3ku20+drL9XEnDWk= github.com/gopherium/gouncer/authkit/ratelimit v0.3.0 h1:K4e8kwzO9XRJB95TErMVV4CuskTtG2c2CwL7nLAI/Ko= diff --git a/internal/server/graphql.go b/internal/server/graphql.go index fc460240..283b53a3 100644 --- a/internal/server/graphql.go +++ b/internal/server/graphql.go @@ -168,7 +168,7 @@ func withOperationGuards(next http.Handler, operations, streams graphPolicy) htt user := authkit.IdentityFromContext(r.Context()) if !policy.limiter.acquire(user.ID) { w.Header().Set("Retry-After", strconv.Itoa(retryAfterSeconds(policy.retryAfter))) - authkit.RespondError(w, http.StatusTooManyRequests, policy.overflow) + authkit.RespondError(w, http.StatusTooManyRequests, authkit.ErrorResponse{Message: policy.overflow}) return } defer policy.limiter.release(user.ID) diff --git a/internal/server/server.go b/internal/server/server.go index 55d55da8..280a326e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -131,7 +131,8 @@ func inArea(area string, next http.Handler) http.Handler { token, carried := credential.TokenOf(r.Context()) writes := changesState(r.Method) if carried && !token.Scopes.Allows(area, writes) { - authkit.RespondError(w, http.StatusForbidden, "scope required: "+area+":"+accessOf(writes)) + authkit.RespondError(w, http.StatusForbidden, + authkit.ErrorResponse{Message: "scope required: " + area + ":" + accessOf(writes)}) return } next.ServeHTTP(w, r) diff --git a/internal/server/spa.go b/internal/server/spa.go index e9b03383..c27f2319 100644 --- a/internal/server/spa.go +++ b/internal/server/spa.go @@ -17,7 +17,7 @@ func spaHandler(webFS fs.FS) http.HandlerFunc { fileServer := http.FileServerFS(webFS) return func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { - authkit.RespondError(w, http.StatusNotFound, "not found") + authkit.RespondError(w, http.StatusNotFound, authkit.ErrorResponse{Message: "not found"}) return } name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") diff --git a/internal/server/streams.go b/internal/server/streams.go index 85e8173e..ab9b5614 100644 --- a/internal/server/streams.go +++ b/internal/server/streams.go @@ -76,7 +76,8 @@ func (s *server) boundPluginRequest(next http.Handler) http.Handler { user := authkit.IdentityFromContext(r.Context()) if !s.streams.acquire(user.ID) { w.Header().Set("Retry-After", strconv.Itoa(int(s.maxStreamLifetime.Seconds()))) - authkit.RespondError(w, http.StatusTooManyRequests, "too many concurrent requests") + authkit.RespondError(w, http.StatusTooManyRequests, + authkit.ErrorResponse{Message: "too many concurrent requests"}) return } defer s.streams.release(user.ID) diff --git a/internal/server/tokens.go b/internal/server/tokens.go index d641d0b4..0036e5a4 100644 --- a/internal/server/tokens.go +++ b/internal/server/tokens.go @@ -63,11 +63,11 @@ func (s *server) identifyIdentity(next http.Handler) http.Handler { func (s *server) identifyBearer(w http.ResponseWriter, r *http.Request, next http.Handler, secret string) { ctx, err := s.identityForToken(r.Context(), secret) if isUnusableToken(err) { - authkit.RespondError(w, http.StatusUnauthorized, "invalid token") + authkit.RespondError(w, http.StatusUnauthorized, authkit.ErrorResponse{Message: "invalid token"}) return } if err != nil { - authkit.RespondError(w, http.StatusInternalServerError, "internal error") + authkit.RespondError(w, http.StatusInternalServerError, authkit.ErrorResponse{Message: "internal error"}) return } next.ServeHTTP(w, r.WithContext(ctx)) From c67c1f76d886ce13ed0502ff1927945f11cd4a41 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 17:32:44 +0200 Subject: [PATCH 02/59] feat(graphres): name the reason and its data on every refused answer --- internal/graphres/errors.go | 73 ++++++++++++++++++++++- internal/graphres/errors_internal_test.go | 73 +++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 internal/graphres/errors_internal_test.go diff --git a/internal/graphres/errors.go b/internal/graphres/errors.go index 07a9f2cc..bf5ca185 100644 --- a/internal/graphres/errors.go +++ b/internal/graphres/errors.go @@ -63,6 +63,67 @@ var notFoundErrors = []error{ apitoken.ErrNotFound, } +// reasonsFor names the stable reason and the data each refused sentinel answers with. +var reasonsFor = []struct { + sentinel error + reason string + meta map[string]any +}{ + {contact.ErrEmptyName, "contact_name_required", nil}, + {contact.ErrEmptyChannel, "identity_channel_required", nil}, + {contact.ErrEmptyIdentifier, "identity_identifier_required", nil}, + {contact.ErrChannelNotWritable, "channel_not_writable", nil}, + {task.ErrEmptyTitle, "task_title_required", nil}, + {task.ErrInvalidPriority, "task_priority_unknown", nil}, + {task.ErrInvalidStatus, "task_status_unknown", nil}, + {task.ErrUnattributedOrigin, "origin_source_required", nil}, + {webhook.ErrInvalidURL, "webhook_url_invalid", nil}, + {webhook.ErrNoEvents, "webhook_events_required", nil}, + {event.ErrUnknownName, "event_unknown", nil}, + {scalar.ErrInvalid, "value_malformed", nil}, + {cursor.ErrMalformed, "cursor_malformed", nil}, + {errExactlyOneTaskFilter, "task_filter_choice_required", nil}, + {errInvalidFirst, "first_out_of_range", map[string]any{"min": 1, "max": maxPageSize}}, + {authkit.ErrSelfDisable, "self_disable_refused", nil}, + {authkit.ErrSelfRole, "self_role_refused", nil}, + {gouncer.ErrLastPrivileged, "last_privileged_refused", nil}, + {role.ErrLastAdmin, "last_privileged_refused", nil}, + {role.ErrBeyondReach, "role_beyond_reach", nil}, + {role.ErrUnknownTier, "role_unknown", nil}, + {apitoken.ErrEmptyName, "token_name_required", nil}, + {apitoken.ErrMalformedScope, "scope_malformed", nil}, + {apitoken.ErrNoScopes, "scopes_required", nil}, + {apitoken.ErrUnknownArea, "area_unknown", nil}, + {apitoken.ErrNegativeLifetime, "lifetime_negative", nil}, + {apitoken.ErrLifetimeTooLong, "lifetime_too_long", map[string]any{"maxDays": apitoken.MaxLifetimeDays}}, + {contact.ErrNotFound, "contact_not_found", nil}, + {contact.ErrIdentityNotFound, "identity_not_found", nil}, + {task.ErrNotFound, "task_not_found", nil}, + {webhook.ErrNotFound, "webhook_not_found", nil}, + {apitoken.ErrNotFound, "token_not_found", nil}, +} + +// withReason records the stable reason and its data on a presented error. +func withReason(presented *gqlerror.Error, reason string, meta map[string]any) { + if reason == "" { + return + } + presented.Extensions["reason"] = reason + if len(meta) > 0 { + presented.Extensions["meta"] = meta + } +} + +// reasonOf returns the table entry a refused sentinel answers with. +func reasonOf(err error) (string, map[string]any) { + for _, held := range reasonsFor { + if errors.Is(err, held.sentinel) { + return held.reason, held.meta + } + } + return "", nil +} + // spokenAs names every brick error in the deployment's own voice. var spokenAs = []struct { sentinel error @@ -114,6 +175,7 @@ func applySpecialCode(presented *gqlerror.Error, err error) bool { presented.Message = conflict.Error() withCode(presented, "CONFLICT") presented.Extensions["ownerContactId"] = conflict.OwnerID.String() + withReason(presented, "identity_taken", map[string]any{"ownerContactId": conflict.OwnerID.String()}) return true } var limited rateLimitedError @@ -121,10 +183,12 @@ func applySpecialCode(presented *gqlerror.Error, err error) bool { presented.Message = limited.Error() withCode(presented, "RATE_LIMITED") presented.Extensions["retryAfter"] = int(limited.retryAfter.Seconds()) + withReason(presented, "rate_limited", map[string]any{"retryAfter": int(limited.retryAfter.Seconds())}) return true } if errors.Is(err, authkit.ErrInvalidCredentials) { withCode(presented, "UNAUTHENTICATED") + withReason(presented, "credentials_invalid", nil) return true } return false @@ -134,15 +198,20 @@ func applySpecialCode(presented *gqlerror.Error, err error) bool { func applyListCode(presented *gqlerror.Error, err error) bool { if anyIs(err, validationErrors) { withCode(presented, "VALIDATION") + named, meta := reasonOf(err) + withReason(presented, named, meta) return true } if anyIs(err, notFoundErrors) { withCode(presented, "NOT_FOUND") + named, meta := reasonOf(err) + withReason(presented, named, meta) return true } - if status, message, ok := authkit.StatusForAuthError(err); ok { - presented.Message = message + if status, response, ok := authkit.ErrorResponseForAuthError(err); ok { + presented.Message = response.Message withCode(presented, codeForStatus(status)) + withReason(presented, response.Code, response.Meta) return true } return false diff --git a/internal/graphres/errors_internal_test.go b/internal/graphres/errors_internal_test.go new file mode 100644 index 00000000..c1d8aca8 --- /dev/null +++ b/internal/graphres/errors_internal_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package graphres + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit" + + "github.com/gopherium/alphone/internal/contact" + "github.com/gopherium/alphone/internal/role" +) + +func TestEveryRefusedSentinelNamesAReason(t *testing.T) { + t.Parallel() + + sentinels := append(append([]error{}, validationErrors...), notFoundErrors...) + for _, sentinel := range sentinels { + presented := PresentError(context.Background(), fmt.Errorf("wrap: %w", sentinel)) + named, _ := presented.Extensions["reason"].(string) + if named == "" { + t.Errorf("%v names no reason, want every refused sentinel naming one", sentinel) + } + } +} + +func TestBothLastPrivilegedSentinelsShareOneReason(t *testing.T) { + t.Parallel() + + core := PresentError(context.Background(), fmt.Errorf("wrap: %w", role.ErrLastAdmin)) + brick := PresentError(context.Background(), fmt.Errorf("wrap: %w", gouncer.ErrLastPrivileged)) + + if core.Extensions["reason"] != brick.Extensions["reason"] { + t.Errorf("reasons %v and %v differ, want one condition named once", + core.Extensions["reason"], brick.Extensions["reason"]) + } +} + +func TestEverySpecialPathNamesItsReason(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + reason string + }{ + {"identity taken", contact.IdentityExistsError{OwnerID: uuid.Nil}, "identity_taken"}, + {"rate limited", rateLimitedError{retryAfter: 30 * time.Second}, "rate_limited"}, + {"bad credentials", authkit.ErrInvalidCredentials, "credentials_invalid"}, + } + for _, tc := range cases { + presented := PresentError(context.Background(), fmt.Errorf("wrap: %w", tc.err)) + if got := presented.Extensions["reason"]; got != tc.reason { + t.Errorf("%s reason = %v, want %q", tc.name, got, tc.reason) + } + } +} + +func TestABrickSentinelSpeaksItsOwnReason(t *testing.T) { + t.Parallel() + + presented := PresentError(context.Background(), fmt.Errorf("wrap: %w", gouncer.ErrEmailTaken)) + + if got, want := presented.Extensions["reason"], "email_taken"; got != want { + t.Errorf("reason = %v, want %v, the brick already names its conditions", got, want) + } +} From ece29db3dccc060e3162e696036a751e1852c3b0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 17:41:44 +0200 Subject: [PATCH 03/59] feat(sdk): let a plugin name the reason and data its error carries --- sdk/grapherror.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/grapherror.go b/sdk/grapherror.go index 2336895b..208774b7 100644 --- a/sdk/grapherror.go +++ b/sdk/grapherror.go @@ -6,6 +6,10 @@ package sdk type GraphError struct { // Code is the extensions code the presenter reports. Code string + // Reason is the stable snake_case name a client translates the error by. + Reason string + // Meta carries the named values the reason's message interpolates. + Meta map[string]any // Extensions carries extra extension fields beside the code. Extensions map[string]any // Err is the underlying error whose message the client reads. From 2e4faacec4125f6de309932374e683890832dedb Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 17:42:09 +0200 Subject: [PATCH 04/59] feat(graphres): name the reason on the gate answers and plugin errors --- internal/graphres/errors.go | 1 + internal/graphres/errors_internal_test.go | 22 +++++++++++++++++ internal/graphres/gate.go | 2 +- internal/graphres/scope.go | 11 +++++++-- internal/graphres/scopegate_test.go | 30 +++++++++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/internal/graphres/errors.go b/internal/graphres/errors.go index bf5ca185..fec4ba4b 100644 --- a/internal/graphres/errors.go +++ b/internal/graphres/errors.go @@ -165,6 +165,7 @@ func applySpecialCode(presented *gqlerror.Error, err error) bool { var coded sdk.GraphError if errors.As(err, &coded) { withCode(presented, coded.Code) + withReason(presented, coded.Reason, coded.Meta) for key, value := range coded.Extensions { presented.Extensions[key] = value } diff --git a/internal/graphres/errors_internal_test.go b/internal/graphres/errors_internal_test.go index c1d8aca8..538312f7 100644 --- a/internal/graphres/errors_internal_test.go +++ b/internal/graphres/errors_internal_test.go @@ -15,6 +15,7 @@ import ( "github.com/gopherium/alphone/internal/contact" "github.com/gopherium/alphone/internal/role" + "github.com/gopherium/alphone/sdk" ) func TestEveryRefusedSentinelNamesAReason(t *testing.T) { @@ -62,6 +63,27 @@ func TestEverySpecialPathNamesItsReason(t *testing.T) { } } +func TestAPluginErrorCarriesItsOwnReason(t *testing.T) { + t.Parallel() + + raised := sdk.GraphError{ + Code: "VALIDATION", + Reason: "field_name_malformed", + Meta: map[string]any{"name": "Not CamelCase"}, + Err: fmt.Errorf("fields: a name is camelCase"), + } + + presented := PresentError(context.Background(), raised) + + if got := presented.Extensions["reason"]; got != "field_name_malformed" { + t.Errorf("reason = %v, want the plugin's own reason carried through", got) + } + meta, _ := presented.Extensions["meta"].(map[string]any) + if meta["name"] != "Not CamelCase" { + t.Errorf("meta = %v, want the plugin's data beside the reason", meta) + } +} + func TestABrickSentinelSpeaksItsOwnReason(t *testing.T) { t.Parallel() diff --git a/internal/graphres/gate.go b/internal/graphres/gate.go index 0517e4b2..aa89b51c 100644 --- a/internal/graphres/gate.go +++ b/internal/graphres/gate.go @@ -46,6 +46,6 @@ func onlyLoginFields(selections ast.SelectionSet) bool { func unauthenticatedError() *gqlerror.Error { return &gqlerror.Error{ Message: "authentication required", - Extensions: map[string]any{"code": "UNAUTHENTICATED"}, + Extensions: map[string]any{"code": "UNAUTHENTICATED", "reason": "authentication_required"}, } } diff --git a/internal/graphres/scope.go b/internal/graphres/scope.go index 72e7e5b2..47a71d4f 100644 --- a/internal/graphres/scope.go +++ b/internal/graphres/scope.go @@ -174,6 +174,8 @@ func capabilityError(needed string, lacked role.Capability) graphql.ResponseHand "code": "UNAUTHORIZED", "scope": needed, "capability": string(lacked), + "reason": "capability_missing", + "meta": map[string]any{"scope": needed, "capability": string(lacked)}, }, }}}) } @@ -181,8 +183,13 @@ func capabilityError(needed string, lacked role.Capability) graphql.ResponseHand // refusedWith answers one operation with the message and the scope the refused field wanted. func refusedWith(message, needed string) graphql.ResponseHandler { return graphql.OneShot(&graphql.Response{Errors: gqlerror.List{&gqlerror.Error{ - Message: message, - Extensions: map[string]any{"code": "UNAUTHORIZED", "scope": needed}, + Message: message, + Extensions: map[string]any{ + "code": "UNAUTHORIZED", + "scope": needed, + "reason": "scope_missing", + "meta": map[string]any{"scope": needed}, + }, }}}) } diff --git a/internal/graphres/scopegate_test.go b/internal/graphres/scopegate_test.go index 9f2c9a01..317fe9d3 100644 --- a/internal/graphres/scopegate_test.go +++ b/internal/graphres/scopegate_test.go @@ -205,6 +205,36 @@ func TestScopeGateLetsAnAccountHoldingNoRoleWorkTheProduct(t *testing.T) { } } +func TestScopeGateNamesTheReasonACapabilityIsMissing(t *testing.T) { + t.Parallel() + + answered := gatedAsRole(t, `mutation { createUser }`, role.Member) + + extensions := answered.Errors[0].Extensions + if got := extensions["reason"]; got != "capability_missing" { + t.Errorf("reason = %v, want capability_missing", got) + } + meta, _ := extensions["meta"].(map[string]any) + if meta["capability"] != "manage_users" { + t.Errorf("meta = %v, want the missing capability named as data", meta) + } +} + +func TestScopeGateNamesTheReasonAScopeIsMissing(t *testing.T) { + t.Parallel() + + answered := gatedAsTokenOf(t, `mutation { createContact }`, apitoken.Scopes{"tasks:write"}, role.Admin) + + extensions := answered.Errors[0].Extensions + if got := extensions["reason"]; got != "scope_missing" { + t.Errorf("reason = %v, want scope_missing", got) + } + meta, _ := extensions["meta"].(map[string]any) + if meta["scope"] != "contacts:write" { + t.Errorf("meta = %v, want the missing scope named as data", meta) + } +} + func TestScopeGateRefusesUserManagementToAnAccountHoldingNoRole(t *testing.T) { t.Parallel() From 5c3fc17b371cb49f42081033d7c73dcfc2cdad99 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 17:42:26 +0200 Subject: [PATCH 05/59] feat(plugins): name the reason each refused path answers with --- plugins/fields/graphql.go | 37 ++++++++++++++++--- plugins/fields/graphql_internal_test.go | 14 ++++++++ plugins/importer/commit_internal_test.go | 45 ++++-------------------- plugins/importer/graphql.go | 22 ++++++------ plugins/whatsapp/graphql.go | 3 +- plugins/whatsapp/send.go | 8 +++-- plugins/whatsapp/send_test.go | 13 +++++++ 7 files changed, 85 insertions(+), 57 deletions(-) diff --git a/plugins/fields/graphql.go b/plugins/fields/graphql.go index 88fb6823..0cb3a11e 100644 --- a/plugins/fields/graphql.go +++ b/plugins/fields/graphql.go @@ -72,11 +72,11 @@ func (m MutationResolvers) DefineField( ) (*model.FieldDefinition, error) { definition, err := newDefinition(name, label, string(declared), reservedNames) if err != nil { - return nil, sdk.GraphError{Code: "VALIDATION", Err: err} + return nil, sdk.GraphError{Code: "VALIDATION", Reason: fieldReason(err), Err: err} } if err := m.plugin.store.define(ctx, definition); err != nil { if errors.Is(err, errNameTaken) || errors.Is(err, errKindLocked) { - return nil, sdk.GraphError{Code: "CONFLICT", Err: err} + return nil, sdk.GraphError{Code: "CONFLICT", Reason: fieldReason(err), Err: err} } return nil, err } @@ -111,11 +111,11 @@ func (m MutationResolvers) WriteContactFields( ) (bool, error) { given, ok := values.(map[string]any) if !ok { - return false, sdk.GraphError{Code: "VALIDATION", Err: errValuesNotAnObject} + return false, sdk.GraphError{Code: "VALIDATION", Reason: fieldReason(errValuesNotAnObject), Err: errValuesNotAnObject} } checked, err := checkValues(m.plugin.catalog.liveKinds(), given) if err != nil { - return false, sdk.GraphError{Code: "VALIDATION", Err: err} + return false, sdk.GraphError{Code: "VALIDATION", Reason: fieldReason(err), Err: err} } if err := m.plugin.store.writeValues(ctx, contactID, checked); err != nil { return false, err @@ -127,7 +127,7 @@ func (m MutationResolvers) WriteContactFields( func (m MutationResolvers) ArchiveField(ctx context.Context, id uuid.UUID) (bool, error) { if err := m.plugin.store.archive(ctx, id); err != nil { if errors.Is(err, errNoDefinition) { - return false, sdk.GraphError{Code: "NOT_FOUND", Err: err} + return false, sdk.GraphError{Code: "NOT_FOUND", Reason: fieldReason(err), Err: err} } return false, err } @@ -136,3 +136,30 @@ func (m MutationResolvers) ArchiveField(ctx context.Context, id uuid.UUID) (bool } return true, nil } + +// fieldReasons names the stable reason each fields sentinel answers with. +var fieldReasons = []struct { + sentinel error + reason string +}{ + {errMalformedName, "field_name_malformed"}, + {errUnknownKind, "field_kind_unknown"}, + {errBlankLabel, "field_label_required"}, + {errReservedName, "field_name_reserved"}, + {errNameTaken, "field_name_taken"}, + {errKindLocked, "field_kind_locked"}, + {errNoDefinition, "field_not_found"}, + {errWrongKind, "value_kind_mismatch"}, + {errNoField, "field_unknown"}, + {errValuesNotAnObject, "values_not_an_object"}, +} + +// fieldReason returns the stable reason a fields error answers with. +func fieldReason(err error) string { + for _, held := range fieldReasons { + if errors.Is(err, held.sentinel) { + return held.reason + } + } + return "" +} diff --git a/plugins/fields/graphql_internal_test.go b/plugins/fields/graphql_internal_test.go index 146b5351..a2f918af 100644 --- a/plugins/fields/graphql_internal_test.go +++ b/plugins/fields/graphql_internal_test.go @@ -9,8 +9,22 @@ import ( "github.com/google/uuid" "github.com/gopherium/alphone/graph/model" + "github.com/gopherium/alphone/sdk" ) +func TestDefineFieldNamesTheReasonItRefuses(t *testing.T) { + t.Parallel() + + p := newClosedPlugin(t) + + _, err := (MutationResolvers{plugin: p}).DefineField(t.Context(), "Not Camel", "Label", model.FieldKindDate) + + var raised sdk.GraphError + if !errors.As(err, &raised) || raised.Reason != "field_name_malformed" { + t.Errorf("error = %v, want the malformed name named as a reason", err) + } +} + // errCatalogue is the failure a wedged catalogue reload reports. var errCatalogue = errors.New("catalogue unavailable") diff --git a/plugins/importer/commit_internal_test.go b/plugins/importer/commit_internal_test.go index 6b03d4f0..1eb35ef0 100644 --- a/plugins/importer/commit_internal_test.go +++ b/plugins/importer/commit_internal_test.go @@ -3,50 +3,19 @@ package importer import ( + "errors" "testing" - "github.com/google/go-cmp/cmp" + "github.com/gopherium/alphone/sdk" ) -func TestCellAtIgnoresAnIndexTheRowCannotAnswer(t *testing.T) { +func TestClassifyClaimErrorNamesTheReason(t *testing.T) { t.Parallel() - cells := []string{"Maria Perez", "maria@example.com"} + classified := classifyClaimError(errNoMapping) - tests := map[string]string{ - "past the row": "9", - "negative": "-1", - "not a number": "first", - } - for name, index := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - if got := cellAt(cells, index); got != "" { - t.Errorf("cellAt(%q) = %q, want an empty string", index, got) - } - }) - } -} - -func TestDraftOfSplitsTheNameFromTheIdentities(t *testing.T) { - t.Parallel() - - cells := []string{"Maria Perez", "maria@example.com", ""} - assigned := mapping{"0": fieldContactName, "1": fieldEmail, "2": fieldPhone} - - got := draftOf(cells, assigned) - - if got.name != "Maria Perez" { - t.Errorf("name = %q, want Maria Perez", got.name) - } - if len(got.identities) != 1 { - t.Fatalf("identities = %d, want the blank phone left out", len(got.identities)) - } - if diff := cmp.Diff("maria@example.com", got.identities[0].Identifier); diff != "" { - t.Errorf("identifier mismatch (-want +got):\n%s", diff) - } - if string(got.identities[0].Channel) != string(fieldEmail) { - t.Errorf("channel = %q, want %q", got.identities[0].Channel, fieldEmail) + var raised sdk.GraphError + if !errors.As(classified, &raised) || raised.Reason != "mapping_required" { + t.Errorf("error = %v, want the missing mapping named as a reason", classified) } } diff --git a/plugins/importer/graphql.go b/plugins/importer/graphql.go index 9a4d7891..d41b5c90 100644 --- a/plugins/importer/graphql.go +++ b/plugins/importer/graphql.go @@ -32,7 +32,8 @@ func graphRowLimit(limit *int) (int, error) { return maxRows, nil } if *limit < 1 || *limit > maxRows { - return 0, sdk.GraphError{Code: "VALIDATION", Err: errInvalidRowLimit} + return 0, sdk.GraphError{Code: "VALIDATION", Reason: "first_out_of_range", + Meta: map[string]any{"min": 1, "max": maxRows}, Err: errInvalidRowLimit} } return *limit, nil } @@ -84,7 +85,7 @@ func toGraphRow(staged stagedRow) *model.ImportRow { func (p *Plugin) loadImportJob(ctx context.Context, id uuid.UUID) (importRow, error) { stored, err := p.store.importByID(ctx, id) if errors.Is(err, pgx.ErrNoRows) { - return importRow{}, sdk.GraphError{Code: "NOT_FOUND", Err: errImportNotFound} + return importRow{}, sdk.GraphError{Code: "NOT_FOUND", Reason: "import_not_found", Err: errImportNotFound} } if err != nil { return importRow{}, err @@ -201,18 +202,19 @@ func (m MutationResolvers) ImportUpload( ) (*model.ImportJob, error) { uploader, ok := sdk.UserFromContext(ctx) if !ok { - return nil, sdk.GraphError{Code: "UNAUTHENTICATED", Err: errNoUploader} + return nil, sdk.GraphError{Code: "UNAUTHENTICATED", Reason: "authentication_required", Err: errNoUploader} } data, err := io.ReadAll(io.LimitReader(file.File, maxUploadBytes+1)) if err != nil { return nil, fmt.Errorf("importer: read upload: %w", err) } if len(data) > maxUploadBytes { - return nil, sdk.GraphError{Code: "VALIDATION", Err: errTooLarge} + return nil, sdk.GraphError{Code: "VALIDATION", Reason: "file_too_large", + Meta: map[string]any{"maxBytes": maxUploadBytes}, Err: errTooLarge} } parsed, err := Parse(data) if err != nil { - return nil, sdk.GraphError{Code: "VALIDATION", Err: err} + return nil, sdk.GraphError{Code: "VALIDATION", Reason: "file_unreadable", Err: err} } stored, err := m.plugin.store.insertImport(ctx, uploader, filepath.Base(file.Filename), parsed) if err != nil { @@ -230,7 +232,7 @@ func (m MutationResolvers) ImportSetMapping( return nil, err } if stored.State != stateReady { - return nil, sdk.GraphError{Code: "CONFLICT", Err: errMappingLocked} + return nil, sdk.GraphError{Code: "CONFLICT", Reason: "mapping_locked", Err: errMappingLocked} } known, err := m.plugin.registry(ctx) if err != nil { @@ -238,7 +240,7 @@ func (m MutationResolvers) ImportSetMapping( } assigned, err := buildMapping(toAssignments(assignments), len(stored.Columns), known) if err != nil { - return nil, sdk.GraphError{Code: "VALIDATION", Err: err} + return nil, sdk.GraphError{Code: "VALIDATION", Reason: "mapping_invalid", Err: err} } if err := m.plugin.store.updateMapping(ctx, stored.ID, assigned); err != nil { return nil, err @@ -269,7 +271,7 @@ func (m MutationResolvers) ImportCommit( return nil, err } if err := checkEntry(stored, known); err != nil { - return nil, sdk.GraphError{Code: "VALIDATION", Err: err} + return nil, sdk.GraphError{Code: "VALIDATION", Reason: "mapping_invalid", Err: err} } claimed, err := m.plugin.store.claimForCommit(ctx, stored.ID) if err != nil { @@ -295,9 +297,9 @@ func (m MutationResolvers) ImportCommit( func classifyClaimError(err error) error { switch { case errors.Is(err, errAlreadyCommitted): - return sdk.GraphError{Code: "CONFLICT", Err: err} + return sdk.GraphError{Code: "CONFLICT", Reason: "already_committed", Err: err} case errors.Is(err, errNoMapping), errors.Is(err, pgx.ErrNoRows): - return sdk.GraphError{Code: "VALIDATION", Err: errNoMapping} + return sdk.GraphError{Code: "VALIDATION", Reason: "mapping_required", Err: errNoMapping} } return err } diff --git a/plugins/whatsapp/graphql.go b/plugins/whatsapp/graphql.go index bb624405..289e1395 100644 --- a/plugins/whatsapp/graphql.go +++ b/plugins/whatsapp/graphql.go @@ -27,7 +27,8 @@ func graphListLimit(limit *int) (int, error) { return defaultListLimit, nil } if *limit < 1 || *limit > maxListLimit { - return 0, sdk.GraphError{Code: "VALIDATION", Err: errInvalidListLimit} + return 0, sdk.GraphError{Code: "VALIDATION", Reason: "first_out_of_range", + Meta: map[string]any{"min": 1, "max": maxListLimit}, Err: errInvalidListLimit} } return *limit, nil } diff --git a/plugins/whatsapp/send.go b/plugins/whatsapp/send.go index afb66541..56cc2cd0 100644 --- a/plugins/whatsapp/send.go +++ b/plugins/whatsapp/send.go @@ -126,11 +126,13 @@ var ( func (p *Plugin) sendMessage(ctx context.Context, conversationID uuid.UUID, content string) (messageRow, error) { trimmed := strings.TrimSpace(content) if trimmed == "" { - return messageRow{}, sdk.GraphError{Code: "VALIDATION", Err: errEmptyMessageContent} + return messageRow{}, sdk.GraphError{ + Code: "VALIDATION", Reason: "message_content_required", Err: errEmptyMessageContent, + } } to, err := p.store.conversationExternalID(ctx, conversationID) if errors.Is(err, pgx.ErrNoRows) { - return messageRow{}, sdk.GraphError{Code: "NOT_FOUND", Err: errConversationNotFound} + return messageRow{}, sdk.GraphError{Code: "NOT_FOUND", Reason: "conversation_not_found", Err: errConversationNotFound} } if err != nil { return messageRow{}, err @@ -154,7 +156,7 @@ func (p *Plugin) sendMessage(ctx context.Context, conversationID uuid.UUID, cont // upstreamError classifies a Cloud API send failure, carrying any rejection code. func upstreamError(err error) error { - coded := sdk.GraphError{Code: "UPSTREAM", Err: err} + coded := sdk.GraphError{Code: "UPSTREAM", Reason: "upstream_failed", Err: err} var rejection graphError if errors.As(err, &rejection) { coded.Extensions = map[string]any{"metaCode": rejection.Code} diff --git a/plugins/whatsapp/send_test.go b/plugins/whatsapp/send_test.go index b3379ecd..97f7e44d 100644 --- a/plugins/whatsapp/send_test.go +++ b/plugins/whatsapp/send_test.go @@ -86,6 +86,19 @@ func sendMessage( return p.MutationResolvers().WhatsAppSendMessage(t.Context(), conversationID, content) } +func TestSendMessageNamesTheReasonForEmptyContent(t *testing.T) { + t.Parallel() + + p, _ := newSendingPlugin(t, nil) + + _, err := sendMessage(t, p, uuid.Must(uuid.NewV7()), "") + + var raised sdk.GraphError + if !errors.As(err, &raised) || raised.Reason != "message_content_required" { + t.Errorf("error = %v, want the empty content named as a reason", err) + } +} + // mustSend sends one reply, failing the test on any error. func mustSend( t *testing.T, p *whatsapp.Plugin, conversationID uuid.UUID, content string, From 766642012ef82eaedabe27695d8904e0b559f0aa Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 18:25:27 +0200 Subject: [PATCH 06/59] docs: catalogue every reason a refused operation names --- .../src/content/docs/reference/graphql-api.md | 106 +++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index d0467e06..d4f01375 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -132,7 +132,9 @@ Three operations need the `manage_users` capability: `createUser`, "extensions": { "code": "UNAUTHORIZED", "scope": "users:write", - "capability": "manage_users" + "capability": "manage_users", + "reason": "capability_missing", + "meta": { "scope": "users:write", "capability": "manage_users" } } } ], @@ -297,7 +299,7 @@ A refused input looks like this: { "message": "contact: empty name", "path": ["createContact"], - "extensions": { "code": "VALIDATION" } + "extensions": { "code": "VALIDATION", "reason": "contact_name_required" } } ], "data": null @@ -307,6 +309,106 @@ A refused input looks like this: `path` names the field that failed, which matters when one operation asks for several. +### Reasons + +Beside the coarse `code`, a refused operation names a `reason`, a short fixed +name for the exact condition, and `meta`, the values its message mentions. A +client should match on `reason` and read `meta`, never parse the message. The +message can be reworded, a reason never is. An `INTERNAL` error names no +reason, its message and shape are deliberately bare. + +```json +{ + "errors": [ + { + "message": "graph: first must be between 1 and 200", + "extensions": { + "code": "VALIDATION", + "reason": "first_out_of_range", + "meta": { "min": 1, "max": 200 } + } + } + ], + "data": null +} +``` + +The reasons the core answers with: + +| Reason | Meta | When | +| ------ | ---- | ---- | +| `authentication_required` | | no usable credential | +| `credentials_invalid` | | a login that did not match | +| `rate_limited` | `retryAfter` | too many attempts | +| `scope_missing` | `scope` | the token lacks the area | +| `capability_missing` | `scope`, `capability` | the role lacks the capability | +| `contact_name_required` | | a contact needs a name | +| `identity_channel_required` | | an identity needs a channel | +| `identity_identifier_required` | | an identity needs an identifier | +| `identity_taken` | `ownerContactId` | the identity belongs to another contact | +| `channel_not_writable` | | the channel accepts no writes | +| `identity_not_found` | | the id names no identity | +| `contact_not_found` | | the id names no contact | +| `task_title_required` | | a task needs a title | +| `task_priority_unknown` | | the priority is not one AlphOne knows | +| `task_status_unknown` | | the status is not one AlphOne knows | +| `task_filter_choice_required` | | tasks take exactly one filter | +| `task_not_found` | | the id names no task | +| `origin_source_required` | | an origin event needs a source | +| `event_unknown` | | the event name is not one AlphOne knows | +| `webhook_url_invalid` | | the webhook URL does not parse | +| `webhook_events_required` | | a webhook needs at least one event | +| `webhook_not_found` | | the id names no webhook | +| `first_out_of_range` | `min`, `max` | the page size is outside the range | +| `cursor_malformed` | | the cursor is not one a field issued | +| `value_malformed` | | a scalar did not parse | +| `token_name_required` | | a token needs a name | +| `token_not_found` | | the id names no token | +| `scope_malformed` | | a scope is area colon access | +| `scopes_required` | | a scoped token needs at least one | +| `area_unknown` | | the area is not one the schema declares | +| `lifetime_negative` | | a lifetime is zero or more days | +| `lifetime_too_long` | `maxDays` | the lifetime is past the cap | +| `email_invalid` | | the address does not parse | +| `email_taken` | | the address belongs to another account | +| `name_required` | | an account needs a name | +| `name_too_long` | `max` | the name is past the cap | +| `password_too_short` | `min` | the password is under the floor | +| `password_too_long` | `max` | the password is past the cap | +| `user_not_found` | | the id names no account | +| `self_disable_refused` | | nobody disables its own account | +| `self_role_refused` | | nobody changes its own role | +| `last_privileged_refused` | | the last account able to manage users stays | +| `role_beyond_reach` | | the role holds more than the caller does | +| `role_unknown` | | the role is not one the deployment names | + +The stock plugins add their own: + +| Reason | Meta | When | +| ------ | ---- | ---- | +| `field_name_malformed` | | a field name is camelCase | +| `field_label_required` | | a field needs a label | +| `field_kind_unknown` | | the kind is not one the plugin knows | +| `field_name_reserved` | | the name is already a column of the type | +| `field_name_taken` | | another definition holds the name | +| `field_kind_locked` | | an archived definition pins the kind | +| `field_not_found` | | the id names no live definition | +| `field_unknown` | | no live definition holds the name | +| `value_kind_mismatch` | | the value does not match the declared kind | +| `values_not_an_object` | | values arrive as an object of names | +| `message_content_required` | | a message needs text | +| `conversation_not_found` | | the id names no conversation | +| `upstream_failed` | | the messaging platform did not accept | +| `import_not_found` | | the id names no import | +| `file_too_large` | `maxBytes` | the upload is past the cap | +| `file_unreadable` | | the file is not a CSV or spreadsheet AlphOne reads | +| `mapping_invalid` | | the mapping does not fit the columns | +| `mapping_required` | | committing needs a mapping first | +| `mapping_locked` | | the import no longer accepts a mapping | +| `already_committed` | | the import was committed before | + +A plugin you install may add more, each documented by the plugin. + ## Limits | Limit | Value | From d72b549862ac88eb5a8cd20646acba215a24a6d2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 18:49:29 +0200 Subject: [PATCH 07/59] feat(postgres): store a per-user setting under its key --- internal/postgres/db/models.go | 7 ++ internal/postgres/db/queries.sql.go | 48 +++++++++++ .../migrations/00015_create_user_settings.sql | 13 +++ internal/postgres/queries.sql | 10 +++ internal/postgres/usersettings.go | 44 ++++++++++ internal/postgres/usersettings_test.go | 82 +++++++++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 internal/postgres/migrations/00015_create_user_settings.sql create mode 100644 internal/postgres/usersettings.go create mode 100644 internal/postgres/usersettings_test.go diff --git a/internal/postgres/db/models.go b/internal/postgres/db/models.go index 80c211eb..21593498 100644 --- a/internal/postgres/db/models.go +++ b/internal/postgres/db/models.go @@ -62,6 +62,13 @@ type CoreTenantMember struct { CreatedAt time.Time } +type CoreUserSetting struct { + UserID uuid.UUID + Key string + Value string + CreatedAt time.Time +} + type CoreWebhookDelivery struct { ID uuid.UUID SubscriptionID uuid.UUID diff --git a/internal/postgres/db/queries.sql.go b/internal/postgres/db/queries.sql.go index 42a2cee8..ecba7c7c 100644 --- a/internal/postgres/db/queries.sql.go +++ b/internal/postgres/db/queries.sql.go @@ -835,6 +835,23 @@ func (q *Queries) RevokeAPIToken(ctx context.Context, arg RevokeAPITokenParams) return result.RowsAffected(), nil } +const setUserSetting = `-- name: SetUserSetting :exec +INSERT INTO core.user_settings (user_id, key, value) +VALUES ($1::uuid, $2::text, $3::text) +ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value +` + +type SetUserSettingParams struct { + UserID uuid.UUID + Key string + Value string +} + +func (q *Queries) SetUserSetting(ctx context.Context, arg SetUserSettingParams) error { + _, err := q.db.Exec(ctx, setUserSetting, arg.UserID, arg.Key, arg.Value) + return err +} + const settleWebhookDelivery = `-- name: SettleWebhookDelivery :exec UPDATE core.webhook_deliveries SET status = $2, deliver_after = $3, last_error = $4 @@ -956,3 +973,34 @@ func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (CoreTas ) return i, err } + +const userSetting = `-- name: UserSetting :many +SELECT value +FROM core.user_settings +WHERE user_id = $1::uuid AND key = $2::text +` + +type UserSettingParams struct { + UserID uuid.UUID + Key string +} + +func (q *Queries) UserSetting(ctx context.Context, arg UserSettingParams) ([]string, error) { + rows, err := q.db.Query(ctx, userSetting, arg.UserID, arg.Key) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, err + } + items = append(items, value) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/postgres/migrations/00015_create_user_settings.sql b/internal/postgres/migrations/00015_create_user_settings.sql new file mode 100644 index 00000000..c208d66f --- /dev/null +++ b/internal/postgres/migrations/00015_create_user_settings.sql @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: Elastic-2.0 + +-- +goose Up +CREATE TABLE core.user_settings ( + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + key text NOT NULL, + value text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, key) +); + +-- +goose Down +DROP TABLE core.user_settings; diff --git a/internal/postgres/queries.sql b/internal/postgres/queries.sql index 6ea2aeb3..5d621e67 100644 --- a/internal/postgres/queries.sql +++ b/internal/postgres/queries.sql @@ -198,3 +198,13 @@ LEFT JOIN core.tenant_members m ON m.tenant_id = t.id AND m.user_id = @user_id:: WHERE m.user_id IS NOT NULL OR t.id = @default_id::uuid ORDER BY m.user_id IS NOT NULL DESC LIMIT 1; + +-- name: UserSetting :many +SELECT value +FROM core.user_settings +WHERE user_id = @user_id::uuid AND key = @key::text; + +-- name: SetUserSetting :exec +INSERT INTO core.user_settings (user_id, key, value) +VALUES (@user_id::uuid, @key::text, @value::text) +ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value; diff --git a/internal/postgres/usersettings.go b/internal/postgres/usersettings.go new file mode 100644 index 00000000..11c7f8d7 --- /dev/null +++ b/internal/postgres/usersettings.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package postgres + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/alphone/internal/postgres/db" +) + +// UserSettingStore reads and writes the per-user settings. +type UserSettingStore struct { + queries *db.Queries +} + +// NewUserSettingStore returns a UserSettingStore backed by pool. +func NewUserSettingStore(pool *pgxpool.Pool) *UserSettingStore { + return &UserSettingStore{queries: db.New(pool)} +} + +// UserSetting returns the stored value under key, empty when the user set none. +func (s *UserSettingStore) UserSetting(ctx context.Context, userID uuid.UUID, key string) (string, error) { + values, err := s.queries.UserSetting(ctx, db.UserSettingParams{UserID: userID, Key: key}) + if err != nil { + return "", fmt.Errorf("read setting %s: %w", key, err) + } + if len(values) == 0 { + return "", nil + } + return values[0], nil +} + +// SetUserSetting stores value under key for the user, replacing any earlier value. +func (s *UserSettingStore) SetUserSetting(ctx context.Context, userID uuid.UUID, key, value string) error { + err := s.queries.SetUserSetting(ctx, db.SetUserSettingParams{UserID: userID, Key: key, Value: value}) + if err != nil { + return fmt.Errorf("store setting %s: %w", key, err) + } + return nil +} diff --git a/internal/postgres/usersettings_test.go b/internal/postgres/usersettings_test.go new file mode 100644 index 00000000..848658ed --- /dev/null +++ b/internal/postgres/usersettings_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package postgres_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/alphone/internal/postgres" +) + +// seedSettingsUser stores one account row for a settings test to hang from. +func seedSettingsUser(t *testing.T, pool *pgxpool.Pool) uuid.UUID { + t.Helper() + id := uuid.Must(uuid.NewV7()) + if _, err := pool.Exec(t.Context(), + `INSERT INTO auth.users (id, email, name, password_hash, disabled, created_at) + VALUES ($1, $2, 'Maria Perez', 'hash', false, now())`, id, id.String()+"@example.com"); err != nil { + t.Fatalf("storing the user: %v", err) + } + return id +} + +func TestUserSettingRoundTrips(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewUserSettingStore(pool) + user := seedSettingsUser(t, pool) + + if err := store.SetUserSetting(t.Context(), user, "locale.default", "es-ES"); err != nil { + t.Fatalf("SetUserSetting() error = %v, want nil", err) + } + + got, err := store.UserSetting(t.Context(), user, "locale.default") + if err != nil { + t.Fatalf("UserSetting() error = %v, want nil", err) + } + if got != "es-ES" { + t.Errorf("value = %q, want the stored choice back", got) + } +} + +func TestUserSettingAnswersEmptyWhenUnset(t *testing.T) { + t.Parallel() + + store := postgres.NewUserSettingStore(newTestPool(t)) + + got, err := store.UserSetting(t.Context(), uuid.Must(uuid.NewV7()), "locale.default") + + if err != nil { + t.Fatalf("UserSetting() error = %v, want nil", err) + } + if got != "" { + t.Errorf("value = %q, want empty for an account that chose nothing", got) + } +} + +func TestUserSettingKeepsOnlyTheLastWrite(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewUserSettingStore(pool) + user := seedSettingsUser(t, pool) + + if err := store.SetUserSetting(t.Context(), user, "locale.default", "es-ES"); err != nil { + t.Fatalf("SetUserSetting() error = %v, want nil", err) + } + if err := store.SetUserSetting(t.Context(), user, "locale.default", "en-US"); err != nil { + t.Fatalf("SetUserSetting() again error = %v, want nil", err) + } + + got, err := store.UserSetting(t.Context(), user, "locale.default") + if err != nil { + t.Fatalf("UserSetting() error = %v, want nil", err) + } + if got != "en-US" { + t.Errorf("value = %q, want the later write to win", got) + } +} From e1b24b11cc52886622e4fa411e16459cbae442a9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 18:49:40 +0200 Subject: [PATCH 08/59] feat(locale): resolve the locale from the choice, the header and the default --- internal/locale/locale.go | 63 ++++++++++++++++++++++++++++ internal/locale/locale_test.go | 75 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 internal/locale/locale.go create mode 100644 internal/locale/locale_test.go diff --git a/internal/locale/locale.go b/internal/locale/locale.go new file mode 100644 index 00000000..ad728591 --- /dev/null +++ b/internal/locale/locale.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package locale + +import ( + "errors" + + "golang.org/x/text/language" +) + +// Default is the locale AlphOne answers when nothing narrows the choice. +const Default = "en-US" + +// ErrUnknown reports a locale outside the supported list. +var ErrUnknown = errors.New("locale: not a supported locale") + +// supported lists every locale AlphOne serves, the default first. +var supported = []string{Default, "es-ES"} + +// tags holds the supported list parsed once for the matcher. +var tags = func() []language.Tag { + parsed := make([]language.Tag, 0, len(supported)) + for _, held := range supported { + parsed = append(parsed, language.MustParse(held)) + } + return parsed +}() + +// matcher picks the closest supported locale for an Accept-Language header. +var matcher = language.NewMatcher(tags) + +// Supported returns every locale AlphOne serves, the default first. +func Supported() []string { + held := make([]string, len(supported)) + copy(held, supported) + return held +} + +// Validate reports whether the locale is one AlphOne serves. +func Validate(candidate string) error { + for _, held := range supported { + if held == candidate { + return nil + } + } + return ErrUnknown +} + +// Resolve returns the locale to serve from the stored choice and the Accept-Language header. +func Resolve(stored, acceptLanguage string) string { + if Validate(stored) == nil { + return stored + } + asked, _, err := language.ParseAcceptLanguage(acceptLanguage) + if err != nil || len(asked) == 0 { + return Default + } + _, index, confidence := matcher.Match(asked...) + if confidence == language.No { + return Default + } + return supported[index] +} diff --git a/internal/locale/locale_test.go b/internal/locale/locale_test.go new file mode 100644 index 00000000..37bbd99b --- /dev/null +++ b/internal/locale/locale_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package locale_test + +import ( + "errors" + "testing" + + "github.com/gopherium/alphone/internal/locale" +) + +func TestResolvePrefersTheStoredChoice(t *testing.T) { + t.Parallel() + + if got := locale.Resolve("es-ES", "en-US"); got != "es-ES" { + t.Errorf("Resolve() = %q, want the stored choice over the header", got) + } +} + +func TestResolveMatchesTheClosestHeaderLanguage(t *testing.T) { + t.Parallel() + + if got := locale.Resolve("", "es"); got != "es-ES" { + t.Errorf("Resolve() = %q, want the bare language matched onto es-ES", got) + } + if got := locale.Resolve("", "es-MX, en;q=0.5"); got != "es-ES" { + t.Errorf("Resolve() = %q, want the closest supported locale, not the tag the header named", got) + } +} + +func TestResolveFallsBackToTheDefault(t *testing.T) { + t.Parallel() + + if got := locale.Resolve("", ""); got != "en-US" { + t.Errorf("Resolve() = %q, want the default with nothing to go on", got) + } + if got := locale.Resolve("", "de-DE"); got != "en-US" { + t.Errorf("Resolve() = %q, want the default for an unsupported language", got) + } + if got := locale.Resolve("", "not a header ;;;"); got != "en-US" { + t.Errorf("Resolve() = %q, want the default for a header that does not parse", got) + } +} + +func TestResolveIgnoresAStoredChoiceNoLongerSupported(t *testing.T) { + t.Parallel() + + if got := locale.Resolve("de-DE", "es"); got != "es-ES" { + t.Errorf("Resolve() = %q, want an unsupported stored choice skipped", got) + } +} + +func TestValidateRefusesALocaleOutsideTheList(t *testing.T) { + t.Parallel() + + if err := locale.Validate("es-ES"); err != nil { + t.Errorf("Validate(es-ES) = %v, want a supported locale accepted", err) + } + if err := locale.Validate("de-DE"); !errors.Is(err, locale.ErrUnknown) { + t.Errorf("Validate(de-DE) = %v, want %v", err, locale.ErrUnknown) + } + if err := locale.Validate(""); !errors.Is(err, locale.ErrUnknown) { + t.Errorf("Validate(empty) = %v, want %v", err, locale.ErrUnknown) + } +} + +func TestSupportedStartsWithTheDefault(t *testing.T) { + t.Parallel() + + supported := locale.Supported() + + if len(supported) == 0 || supported[0] != locale.Default { + t.Errorf("Supported() = %v, want the default first", supported) + } +} From 06b3f66398f6aabd71dabae027b88fc1478bd5a4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 18:50:23 +0200 Subject: [PATCH 09/59] feat(graph): answer and store the caller's locale --- cmd/alphone/run.go | 1 + go.mod | 2 +- graph/budget_test.go | 2 +- graph/generated.go | 131 +++++++++++++++++++++++++++++++++ graph/schema.graphql | 2 + graph/schema/core.graphqls | 5 ++ internal/graphres/auth_test.go | 28 +++++++ internal/graphres/errors.go | 3 + internal/graphres/gate.go | 33 ++++++++- internal/graphres/graphres.go | 2 + internal/graphres/locale.go | 51 +++++++++++++ 11 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 internal/graphres/locale.go diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index a1cf93e8..a70f2899 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -105,6 +105,7 @@ func run( Live: hub, Auth: auth, Admin: admin, + Settings: postgres.NewUserSettingStore(pool), LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), }, registered) if err != nil { diff --git a/go.mod b/go.mod index 055041cf..c5e62ccb 100644 --- a/go.mod +++ b/go.mod @@ -119,7 +119,7 @@ require ( golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.40.0 google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect google.golang.org/grpc v1.82.1 // indirect diff --git a/graph/budget_test.go b/graph/budget_test.go index 26180b2e..27566177 100644 --- a/graph/budget_test.go +++ b/graph/budget_test.go @@ -13,7 +13,7 @@ import ( // Root field budgets per schema owner. const ( - coreRootFieldBudget = 25 + coreRootFieldBudget = 27 pluginRootFieldBudget = 10 ) diff --git a/graph/generated.go b/graph/generated.go index ae225dd1..e31247e2 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -184,6 +184,7 @@ type ComplexityRoot struct { Login func(childComplexity int, email string, password string) int Logout func(childComplexity int) int RenameContact func(childComplexity int, id uuid.UUID, name string) int + SetLocale func(childComplexity int, locale string) int SetUserDisabled func(childComplexity int, id uuid.UUID, disabled bool) int SetUserRole func(childComplexity int, id uuid.UUID, role string) int UpdateTask func(childComplexity int, id uuid.UUID, input model.UpdateTaskInput) int @@ -206,6 +207,7 @@ type ComplexityRoot struct { ImportFields func(childComplexity int) int ImportJob func(childComplexity int, id uuid.UUID) int Imports func(childComplexity int) int + Locale func(childComplexity int) int Me func(childComplexity int) int Task func(childComplexity int, id uuid.UUID) int Tasks func(childComplexity int, date *time.Time, dueBefore *time.Time, contactID *uuid.UUID, status *string, first *int, after *string) int @@ -326,6 +328,7 @@ type MutationResolver interface { CreateUser(ctx context.Context, email string, name string, password string, role *string) (*model.User, error) SetUserDisabled(ctx context.Context, id uuid.UUID, disabled bool) (bool, error) SetUserRole(ctx context.Context, id uuid.UUID, role string) (bool, error) + SetLocale(ctx context.Context, locale string) (string, error) CreateTask(ctx context.Context, input model.CreateTaskInput) (*model.CreateTaskPayload, error) UpdateTask(ctx context.Context, id uuid.UUID, input model.UpdateTaskInput) (*model.Task, error) APITokenCreate(ctx context.Context, name string, scopes []string, ttlDays *int) (*model.APITokenSecret, error) @@ -343,6 +346,7 @@ type MutationResolver interface { type QueryResolver interface { Version(ctx context.Context) (string, error) Tenant(ctx context.Context) (*model.Tenant, error) + Locale(ctx context.Context) (string, error) Me(ctx context.Context) (*model.Identity, error) Users(ctx context.Context) ([]*model.User, error) Contacts(ctx context.Context, q *string, first *int, after *string) (*model.ContactConnection, error) @@ -1023,6 +1027,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Mutation.RenameContact(childComplexity, args["id"].(uuid.UUID), args["name"].(string)), true + case "Mutation.setLocale": + if e.ComplexityRoot.Mutation.SetLocale == nil { + break + } + + args, err := ec.field_Mutation_setLocale_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.SetLocale(childComplexity, args["locale"].(string)), true case "Mutation.setUserDisabled": if e.ComplexityRoot.Mutation.SetUserDisabled == nil { break @@ -1167,6 +1182,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.Imports(childComplexity), true + case "Query.locale": + if e.ComplexityRoot.Query.Locale == nil { + break + } + + return e.ComplexityRoot.Query.Locale(childComplexity), true case "Query.me": if e.ComplexityRoot.Query.Me == nil { break @@ -2779,6 +2800,20 @@ func (ec *executionContext) field_Mutation_renameContact_args(ctx context.Contex return args, nil } +func (ec *executionContext) field_Mutation_setLocale_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "locale", + func(ctx context.Context, v any) (string, error) { + return ec.unmarshalNString2string(ctx, v) + }) + if err != nil { + return nil, err + } + args["locale"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_setUserDisabled_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -5306,6 +5341,50 @@ func (ec *executionContext) fieldContext_Mutation_setUserRole(ctx context.Contex return fc, nil } +func (ec *executionContext) _Mutation_setLocale(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_setLocale(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().SetLocale(ctx, fc.Args["locale"].(string)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_setLocale(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_setLocale_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createTask(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6031,6 +6110,29 @@ func (ec *executionContext) fieldContext_Query_tenant(_ context.Context, field g return fc, nil } +func (ec *executionContext) _Query_locale(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_locale(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().Locale(ctx) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_locale(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("Query", field, true, true, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10448,6 +10550,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "setLocale": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_setLocale(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createTask": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createTask(ctx, field) @@ -10676,6 +10785,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "locale": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_locale(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "me": field := field diff --git a/graph/schema.graphql b/graph/schema.graphql index f384b37e..1d944bba 100644 --- a/graph/schema.graphql +++ b/graph/schema.graphql @@ -141,6 +141,7 @@ type Mutation { createUser(email: String!, name: String!, password: String!, role: String): User! @scope(area: "users", write: true, capability: "manage_users") setUserDisabled(id: UUID!, disabled: Boolean!): Boolean! @scope(area: "users", write: true, capability: "manage_users") setUserRole(id: UUID!, role: String!): Boolean! @scope(area: "users", write: true, capability: "manage_users") + setLocale(locale: String!): String! @scope(area: "meta", write: true) createTask(input: CreateTaskInput!): CreateTaskPayload! @scope(area: "tasks", write: true) updateTask(id: UUID!, input: UpdateTaskInput!): Task! @scope(area: "tasks", write: true) apiTokenCreate(name: String!, scopes: [String!]!, ttlDays: Int): ApiTokenSecret! @scope(area: "tokens", write: true) @@ -164,6 +165,7 @@ type PageInfo { type Query { version: String! @scope(area: "meta", write: false) tenant: Tenant! @scope(area: "meta", write: false) + locale: String! @scope(area: "meta", write: false) me: Identity! @scope(area: "auth", write: false) users: [User!]! @scope(area: "users", write: false) contacts(q: String, first: Int, after: String): ContactConnection! @scope(area: "contacts", write: false) diff --git a/graph/schema/core.graphqls b/graph/schema/core.graphqls index aa2b897d..ba4739b0 100644 --- a/graph/schema/core.graphqls +++ b/graph/schema/core.graphqls @@ -27,6 +27,11 @@ type Tenant { type Query { version: String! @scope(area: "meta", write: false) tenant: Tenant! @scope(area: "meta", write: false) + locale: String! @scope(area: "meta", write: false) +} + +extend type Mutation { + setLocale(locale: String!): String! @scope(area: "meta", write: true) } type Subscription { diff --git a/internal/graphres/auth_test.go b/internal/graphres/auth_test.go index f2d4b529..77622749 100644 --- a/internal/graphres/auth_test.go +++ b/internal/graphres/auth_test.go @@ -116,6 +116,34 @@ func TestAnonymousOperationsBeyondLoginAreRejected(t *testing.T) { } } +func TestAnAnonymousCallerMayAskItsLocale(t *testing.T) { + t.Parallel() + + client := newAnonymousGraphClient(t, newAuthResolver(testkit.NewStore())) + + var answered struct{ Locale string } + if err := client.Post(`{ locale }`, &answered); err != nil { + t.Fatalf("Post() error = %v, want the locale answered before login", err) + } + if answered.Locale != "en-US" { + t.Errorf("locale = %q, want the default with nothing to go on", answered.Locale) + } +} + +func TestAnAnonymousCallerMayNotSetALocale(t *testing.T) { + t.Parallel() + + client := newAnonymousGraphClient(t, newAuthResolver(testkit.NewStore())) + + response, err := client.RawPost(`mutation { setLocale(locale: "es-ES") }`) + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if got := firstErrorCode(t, response.Errors); got != "UNAUTHENTICATED" { + t.Errorf("code = %q, want UNAUTHENTICATED, storing a choice needs an account", got) + } +} + func TestLoginIssuesTheSessionForValidCredentials(t *testing.T) { t.Parallel() diff --git a/internal/graphres/errors.go b/internal/graphres/errors.go index fec4ba4b..5293e659 100644 --- a/internal/graphres/errors.go +++ b/internal/graphres/errors.go @@ -17,6 +17,7 @@ import ( "github.com/gopherium/alphone/internal/contact" "github.com/gopherium/alphone/internal/cursor" "github.com/gopherium/alphone/internal/event" + "github.com/gopherium/alphone/internal/locale" "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/task" "github.com/gopherium/alphone/internal/webhook" @@ -36,6 +37,7 @@ var validationErrors = []error{ webhook.ErrInvalidURL, webhook.ErrNoEvents, event.ErrUnknownName, + locale.ErrUnknown, scalar.ErrInvalid, cursor.ErrMalformed, errExactlyOneTaskFilter, @@ -80,6 +82,7 @@ var reasonsFor = []struct { {webhook.ErrInvalidURL, "webhook_url_invalid", nil}, {webhook.ErrNoEvents, "webhook_events_required", nil}, {event.ErrUnknownName, "event_unknown", nil}, + {locale.ErrUnknown, "locale_unknown", map[string]any{"supported": locale.Supported()}}, {scalar.ErrInvalid, "value_malformed", nil}, {cursor.ErrMalformed, "cursor_malformed", nil}, {errExactlyOneTaskFilter, "task_filter_choice_required", nil}, diff --git a/internal/graphres/gate.go b/internal/graphres/gate.go index aa89b51c..cea10888 100644 --- a/internal/graphres/gate.go +++ b/internal/graphres/gate.go @@ -16,18 +16,47 @@ import ( // loginFieldName is the one root field an anonymous caller may select. const loginFieldName = "login" -// AnonymousGate rejects anonymous operations reaching beyond the login mutation. +// AnonymousGate rejects anonymous operations reaching beyond login and the locale. func AnonymousGate(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler { if authkit.IdentityFromContext(ctx).ID != uuid.Nil { return next(ctx) } operation := graphql.GetOperationContext(ctx).Operation - if operation == nil || operation.Operation != ast.Mutation || !onlyLoginFields(operation.SelectionSet) { + if operation == nil || !anonymousOperation(operation) { return graphql.OneShot(&graphql.Response{Errors: gqlerror.List{unauthenticatedError()}}) } return next(ctx) } +// anonymousOperation reports whether every selection is one an anonymous caller may reach. +func anonymousOperation(operation *ast.OperationDefinition) bool { + switch operation.Operation { + case ast.Mutation: + return onlyLoginFields(operation.SelectionSet) + case ast.Query: + return onlyLocaleFields(operation.SelectionSet) + default: + return false + } +} + +// localeFieldName is the one root query an anonymous caller may select. +const localeFieldName = "locale" + +// onlyLocaleFields reports whether every root selection is the plain locale field. +func onlyLocaleFields(selections ast.SelectionSet) bool { + if len(selections) == 0 { + return false + } + for _, selection := range selections { + field, ok := selection.(*ast.Field) + if !ok || field.Name != localeFieldName { + return false + } + } + return true +} + // onlyLoginFields reports whether every root selection is the plain login field. func onlyLoginFields(selections ast.SelectionSet) bool { if len(selections) == 0 { diff --git a/internal/graphres/graphres.go b/internal/graphres/graphres.go index b2843169..cd5d8dab 100644 --- a/internal/graphres/graphres.go +++ b/internal/graphres/graphres.go @@ -129,6 +129,8 @@ type Resolver struct { Auth *authkit.Handlers // Admin serves user administration through the authkit seams. Admin *authkit.AdminHandlers + // Settings reads and writes the caller's stored preferences. + Settings SettingStore // LoginLimiter counts failed logins per client IP. LoginLimiter AttemptLimiter // BatchWait bounds the loader batching window. Zero means one millisecond. diff --git a/internal/graphres/locale.go b/internal/graphres/locale.go new file mode 100644 index 00000000..6e178a14 --- /dev/null +++ b/internal/graphres/locale.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package graphres + +import ( + "context" + + "github.com/google/uuid" + + "github.com/gopherium/gouncer/authkit" + + "github.com/gopherium/alphone/internal/locale" +) + +// localeKey names the setting the reader's language choice is stored under. +const localeKey = "locale.default" + +// SettingStore reads and writes one per-user setting. +type SettingStore interface { + UserSetting(ctx context.Context, userID uuid.UUID, key string) (string, error) + SetUserSetting(ctx context.Context, userID uuid.UUID, key, value string) error +} + +// Locale answers the locale the caller is served in. +func (q QueryResolvers) Locale(ctx context.Context) (string, error) { + stored := "" + if id := authkit.IdentityFromContext(ctx).ID; id != uuid.Nil && q.root.Settings != nil { + held, err := q.root.Settings.UserSetting(ctx, id, localeKey) + if err != nil { + return "", err + } + stored = held + } + header := "" + if carrier, err := httpFrom(ctx); err == nil { + header = carrier.request.Header.Get("Accept-Language") + } + return locale.Resolve(stored, header), nil +} + +// SetLocale stores the caller's language choice and answers it back. +func (m MutationResolvers) SetLocale(ctx context.Context, chosen string) (string, error) { + if err := locale.Validate(chosen); err != nil { + return "", err + } + id := authkit.IdentityFromContext(ctx).ID + if err := m.root.Settings.SetUserSetting(ctx, id, localeKey, chosen); err != nil { + return "", err + } + return chosen, nil +} From a3634d42145804ab8847f29711b90845c2161a00 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 19:03:34 +0200 Subject: [PATCH 10/59] test: pin the locale paths and every error branch they carry --- internal/graphres/errors_internal_test.go | 10 ++ internal/graphres/gate_internal_test.go | 35 +++++- internal/graphres/locale_test.go | 125 ++++++++++++++++++++++ internal/postgres/usersettings_test.go | 15 +++ internal/server/locale_test.go | 47 ++++++++ plugins/fields/graphql_internal_test.go | 8 ++ plugins/importer/commit_internal_test.go | 14 +++ 7 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 internal/graphres/locale_test.go create mode 100644 internal/server/locale_test.go diff --git a/internal/graphres/errors_internal_test.go b/internal/graphres/errors_internal_test.go index 538312f7..dfc67d64 100644 --- a/internal/graphres/errors_internal_test.go +++ b/internal/graphres/errors_internal_test.go @@ -84,6 +84,16 @@ func TestAPluginErrorCarriesItsOwnReason(t *testing.T) { } } +func TestReasonOfAnswersNothingForAnUnlistedError(t *testing.T) { + t.Parallel() + + named, meta := reasonOf(fmt.Errorf("nothing the tables know")) + + if named != "" || meta != nil { + t.Errorf("reasonOf() = %q, %v, want nothing for an error outside every table", named, meta) + } +} + func TestABrickSentinelSpeaksItsOwnReason(t *testing.T) { t.Parallel() diff --git a/internal/graphres/gate_internal_test.go b/internal/graphres/gate_internal_test.go index f5594218..666cc2df 100644 --- a/internal/graphres/gate_internal_test.go +++ b/internal/graphres/gate_internal_test.go @@ -2,7 +2,11 @@ package graphres -import "testing" +import ( + "testing" + + "github.com/vektah/gqlparser/v2/ast" +) func TestOnlyLoginFieldsRejectsAnEmptySelection(t *testing.T) { t.Parallel() @@ -11,3 +15,32 @@ func TestOnlyLoginFieldsRejectsAnEmptySelection(t *testing.T) { t.Error("onlyLoginFields(nil) = true, want false") } } + +func TestAnonymousOperationRefusesASubscription(t *testing.T) { + t.Parallel() + + operation := &ast.OperationDefinition{ + Operation: ast.Subscription, + SelectionSet: ast.SelectionSet{&ast.Field{Name: "coreEvent"}}, + } + + if anonymousOperation(operation) { + t.Error("anonymousOperation(subscription) = true, want streams behind login") + } +} + +func TestOnlyLocaleFieldsRejectsAFragmentSelection(t *testing.T) { + t.Parallel() + + if onlyLocaleFields(ast.SelectionSet{&ast.FragmentSpread{Name: "sneaky"}}) { + t.Error("onlyLocaleFields(fragment) = true, want only the plain field admitted") + } +} + +func TestOnlyLocaleFieldsRejectsAnEmptySelection(t *testing.T) { + t.Parallel() + + if onlyLocaleFields(nil) { + t.Error("onlyLocaleFields(nil) = true, want false") + } +} diff --git a/internal/graphres/locale_test.go b/internal/graphres/locale_test.go new file mode 100644 index 00000000..09dec83b --- /dev/null +++ b/internal/graphres/locale_test.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package graphres_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/alphone/internal/graphres" + "github.com/gopherium/alphone/internal/postgres" +) + +// seedLocaleUser stores one account row and returns its id. +func seedLocaleUser(t *testing.T, pool *pgxpool.Pool) uuid.UUID { + t.Helper() + id := uuid.Must(uuid.NewV7()) + if _, err := pool.Exec(t.Context(), + `INSERT INTO auth.users (id, email, name, password_hash, disabled, created_at) + VALUES ($1, $2, 'Maria Perez', 'hash', false, now())`, id, id.String()+"@example.com"); err != nil { + t.Fatalf("storing the user: %v", err) + } + return id +} + +func TestSetLocaleRoundTripsForASignedInCaller(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + caller := seedLocaleUser(t, pool) + resolver := &graphres.Resolver{Settings: postgres.NewUserSettingStore(pool)} + client := newGraphClient(t, resolver, caller) + + var set struct{ SetLocale string } + if err := client.Post(`mutation { setLocale(locale: "es-ES") }`, &set); err != nil { + t.Fatalf("setLocale error = %v, want nil", err) + } + if set.SetLocale != "es-ES" { + t.Errorf("setLocale = %q, want the stored choice answered back", set.SetLocale) + } + + var asked struct{ Locale string } + if err := client.Post(`{ locale }`, &asked); err != nil { + t.Fatalf("locale error = %v, want nil", err) + } + if asked.Locale != "es-ES" { + t.Errorf("locale = %q, want the stored choice preferred over any header", asked.Locale) + } +} + +// failingSettings is a settings store whose reads and writes always fail. +type failingSettings struct{} + +// UserSetting always reports the store unreachable. +func (failingSettings) UserSetting(context.Context, uuid.UUID, string) (string, error) { + return "", errSettingsDown +} + +// SetUserSetting always reports the store unreachable. +func (failingSettings) SetUserSetting(context.Context, uuid.UUID, string, string) error { + return errSettingsDown +} + +// errSettingsDown is the failure the failing settings store reports. +var errSettingsDown = errors.New("settings store down") + +func TestLocaleReportsAFailingSettingsStore(t *testing.T) { + t.Parallel() + + client := newGraphClient(t, &graphres.Resolver{Settings: failingSettings{}}, uuid.Must(uuid.NewV7())) + + if err := client.Post(`{ locale }`, &struct{ Locale string }{}); err == nil { + t.Error("locale error = nil, want the store failure reported rather than a guessed locale") + } +} + +func TestSetLocaleReportsAFailingSettingsStore(t *testing.T) { + t.Parallel() + + client := newGraphClient(t, &graphres.Resolver{Settings: failingSettings{}}, uuid.Must(uuid.NewV7())) + + if err := client.Post(`mutation { setLocale(locale: "es-ES") }`, &struct{ SetLocale string }{}); err == nil { + t.Error("setLocale error = nil, want the store failure reported rather than a silent drop") + } +} + +func TestLocaleReadsTheHeaderTheTransportCarries(t *testing.T) { + t.Parallel() + + request := httptest.NewRequest(http.MethodPost, "/api/graphql", nil) + request.Header.Set("Accept-Language", "es") + client := newDecoratedGraphClient(t, &graphres.Resolver{}, func(ctx context.Context) context.Context { + return graphres.WithHTTP(ctx, httptest.NewRecorder(), request) + }) + + var asked struct{ Locale string } + if err := client.Post(`{ locale }`, &asked); err != nil { + t.Fatalf("locale error = %v, want nil", err) + } + if asked.Locale != "es-ES" { + t.Errorf("locale = %q, want the header the transport carries matched", asked.Locale) + } +} + +func TestSetLocaleRefusesALocaleOutsideTheList(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + caller := seedLocaleUser(t, pool) + resolver := &graphres.Resolver{Settings: postgres.NewUserSettingStore(pool)} + client := newGraphClient(t, resolver, caller) + + response, err := client.RawPost(`mutation { setLocale(locale: "de-DE") }`) + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if got := firstErrorCode(t, response.Errors); got != "VALIDATION" { + t.Errorf("code = %q, want VALIDATION", got) + } +} diff --git a/internal/postgres/usersettings_test.go b/internal/postgres/usersettings_test.go index 848658ed..a3698888 100644 --- a/internal/postgres/usersettings_test.go +++ b/internal/postgres/usersettings_test.go @@ -58,6 +58,21 @@ func TestUserSettingAnswersEmptyWhenUnset(t *testing.T) { } } +func TestUserSettingReportsAClosedPool(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewUserSettingStore(pool) + pool.Close() + + if _, err := store.UserSetting(t.Context(), uuid.Must(uuid.NewV7()), "locale.default"); err == nil { + t.Error("UserSetting() error = nil, want the closed pool reported") + } + if err := store.SetUserSetting(t.Context(), uuid.Must(uuid.NewV7()), "locale.default", "es-ES"); err == nil { + t.Error("SetUserSetting() error = nil, want the closed pool reported") + } +} + func TestUserSettingKeepsOnlyTheLastWrite(t *testing.T) { t.Parallel() diff --git a/internal/server/locale_test.go b/internal/server/locale_test.go new file mode 100644 index 00000000..d0606b86 --- /dev/null +++ b/internal/server/locale_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package server_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gopherium/gouncer/authkit/testkit" +) + +// askLocale posts the locale query carrying the given Accept-Language header. +func askLocale(t *testing.T, handler http.Handler, acceptLanguage string) string { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/api/graphql", strings.NewReader(`{"query":"{ locale }"}`)) + request.Header.Set("Content-Type", "application/json") + if acceptLanguage != "" { + request.Header.Set("Accept-Language", acceptLanguage) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + var body struct { + Data struct { + Locale string `json:"locale"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decoding the locale answer: %v from %s", err, recorder.Body.String()) + } + return body.Data.Locale +} + +func TestLocaleReadsTheAcceptLanguageHeaderThroughTheServer(t *testing.T) { + t.Parallel() + + srv := newGraphServer(t, graphConfig{Users: testkit.NewStore()}) + + if got := askLocale(t, srv, "es"); got != "es-ES" { + t.Errorf("locale = %q, want the header matched through the real handler", got) + } + if got := askLocale(t, srv, ""); got != "en-US" { + t.Errorf("locale = %q, want the default without a header", got) + } +} diff --git a/plugins/fields/graphql_internal_test.go b/plugins/fields/graphql_internal_test.go index a2f918af..88750c32 100644 --- a/plugins/fields/graphql_internal_test.go +++ b/plugins/fields/graphql_internal_test.go @@ -25,6 +25,14 @@ func TestDefineFieldNamesTheReasonItRefuses(t *testing.T) { } } +func TestFieldReasonAnswersNothingForAnUnlistedError(t *testing.T) { + t.Parallel() + + if got := fieldReason(errCatalogue); got != "" { + t.Errorf("fieldReason() = %q, want nothing for an error outside the table", got) + } +} + // errCatalogue is the failure a wedged catalogue reload reports. var errCatalogue = errors.New("catalogue unavailable") diff --git a/plugins/importer/commit_internal_test.go b/plugins/importer/commit_internal_test.go index 1eb35ef0..d791fa1a 100644 --- a/plugins/importer/commit_internal_test.go +++ b/plugins/importer/commit_internal_test.go @@ -9,6 +9,20 @@ import ( "github.com/gopherium/alphone/sdk" ) +func TestCellAtAnswersNothingOutsideTheRow(t *testing.T) { + t.Parallel() + + if got := cellAt([]string{"a", "b"}, "5"); got != "" { + t.Errorf("cellAt(5) = %q, want nothing past the row", got) + } + if got := cellAt([]string{"a", "b"}, "not a number"); got != "" { + t.Errorf("cellAt(text) = %q, want nothing for an unreadable index", got) + } + if got := cellAt([]string{"a", "b"}, "1"); got != "b" { + t.Errorf("cellAt(1) = %q, want the named cell", got) + } +} + func TestClassifyClaimErrorNamesTheReason(t *testing.T) { t.Parallel() From 9a009c5b5b59e74b0607b14bf112e13f7a80b64b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 19:03:45 +0200 Subject: [PATCH 11/59] test(cmd): prove the locale wiring through the real binary --- cmd/alphone/main_exec_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cmd/alphone/main_exec_test.go b/cmd/alphone/main_exec_test.go index c79cd6de..293bd781 100644 --- a/cmd/alphone/main_exec_test.go +++ b/cmd/alphone/main_exec_test.go @@ -222,6 +222,33 @@ func TestMainBinarySeedFillsTheDemoImportField(t *testing.T) { } } +func TestMainBinaryStoresAndAnswersTheLocale(t *testing.T) { + t.Parallel() + + binary, env := coverBinary(t) + databaseURL := testDatabaseURL(t) + var stderr bytes.Buffer + seedCmd := exec.Command(binary, "seed") + seedCmd.Dir = t.TempDir() + seedCmd.Env = append(env, "ALPHONE_DATABASE_URL="+databaseURL) + seedCmd.Stderr = &stderr + if err := seedCmd.Run(); err != nil { + t.Fatalf("seed: %v, stderr: %s", err, stderr.String()) + } + addr, secret := servedSeededBinary(t, databaseURL) + + set := postGraph(t, addr, secret, `{"query":"mutation { setLocale(locale: \"es-ES\") }"}`) + if len(set.Errors) > 0 || set.Data.SetLocale != "es-ES" { + t.Fatalf("setLocale = %q with errors %v, want the choice stored through the real wiring", + set.Data.SetLocale, set.Errors) + } + + asked := postGraph(t, addr, secret, `{"query":"{ locale }"}`) + if asked.Data.Locale != "es-ES" { + t.Errorf("locale = %q, want the stored choice back from the real binary", asked.Data.Locale) + } +} + func TestMainBinaryServesUntilSignalled(t *testing.T) { t.Parallel() @@ -325,6 +352,8 @@ type graphAnswer struct { Node map[string]any `json:"node"` } `json:"edges"` } `json:"contacts"` + SetLocale string `json:"setLocale"` + Locale string `json:"locale"` } `json:"data"` Errors []struct { Message string `json:"message"` From a4e30cd3f4a612d88a0e9cd68f86eed9bda69b66 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 19:19:54 +0200 Subject: [PATCH 12/59] test(e2e): prove the locale resolution against a running server --- test/features/features/locale.feature | 24 +++++ test/features/features_test.go | 9 ++ test/features/steps_locale_test.go | 136 ++++++++++++++++++++++++++ test/features/world_test.go | 1 + 4 files changed, 170 insertions(+) create mode 100644 test/features/features/locale.feature create mode 100644 test/features/steps_locale_test.go diff --git a/test/features/features/locale.feature b/test/features/features/locale.feature new file mode 100644 index 00000000..bcbbf2d4 --- /dev/null +++ b/test/features/features/locale.feature @@ -0,0 +1,24 @@ +Feature: The graph answers the reader's locale + AlphOne serves one locale per reader. A stored choice wins, the + Accept-Language header narrows an anonymous ask, and the default + answers when nothing else does. + + Background: + Given a running AlphOne holding a user with an API token + + Scenario: An anonymous ask is answered the default + When an anonymous caller asks for the locale + Then the locale answered is "en-US" + + Scenario: The Accept-Language header narrows an anonymous ask + When an anonymous caller asks for the locale speaking "es" + Then the locale answered is "es-ES" + + Scenario: A stored choice wins over the header + Given the caller stored the locale "es-ES" + When the caller asks for the locale speaking "en" + Then the locale answered is "es-ES" + + Scenario: A choice outside the list is refused naming its reason + When the caller stores the locale "de-DE" + Then the ask is refused naming the reason "locale_unknown" diff --git a/test/features/features_test.go b/test/features/features_test.go index ccd14424..3debe9b6 100644 --- a/test/features/features_test.go +++ b/test/features/features_test.go @@ -78,6 +78,11 @@ func initializeTenants(t *testing.T) func(*godog.ScenarioContext) { return func(sc *godog.ScenarioContext) { registerTenantSteps(sc, t) } } +// initializeLocale registers the locale resolution steps. +func initializeLocale(t *testing.T) func(*godog.ScenarioContext) { + return func(sc *godog.ScenarioContext) { registerLocaleSteps(sc, t) } +} + // initializeImportFields registers the import mapping steps. func initializeImportFields(t *testing.T) func(*godog.ScenarioContext) { return func(sc *godog.ScenarioContext) { registerImportFieldsSteps(sc, t) } @@ -125,6 +130,10 @@ func TestTenants(t *testing.T) { runFeature(t, "features/tenants.feature", initializeTenants(t)) } +func TestLocaleResolution(t *testing.T) { + runFeature(t, "features/locale.feature", initializeLocale(t)) +} + func TestImportFields(t *testing.T) { runFeature(t, "features/import-fields.feature", initializeImportFields(t)) } diff --git a/test/features/steps_locale_test.go b/test/features/steps_locale_test.go new file mode 100644 index 00000000..cd1e6224 --- /dev/null +++ b/test/features/steps_locale_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package features_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/cucumber/godog" +) + +// localeAnswer is the envelope every locale step reads. +type localeAnswer struct { + Data struct { + Locale string `json:"locale"` + SetLocale string `json:"setLocale"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + Extensions map[string]any `json:"extensions"` + } `json:"errors"` +} + +// postGraphSpeaking posts a graph request carrying the secret and the Accept-Language header. +func (w *world) postGraphSpeaking(ctx context.Context, secret, acceptLanguage, body string) error { + request, err := http.NewRequestWithContext( + ctx, http.MethodPost, w.server.URL+"/api/graphql", strings.NewReader(body)) + if err != nil { + return fmt.Errorf("building the graph request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + if secret != "" { + request.Header.Set("Authorization", "Bearer "+secret) + } + if acceptLanguage != "" { + request.Header.Set("Accept-Language", acceptLanguage) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return fmt.Errorf("posting the graph request: %w", err) + } + defer func() { _ = response.Body.Close() }() + answered, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("reading the graph answer: %w", err) + } + w.answered = answered + return nil +} + +// registerLocaleSteps binds the locale resolution steps and the world lifecycle. +func registerLocaleSteps(sc *godog.ScenarioContext, t *testing.T) { + sc.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) { + return context.WithValue(ctx, worldKey{}, newWorld(t)), nil + }) + + sc.Given(`^a running AlphOne holding a user with an API token$`, func(ctx context.Context) error { + if worldFrom(ctx).secret == "" { + return fmt.Errorf("the scenario holds no token") + } + return nil + }) + + sc.When(`^an anonymous caller asks for the locale$`, func(ctx context.Context) error { + return worldFrom(ctx).postGraphSpeaking(ctx, "", "", `{"query":"{ locale }"}`) + }) + + sc.When(`^an anonymous caller asks for the locale speaking "([^"]*)"$`, + func(ctx context.Context, language string) error { + return worldFrom(ctx).postGraphSpeaking(ctx, "", language, `{"query":"{ locale }"}`) + }) + + sc.When(`^the caller asks for the locale speaking "([^"]*)"$`, + func(ctx context.Context, language string) error { + w := worldFrom(ctx) + return w.postGraphSpeaking(ctx, w.secret, language, `{"query":"{ locale }"}`) + }) + + sc.Given(`^the caller stored the locale "([^"]*)"$`, func(ctx context.Context, chosen string) error { + w := worldFrom(ctx) + body := fmt.Sprintf(`{"query":"mutation { setLocale(locale: \"%s\") }"}`, chosen) + if err := w.postGraphSpeaking(ctx, w.secret, "", body); err != nil { + return err + } + var answer localeAnswer + if err := json.Unmarshal(w.answered, &answer); err != nil { + return fmt.Errorf("decoding the store answer: %w", err) + } + if len(answer.Errors) != 0 { + return fmt.Errorf("storing the locale was refused, answered %s", w.answered) + } + return nil + }) + + sc.When(`^the caller stores the locale "([^"]*)"$`, func(ctx context.Context, chosen string) error { + w := worldFrom(ctx) + body := fmt.Sprintf(`{"query":"mutation { setLocale(locale: \"%s\") }"}`, chosen) + return w.postGraphSpeaking(ctx, w.secret, "", body) + }) + + sc.Then(`^the locale answered is "([^"]*)"$`, func(ctx context.Context, want string) error { + w := worldFrom(ctx) + var answer localeAnswer + if err := json.Unmarshal(w.answered, &answer); err != nil { + return fmt.Errorf("decoding the locale answer: %w", err) + } + if len(answer.Errors) != 0 { + return fmt.Errorf("errors = %v, want the locale answered", answer.Errors) + } + if answer.Data.Locale != want { + return fmt.Errorf("locale = %q, want %q", answer.Data.Locale, want) + } + return nil + }) + + sc.Then(`^the ask is refused naming the reason "([^"]*)"$`, + func(ctx context.Context, want string) error { + w := worldFrom(ctx) + var answer localeAnswer + if err := json.Unmarshal(w.answered, &answer); err != nil { + return fmt.Errorf("decoding the refused answer: %w", err) + } + if len(answer.Errors) != 1 { + return fmt.Errorf("errors = %v, want exactly one error", answer.Errors) + } + if got := answer.Errors[0].Extensions["reason"]; got != want { + return fmt.Errorf("reason = %v, want %q, answered %s", got, want, w.answered) + } + return nil + }) +} diff --git a/test/features/world_test.go b/test/features/world_test.go index f8c5e9dd..9704b711 100644 --- a/test/features/world_test.go +++ b/test/features/world_test.go @@ -123,6 +123,7 @@ func bootWorld(t *testing.T, liveImports bool) *world { Webhooks: webhooks, Tenants: postgres.NewTenantStore(pool), Tokens: tokens, + Settings: postgres.NewUserSettingStore(pool), Live: hub, Auth: auth, Admin: authkit.NewAdmin(authkit.AdminConfig{Store: users, Privileged: role.Privileged()}), From c45d211646d6174522d4e549578f3e7e30e21b8e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 19:20:23 +0200 Subject: [PATCH 13/59] docs: describe the locale the graph answers and stores --- .../src/content/docs/reference/graphql-api.md | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index d4f01375..2695a692 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -80,9 +80,9 @@ mutation { } ``` -`login` is the only operation an anonymous caller may run. Anything else -answers `UNAUTHENTICATED` with HTTP 200, because a GraphQL error is not an HTTP -error: +`login` and the `locale` query are the only operations an anonymous caller may +run. Anything else answers `UNAUTHENTICATED` with HTTP 200, because a GraphQL +error is not an HTTP error: ```json { @@ -189,6 +189,25 @@ authority. An operation runs only when both allow it. The token is checked first. A caller holding neither is told about the scope, because that is the half it can fix on its own. +## Locale + +AlphOne answers each reader in one locale. `locale` resolves it: the signed-in +account's stored choice wins, else the closest match to the `Accept-Language` +header, else `en-US`. The query is open to anonymous callers, so a login screen +can ask before anyone signs in. + +```graphql +query { locale } +``` + +`setLocale` stores the calling account's choice and answers it back. It takes +a locale from the supported list and refuses anything else with the reason +`locale_unknown`, naming the list in `meta.supported`. + +```graphql +mutation { setLocale(locale: "es-ES") } +``` + ## Scalars | Scalar | Format | Example | @@ -360,6 +379,7 @@ The reasons the core answers with: | `webhook_events_required` | | a webhook needs at least one event | | `webhook_not_found` | | the id names no webhook | | `first_out_of_range` | `min`, `max` | the page size is outside the range | +| `locale_unknown` | `supported` | the locale is not one AlphOne serves | | `cursor_malformed` | | the cursor is not one a field issued | | `value_malformed` | | a scalar did not parse | | `token_name_required` | | a token needs a name | @@ -480,6 +500,7 @@ cannot drift. Point a client at the endpoint, or read | Area | Reads | Writes | | ---- | ----- | ------ | | Session | `me` | `login`, `logout` | +| Locale | `locale` | `setLocale` | | Users | `users` | `createUser`, `setUserDisabled`, `setUserRole` | | Contacts | `contacts`, `contact` | `createContact`, `renameContact`, `addContactIdentity`, `deleteContactIdentity` | | Tasks | `tasks`, `task` | `createTask`, `updateTask` | From e3ea658b0a7f4a58522cacf0d270208a0dcb1ce4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:20:52 +0200 Subject: [PATCH 14/59] chore(deps): pin the translation runtime and the brick --- frontend/package.json | 3 + pnpm-lock.yaml | 145 ++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + sdk/frontend/package.json | 2 + 4 files changed, 151 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index e55018d4..35cb52eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,9 +18,11 @@ "@alphone/plugin-importer": "workspace:*", "@alphone/plugin-whatsapp": "workspace:*", "@gopherium/godmin": "0.7.0", + "@gopherium/gottext": "0.2.0", "@gopherium/react-auth": "0.6.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.23", + "@wordpress/i18n": "6.26.0", "@wordpress/theme": "1.1.0", "@wordpress/ui": "0.19.0", "graphql": "^17.0.2", @@ -36,6 +38,7 @@ "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.10", + "gettext-extractor": "4.0.6", "jsdom": "^29.1.1", "oxlint": "^1.75.0", "stylelint": "^17.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2c490dd..5a4bf5d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@gopherium/godmin': specifier: 0.7.0 version: 0.7.0(@tanstack/react-router@1.170.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/style-runtime@0.8.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vitest@4.1.10) + '@gopherium/gottext': + specifier: 0.2.0 + version: 0.2.0(@wordpress/i18n@6.26.0)(gettext-extractor@4.0.6) '@gopherium/react-auth': specifier: 0.6.0 version: 0.6.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10) @@ -64,6 +67,9 @@ importers: '@tanstack/react-router': specifier: ^1.170.23 version: 1.170.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@wordpress/i18n': + specifier: 6.26.0 + version: 6.26.0 '@wordpress/theme': specifier: 1.1.0 version: 1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -104,6 +110,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) + gettext-extractor: + specifier: 4.0.6 + version: 4.0.6 jsdom: specifier: ^29.1.1 version: 29.1.1 @@ -239,6 +248,9 @@ importers: '@gopherium/godmin': specifier: 0.7.0 version: 0.7.0(@tanstack/react-router@1.170.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/style-runtime@0.8.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vitest@4.1.10) + '@gopherium/gottext': + specifier: 0.2.0 + version: 0.2.0(@wordpress/i18n@6.26.0)(gettext-extractor@4.0.6) '@gopherium/react-auth': specifier: 0.6.0 version: 0.6.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10) @@ -257,6 +269,9 @@ importers: '@wordpress/dataviews': specifier: 17.3.0 version: 17.3.0(@date-fns/tz@1.5.0)(@emotion/is-prop-valid@1.4.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@wordpress/i18n': + specifier: 6.26.0 + version: 6.26.0 '@wordpress/icons': specifier: 15.3.0 version: 15.3.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -910,6 +925,16 @@ packages: vitest: optional: true + '@gopherium/gottext@0.2.0': + resolution: {integrity: sha512-Pr6whkn01B+b/lXe6ry2kIXtO18G3SJPyMW4NFYWdXnZOBAtqNafmwyv9lTTdn634li+RMSGL0Ej7wqngUDm/A==} + engines: {node: '>=22'} + peerDependencies: + '@wordpress/i18n': 6.26.0 + gettext-extractor: '>=4.0.6 <5.0.0' + peerDependenciesMeta: + gettext-extractor: + optional: true + '@gopherium/react-auth@0.6.0': resolution: {integrity: sha512-uAf9cVpjviuEFRr8/Oo3Ju+WO3CKlDzjjRcR1rXj4PSX2a/InJ6Q321IGnNE9L2mfxRMUgwQGaGMBQ/vaIEStA==} peerDependencies: @@ -1581,6 +1606,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2235,6 +2264,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/parse5@5.0.3': + resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} + '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: @@ -2874,6 +2906,10 @@ packages: constant-case@3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -2921,6 +2957,9 @@ packages: resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} engines: {node: '>=12'} + css-selector-parser@1.4.1: + resolution: {integrity: sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -3219,6 +3258,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -3273,9 +3316,17 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + gettext-extractor@4.0.6: + resolution: {integrity: sha512-V06SFy/KuWLRL+szfr+rleAnLdzUet/wfLlCo4d2qJ8yF0essGdxX5jBqpCx69TB5U6E3jIC9lZtpvnMkNSCTw==} + engines: {node: '>=20'} + gettext-parser@1.4.0: resolution: {integrity: sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==} + gettext-parser@9.1.1: + resolution: {integrity: sha512-ZLeqWPz9OMNrTgMuww0C22kkcNqis+e4059R94t7L7ERlZ2rUNpiDbaAUus+esBC6uBAQWbS9N+R5vJIJg//lw==} + engines: {node: '>=20'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3284,6 +3335,12 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + global-modules@2.0.0: resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} engines: {node: '>=6'} @@ -3567,6 +3624,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3832,6 +3893,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + moment-timezone@0.5.48: resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==} @@ -3959,6 +4024,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -3980,6 +4048,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -4008,6 +4079,10 @@ packages: resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} engines: {node: '>=0.10.0'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -4039,6 +4114,9 @@ packages: engines: {node: '>=18'} hasBin: true + pofile@1.0.11: + resolution: {integrity: sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==} + postcss-safe-parser@7.0.1: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} @@ -4486,6 +4564,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -5375,6 +5458,13 @@ snapshots: stylelint: 17.14.1(typescript@6.0.3) vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@gopherium/gottext@0.2.0(@wordpress/i18n@6.26.0)(gettext-extractor@4.0.6)': + dependencies: + '@wordpress/i18n': 6.26.0 + gettext-parser: 9.1.1 + optionalDependencies: + gettext-extractor: 4.0.6 + '@gopherium/react-auth@0.6.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10)': dependencies: '@tanstack/react-query': 5.101.4(react@19.2.8) @@ -6368,6 +6458,8 @@ snapshots: optionalDependencies: '@types/node': 26.1.2 + '@isaacs/cliui@9.0.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6804,6 +6896,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/parse5@5.0.3': {} + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -7632,6 +7726,8 @@ snapshots: tslib: 2.8.1 upper-case: 2.0.2 + content-type@1.0.5: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} @@ -7678,6 +7774,8 @@ snapshots: css-functions-list@3.3.3: {} + css-selector-parser@1.4.1: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -8011,6 +8109,11 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -8049,11 +8152,25 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gettext-extractor@4.0.6: + dependencies: + '@types/parse5': 5.0.3 + css-selector-parser: 1.4.1 + glob: 11.1.0 + parse5: 6.0.1 + pofile: 1.0.11 + typescript: 5.9.3 + gettext-parser@1.4.0: dependencies: encoding: 0.1.13 safe-buffer: 5.2.1 + gettext-parser@9.1.1: + dependencies: + content-type: 1.0.5 + encoding: 0.1.13 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8062,6 +8179,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + global-modules@2.0.0: dependencies: global-prefix: 3.0.0 @@ -8313,6 +8439,10 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jiti@2.7.0: {} jju@1.4.0: {} @@ -8553,6 +8683,8 @@ snapshots: dependencies: brace-expansion: 5.0.7 + minipass@7.1.3: {} + moment-timezone@0.5.48: dependencies: moment: 2.30.1 @@ -8731,6 +8863,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -8759,6 +8893,8 @@ snapshots: parse-statements@1.0.11: {} + parse5@6.0.1: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -8785,6 +8921,11 @@ snapshots: dependencies: path-root-regex: 0.1.2 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} path-type@4.0.0: {} @@ -8805,6 +8946,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pofile@1.0.11: {} + postcss-safe-parser@7.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 @@ -9241,6 +9384,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.9.3: {} + typescript@6.0.3: {} unbash@4.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09b426eb..d298f5cd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ packages: minimumReleaseAgeExclude: - '@gopherium/godmin' - '@gopherium/react-auth@0.6.0' + - '@gopherium/gottext@0.2.0' overrides: react: ^19.2.8 diff --git a/sdk/frontend/package.json b/sdk/frontend/package.json index 1f65c532..a8c84833 100644 --- a/sdk/frontend/package.json +++ b/sdk/frontend/package.json @@ -10,12 +10,14 @@ }, "dependencies": { "@gopherium/godmin": "0.7.0", + "@gopherium/gottext": "0.2.0", "@gopherium/react-auth": "0.6.0", "@tanstack/react-query": "^5.101.4", "@urql/exchange-graphcache": "9.0.1", "@urql/exchange-retry": "2.0.0", "@wordpress/components": "38.0.0", "@wordpress/dataviews": "17.3.0", + "@wordpress/i18n": "6.26.0", "@wordpress/icons": "15.3.0", "@wordpress/theme": "1.1.0", "@wordpress/ui": "0.19.0", From d63203335ae1f129d690a2c777f96644f3ee1224 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:21:11 +0200 Subject: [PATCH 15/59] build(frontend): resolve one copy of the translation packages --- frontend/vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4454e0fa..8302272e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,8 +18,10 @@ export default defineConfig({ resolve: { dedupe: [ ...godminDedupe, + '@gopherium/gottext', '@tanstack/react-query', '@tanstack/react-router', + '@wordpress/i18n', ], }, server: { @@ -31,6 +33,7 @@ export default defineConfig({ root: repoGlob(''), environment: 'jsdom', env: { TZ: 'UTC' }, + server: { deps: { inline: ['@wordpress/i18n'] } }, setupFiles: [repoGlob('frontend/src/test/setup.ts')], include: [ 'frontend/src/**/*.test.{ts,tsx}', From 0ca710cf5828bcad60e9417ff4b60096d20190b4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:21:23 +0200 Subject: [PATCH 16/59] test(frontend): gate the repository against a second translation copy --- frontend/src/test/i18n-single-copy.test.ts | 60 ++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 frontend/src/test/i18n-single-copy.test.ts diff --git a/frontend/src/test/i18n-single-copy.test.ts b/frontend/src/test/i18n-single-copy.test.ts new file mode 100644 index 00000000..36aaedd8 --- /dev/null +++ b/frontend/src/test/i18n-single-copy.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/// +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { pinnedVersions, resolvedVersions } from '@gopherium/gottext/build' +import { expect, test } from 'vitest' + +/** repositoryRoot returns the directory the workspaces sit under. */ +function repositoryRoot(): string { + return join(import.meta.dirname, '..', '..', '..') +} + +/** PINNING lists every workspace declaring the translation packages. */ +const PINNING = ['frontend', 'sdk/frontend'] + +/** RUNTIME is the package holding the module scoped catalogue. */ +const RUNTIME = '@wordpress/i18n' + +/** BRICK is the package holding the shared translation seams. */ +const BRICK = '@gopherium/gottext' + +/** lockfile reads the workspace lockfile the resolution gate walks. */ +function lockfile(): string { + return readFileSync(join(repositoryRoot(), 'pnpm-lock.yaml'), 'utf8') +} + +test('resolves exactly one copy of the translation runtime', () => { + expect(resolvedVersions(lockfile(), RUNTIME)).toHaveLength(1) +}) + +test('resolves exactly one copy of the translation brick', () => { + expect(resolvedVersions(lockfile(), BRICK)).toHaveLength(1) +}) + +test('pins the runtime exactly, at the one resolved version', () => { + const resolved = resolvedVersions(lockfile(), RUNTIME) + + for (const pinned of pinnedVersions(repositoryRoot(), PINNING, RUNTIME)) { + expect(pinned).toBe(resolved[0]) + } +}) + +test('pins the brick exactly, at the one resolved version', () => { + const resolved = resolvedVersions(lockfile(), BRICK) + + for (const pinned of pinnedVersions(repositoryRoot(), PINNING, BRICK)) { + expect(pinned).toBe(resolved[0]) + } +}) + +test('pins both packages in every workspace that calls them', () => { + expect(pinnedVersions(repositoryRoot(), PINNING, RUNTIME)).toHaveLength(2) + expect(pinnedVersions(repositoryRoot(), PINNING, BRICK)).toHaveLength(2) +}) + +test('reports no pin for a package nothing declares', () => { + expect(pinnedVersions(repositoryRoot(), PINNING, '@alphone/not-a-package')).toEqual([]) +}) From dcca0786a243dcad3b1bf50eb59d444a9bce2630 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:21:45 +0200 Subject: [PATCH 17/59] feat(frontend): settle the locale before the app mounts --- frontend/src/i18n/api.ts | 27 ++++++++++++ frontend/src/i18n/start.ts | 24 +++++++++++ frontend/src/main.tsx | 3 ++ frontend/src/test/locale-boot.test.ts | 60 +++++++++++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 frontend/src/i18n/api.ts create mode 100644 frontend/src/i18n/start.ts create mode 100644 frontend/src/test/locale-boot.test.ts diff --git a/frontend/src/i18n/api.ts b/frontend/src/i18n/api.ts new file mode 100644 index 00000000..a483ffa2 --- /dev/null +++ b/frontend/src/i18n/api.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** LOCALE_QUERY asks the graph which language to serve the reader in. */ +const LOCALE_QUERY = 'query AppLocale { locale }' + +/** DEFAULT_LOCALE is the language the sources are written in. */ +export const DEFAULT_LOCALE = 'en-US' + +/** + * Returns the locale the server resolves for the caller, the default when it cannot say. + * @returns The locale to read in. + */ +export async function fetchLocale(): Promise { + try { + const response = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ query: LOCALE_QUERY }), + }) + const answered: unknown = await response.json() + const locale = (answered as { data?: { locale?: unknown } })?.data?.locale + return typeof locale === 'string' && locale !== '' ? locale : DEFAULT_LOCALE + } catch { + return DEFAULT_LOCALE + } +} diff --git a/frontend/src/i18n/start.ts b/frontend/src/i18n/start.ts new file mode 100644 index 00000000..98f5b6f7 --- /dev/null +++ b/frontend/src/i18n/start.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { globCatalogs, startLocale } from '@gopherium/gottext' +import type { Catalog } from '@gopherium/gottext' + +import { DEFAULT_LOCALE, fetchLocale } from './api' + +/** DOMAIN is the text domain AlphOne's own strings answer under. */ +export const DOMAIN = 'alphone' + +/** own holds the catalogues built from the committed sources. */ +const own = import.meta.glob<{ default: Catalog }>('../languages/*.json') + +/** + * Settles the locale the interface stands in and loads the catalogues it reads. + * @returns The settled locale. + */ +export async function startAppLocale(): Promise { + return startLocale( + fetchLocale, + [{ domain: DOMAIN, load: globCatalogs(own) }], + { defaultLocale: DEFAULT_LOCALE }, + ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c209d315..bc804fff 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -19,8 +19,11 @@ import '@gopherium/react-auth/wpds/style.css' import './index.css' import { graphAuthTransport } from './auth/graphTransport' import { BootLoading } from './boot' +import { startAppLocale } from './i18n/start' import { createAppRouter } from './router' +await startAppLocale() + configureAuthTransport(graphAuthTransport) const queryClient = createAuthQueryClient() const graph = createGraphClient({ diff --git a/frontend/src/test/locale-boot.test.ts b/frontend/src/test/locale-boot.test.ts new file mode 100644 index 00000000..d9bb0366 --- /dev/null +++ b/frontend/src/test/locale-boot.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { HttpResponse, graphql, server } from '@alphone/frontend-sdk/testing' +import { displayLocale } from '@gopherium/gottext' +import { resetLocale } from '@gopherium/gottext/testing' +import { afterEach, expect, test } from 'vitest' + +import { fetchLocale } from '../i18n/api' +import { DOMAIN, startAppLocale } from '../i18n/start' + +afterEach(() => { + resetLocale() +}) + +test('answers the locale the graph resolves', async () => { + server.use(graphql.query('AppLocale', () => HttpResponse.json({ data: { locale: 'es-ES' } }))) + + expect(await fetchLocale()).toBe('es-ES') +}) + +test('answers the default when the graph refuses the ask', async () => { + server.use( + graphql.query('AppLocale', () => + HttpResponse.json({ errors: [{ message: 'authentication required' }] })), + ) + + expect(await fetchLocale()).toBe('en-US') +}) + +test('answers the default when the server cannot be reached', async () => { + server.use(graphql.query('AppLocale', () => HttpResponse.error())) + + expect(await fetchLocale()).toBe('en-US') +}) + +test('answers the default when the graph answers nothing readable', async () => { + server.use(graphql.query('AppLocale', () => HttpResponse.json({ data: {} }))) + + expect(await fetchLocale()).toBe('en-US') +}) + +test('settles the interface on the locale the graph resolves', async () => { + server.use(graphql.query('AppLocale', () => HttpResponse.json({ data: { locale: 'es-ES' } }))) + + const settled = await startAppLocale() + + expect(settled).toBe('es-ES') + expect(displayLocale()).toBe('es-ES') +}) + +test('settles on the default when the graph cannot say', async () => { + server.use(graphql.query('AppLocale', () => HttpResponse.error())) + + expect(await startAppLocale()).toBe('en-US') + expect(displayLocale()).toBe('en-US') +}) + +test('names the domain AlphOne strings answer under', () => { + expect(DOMAIN).toBe('alphone') +}) From ee21d2d682bfe63e9435dc3ab6fe05d12e29e229 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:21:51 +0200 Subject: [PATCH 18/59] feat(sdk): re-export the translation seam plugins read through --- sdk/frontend/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/frontend/index.ts b/sdk/frontend/index.ts index 32147612..c5ba2f3a 100644 --- a/sdk/frontend/index.ts +++ b/sdk/frontend/index.ts @@ -79,3 +79,5 @@ export { export type { CombinedError as GraphFailure } from 'urql' export { SidebarNavigationScreen } from './SidebarNavigationScreen' export { useGraphEvents, useGraphStream } from './stream' +export { __, _n, _nx, _x, sprintf } from '@wordpress/i18n' +export { displayLocale, formatDate } from '@gopherium/gottext' From d7083bb15e7ced0b991ab489c7cd55ceb75475ee Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:28:04 +0200 Subject: [PATCH 19/59] feat(sdk): let a plugin declare the text domain its strings answer under --- sdk/frontend/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sdk/frontend/index.ts b/sdk/frontend/index.ts index c5ba2f3a..90830b88 100644 --- a/sdk/frontend/index.ts +++ b/sdk/frontend/index.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type { Catalog } from '@gopherium/gottext' import type { AnyRoute } from '@tanstack/react-router' import type { ComponentProps, ComponentType, ReactElement } from 'react' @@ -20,11 +21,17 @@ export interface ContactPanel { Panel: ComponentType<{ contactId: string }> } +export interface PluginLocale { + domain: string + load: (locale: string) => Promise +} + export interface FrontendPlugin { id: string routes: (parent: AnyRoute) => AnyRoute[] nav: NavItem[] contactPanels?: ContactPanel[] + locale?: PluginLocale } export { @@ -80,4 +87,5 @@ export type { CombinedError as GraphFailure } from 'urql' export { SidebarNavigationScreen } from './SidebarNavigationScreen' export { useGraphEvents, useGraphStream } from './stream' export { __, _n, _nx, _x, sprintf } from '@wordpress/i18n' -export { displayLocale, formatDate } from '@gopherium/gottext' +export { displayLocale, formatDate, globCatalogs } from '@gopherium/gottext' +export type { Catalog } from '@gopherium/gottext' From 41e25456f1835574d7775d620d512e09ed23faed Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:28:11 +0200 Subject: [PATCH 20/59] feat(plugins): declare one text domain per plugin --- plugins/fields/frontend/index.ts | 10 +++++++++- plugins/fields/frontend/test/locale.test.ts | 13 +++++++++++++ plugins/importer/frontend/index.ts | 10 +++++++++- plugins/importer/frontend/test/locale.test.ts | 13 +++++++++++++ plugins/whatsapp/frontend/index.ts | 10 +++++++++- plugins/whatsapp/frontend/test/locale.test.ts | 13 +++++++++++++ 6 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 plugins/fields/frontend/test/locale.test.ts create mode 100644 plugins/importer/frontend/test/locale.test.ts create mode 100644 plugins/whatsapp/frontend/test/locale.test.ts diff --git a/plugins/fields/frontend/index.ts b/plugins/fields/frontend/index.ts index c1569c3e..1806d15e 100644 --- a/plugins/fields/frontend/index.ts +++ b/plugins/fields/frontend/index.ts @@ -1,14 +1,22 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type { FrontendPlugin } from '@alphone/frontend-sdk' +import { globCatalogs } from '@alphone/frontend-sdk' +import type { Catalog, FrontendPlugin } from '@alphone/frontend-sdk' import { ContactFieldsPanel } from './ContactFieldsPanel' import { fieldsIcon } from './icon' import { routes } from './routes' +/** DOMAIN is the text domain the plugin's strings answer under. */ +export const DOMAIN = 'alphone-fields' + +/** catalogs holds the catalogues built beside the plugin's sources. */ +const catalogs = import.meta.glob<{ default: Catalog }>('./languages/*.json') + export const plugin: FrontendPlugin = { id: 'fields', routes, nav: [{ label: 'Fields', to: '/fields', icon: fieldsIcon }], contactPanels: [{ id: 'fields', Panel: ContactFieldsPanel }], + locale: { domain: DOMAIN, load: globCatalogs(catalogs) }, } diff --git a/plugins/fields/frontend/test/locale.test.ts b/plugins/fields/frontend/test/locale.test.ts new file mode 100644 index 00000000..6b737c17 --- /dev/null +++ b/plugins/fields/frontend/test/locale.test.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { expect, test } from 'vitest' + +import { plugin } from '../index' + +test('declares its own text domain', () => { + expect(plugin.locale?.domain).toBe('alphone-fields') +}) + +test('answers no catalogue for a locale shipping none', async () => { + expect(await plugin.locale?.load('xx-XX')).toBeUndefined() +}) diff --git a/plugins/importer/frontend/index.ts b/plugins/importer/frontend/index.ts index b9718575..d1a00835 100644 --- a/plugins/importer/frontend/index.ts +++ b/plugins/importer/frontend/index.ts @@ -1,12 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type { FrontendPlugin } from '@alphone/frontend-sdk' +import { globCatalogs } from '@alphone/frontend-sdk' +import type { Catalog, FrontendPlugin } from '@alphone/frontend-sdk' import { importerIcon } from './icon' import { routes } from './routes' +/** DOMAIN is the text domain the plugin's strings answer under. */ +export const DOMAIN = 'alphone-importer' + +/** catalogs holds the catalogues built beside the plugin's sources. */ +const catalogs = import.meta.glob<{ default: Catalog }>('./languages/*.json') + export const plugin: FrontendPlugin = { id: 'importer', routes, nav: [{ label: 'Import', to: '/import', icon: importerIcon }], + locale: { domain: DOMAIN, load: globCatalogs(catalogs) }, } diff --git a/plugins/importer/frontend/test/locale.test.ts b/plugins/importer/frontend/test/locale.test.ts new file mode 100644 index 00000000..3f657fa2 --- /dev/null +++ b/plugins/importer/frontend/test/locale.test.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { expect, test } from 'vitest' + +import { plugin } from '../index' + +test('declares its own text domain', () => { + expect(plugin.locale?.domain).toBe('alphone-importer') +}) + +test('answers no catalogue for a locale shipping none', async () => { + expect(await plugin.locale?.load('xx-XX')).toBeUndefined() +}) diff --git a/plugins/whatsapp/frontend/index.ts b/plugins/whatsapp/frontend/index.ts index ef3e0395..7434971a 100644 --- a/plugins/whatsapp/frontend/index.ts +++ b/plugins/whatsapp/frontend/index.ts @@ -1,12 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type { FrontendPlugin } from '@alphone/frontend-sdk' +import { globCatalogs } from '@alphone/frontend-sdk' +import type { Catalog, FrontendPlugin } from '@alphone/frontend-sdk' import { whatsappIcon } from './icon' import { routes } from './routes' +/** DOMAIN is the text domain the plugin's strings answer under. */ +export const DOMAIN = 'alphone-whatsapp' + +/** catalogs holds the catalogues built beside the plugin's sources. */ +const catalogs = import.meta.glob<{ default: Catalog }>('./languages/*.json') + export const plugin: FrontendPlugin = { id: 'whatsapp', routes, nav: [{ label: 'WhatsApp', to: '/whatsapp', icon: whatsappIcon }], + locale: { domain: DOMAIN, load: globCatalogs(catalogs) }, } diff --git a/plugins/whatsapp/frontend/test/locale.test.ts b/plugins/whatsapp/frontend/test/locale.test.ts new file mode 100644 index 00000000..1205f185 --- /dev/null +++ b/plugins/whatsapp/frontend/test/locale.test.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { expect, test } from 'vitest' + +import { plugin } from '../index' + +test('declares its own text domain', () => { + expect(plugin.locale?.domain).toBe('alphone-whatsapp') +}) + +test('answers no catalogue for a locale shipping none', async () => { + expect(await plugin.locale?.load('xx-XX')).toBeUndefined() +}) From b9ccbb9adb83754c531ad027b86c6157ae9d5e16 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 00:28:21 +0200 Subject: [PATCH 21/59] feat(frontend): load one catalogue entry per declared domain --- frontend/src/i18n/start.ts | 33 ++++++++++++++++++++++----- frontend/src/test/locale-boot.test.ts | 20 +++++++++++++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/frontend/src/i18n/start.ts b/frontend/src/i18n/start.ts index 98f5b6f7..d22583c0 100644 --- a/frontend/src/i18n/start.ts +++ b/frontend/src/i18n/start.ts @@ -1,9 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { globCatalogs, startLocale } from '@gopherium/gottext' -import type { Catalog } from '@gopherium/gottext' +import type { Catalog, CatalogEntry } from '@gopherium/gottext' +import { DOMAIN as BRICK_DOMAIN, catalogFor as brickCatalogFor } from '@gopherium/react-auth' + +import type { FrontendPlugin } from '@alphone/frontend-sdk' import { DEFAULT_LOCALE, fetchLocale } from './api' +import { plugins } from '../plugins' /** DOMAIN is the text domain AlphOne's own strings answer under. */ export const DOMAIN = 'alphone' @@ -11,14 +15,31 @@ export const DOMAIN = 'alphone' /** own holds the catalogues built from the committed sources. */ const own = import.meta.glob<{ default: Catalog }>('../languages/*.json') +/** + * Returns the catalogue entry each plugin declares, skipping the ones declaring none. + * @param registered - The plugins the build wired in. + * @returns The declared entries, in registration order. + */ +export function declaredEntries(registered: FrontendPlugin[]): CatalogEntry[] { + return registered.flatMap((plugin) => (plugin.locale === undefined ? [] : [plugin.locale])) +} + +/** + * Returns one catalogue entry per text domain the interface reads. + * @returns The entries, AlphOne's own first and the auth brick's last. + */ +export function localeEntries(): CatalogEntry[] { + return [ + { domain: DOMAIN, load: globCatalogs(own) }, + ...declaredEntries(plugins), + { domain: BRICK_DOMAIN, load: brickCatalogFor }, + ] +} + /** * Settles the locale the interface stands in and loads the catalogues it reads. * @returns The settled locale. */ export async function startAppLocale(): Promise { - return startLocale( - fetchLocale, - [{ domain: DOMAIN, load: globCatalogs(own) }], - { defaultLocale: DEFAULT_LOCALE }, - ) + return startLocale(fetchLocale, localeEntries(), { defaultLocale: DEFAULT_LOCALE }) } diff --git a/frontend/src/test/locale-boot.test.ts b/frontend/src/test/locale-boot.test.ts index d9bb0366..724f7416 100644 --- a/frontend/src/test/locale-boot.test.ts +++ b/frontend/src/test/locale-boot.test.ts @@ -6,7 +6,7 @@ import { resetLocale } from '@gopherium/gottext/testing' import { afterEach, expect, test } from 'vitest' import { fetchLocale } from '../i18n/api' -import { DOMAIN, startAppLocale } from '../i18n/start' +import { DOMAIN, declaredEntries, localeEntries, startAppLocale } from '../i18n/start' afterEach(() => { resetLocale() @@ -58,3 +58,21 @@ test('settles on the default when the graph cannot say', async () => { test('names the domain AlphOne strings answer under', () => { expect(DOMAIN).toBe('alphone') }) + +test('loads one entry per domain beside the react-auth pair', () => { + const domains = localeEntries().map((entry) => entry.domain) + + expect(domains).toEqual([ + 'alphone', + 'alphone-fields', + 'alphone-importer', + 'alphone-whatsapp', + 'gopherium-react-auth', + ]) +}) + +test('skips a plugin declaring no domain of its own', () => { + const declared = declaredEntries([{ id: 'bare', routes: () => [], nav: [] }]) + + expect(declared).toEqual([]) +}) From 3dc976e85b547cee215e15234e1f636e668a89d6 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 01:00:24 +0200 Subject: [PATCH 22/59] feat(graph): list every locale AlphOne serves --- graph/budget_test.go | 2 +- graph/generated.go | 53 ++++++++++++++++++++++++++++++++ graph/schema.graphql | 1 + graph/schema/core.graphqls | 1 + internal/graphres/locale.go | 5 +++ internal/graphres/locale_test.go | 20 ++++++++++++ 6 files changed, 81 insertions(+), 1 deletion(-) diff --git a/graph/budget_test.go b/graph/budget_test.go index 27566177..e1cf71aa 100644 --- a/graph/budget_test.go +++ b/graph/budget_test.go @@ -13,7 +13,7 @@ import ( // Root field budgets per schema owner. const ( - coreRootFieldBudget = 27 + coreRootFieldBudget = 28 pluginRootFieldBudget = 10 ) diff --git a/graph/generated.go b/graph/generated.go index e31247e2..6c0d135c 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -209,6 +209,7 @@ type ComplexityRoot struct { Imports func(childComplexity int) int Locale func(childComplexity int) int Me func(childComplexity int) int + SupportedLocales func(childComplexity int) int Task func(childComplexity int, id uuid.UUID) int Tasks func(childComplexity int, date *time.Time, dueBefore *time.Time, contactID *uuid.UUID, status *string, first *int, after *string) int Tenant func(childComplexity int) int @@ -347,6 +348,7 @@ type QueryResolver interface { Version(ctx context.Context) (string, error) Tenant(ctx context.Context) (*model.Tenant, error) Locale(ctx context.Context) (string, error) + SupportedLocales(ctx context.Context) ([]string, error) Me(ctx context.Context) (*model.Identity, error) Users(ctx context.Context) ([]*model.User, error) Contacts(ctx context.Context, q *string, first *int, after *string) (*model.ContactConnection, error) @@ -1194,6 +1196,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Query.Me(childComplexity), true + case "Query.supportedLocales": + if e.ComplexityRoot.Query.SupportedLocales == nil { + break + } + + return e.ComplexityRoot.Query.SupportedLocales(childComplexity), true case "Query.task": if e.ComplexityRoot.Query.Task == nil { break @@ -6133,6 +6141,29 @@ func (ec *executionContext) fieldContext_Query_locale(_ context.Context, field g return graphql.NewScalarFieldContext("Query", field, true, true, errors.New("field of type String does not have child fields")) } +func (ec *executionContext) _Query_supportedLocales(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_supportedLocales(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().SupportedLocales(ctx) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []string) graphql.Marshaler { + return ec.marshalNString2ᚕstringᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_supportedLocales(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("Query", field, true, true, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10807,6 +10838,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "supportedLocales": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_supportedLocales(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "me": field := field diff --git a/graph/schema.graphql b/graph/schema.graphql index 1d944bba..1829776d 100644 --- a/graph/schema.graphql +++ b/graph/schema.graphql @@ -166,6 +166,7 @@ type Query { version: String! @scope(area: "meta", write: false) tenant: Tenant! @scope(area: "meta", write: false) locale: String! @scope(area: "meta", write: false) + supportedLocales: [String!]! @scope(area: "meta", write: false) me: Identity! @scope(area: "auth", write: false) users: [User!]! @scope(area: "users", write: false) contacts(q: String, first: Int, after: String): ContactConnection! @scope(area: "contacts", write: false) diff --git a/graph/schema/core.graphqls b/graph/schema/core.graphqls index ba4739b0..4e258821 100644 --- a/graph/schema/core.graphqls +++ b/graph/schema/core.graphqls @@ -28,6 +28,7 @@ type Query { version: String! @scope(area: "meta", write: false) tenant: Tenant! @scope(area: "meta", write: false) locale: String! @scope(area: "meta", write: false) + supportedLocales: [String!]! @scope(area: "meta", write: false) } extend type Mutation { diff --git a/internal/graphres/locale.go b/internal/graphres/locale.go index 6e178a14..62571151 100644 --- a/internal/graphres/locale.go +++ b/internal/graphres/locale.go @@ -38,6 +38,11 @@ func (q QueryResolvers) Locale(ctx context.Context) (string, error) { return locale.Resolve(stored, header), nil } +// SupportedLocales answers every locale AlphOne serves, the default first. +func (q QueryResolvers) SupportedLocales(context.Context) ([]string, error) { + return locale.Supported(), nil +} + // SetLocale stores the caller's language choice and answers it back. func (m MutationResolvers) SetLocale(ctx context.Context, chosen string) (string, error) { if err := locale.Validate(chosen); err != nil { diff --git a/internal/graphres/locale_test.go b/internal/graphres/locale_test.go index 09dec83b..e8da89c5 100644 --- a/internal/graphres/locale_test.go +++ b/internal/graphres/locale_test.go @@ -28,6 +28,26 @@ func seedLocaleUser(t *testing.T, pool *pgxpool.Pool) uuid.UUID { return id } +func TestSupportedLocalesListsTheDefaultFirst(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + caller := seedLocaleUser(t, pool) + resolver := &graphres.Resolver{Settings: postgres.NewUserSettingStore(pool)} + client := newGraphClient(t, resolver, caller) + + var asked struct{ SupportedLocales []string } + if err := client.Post(`{ supportedLocales }`, &asked); err != nil { + t.Fatalf("supportedLocales error = %v, want nil", err) + } + if len(asked.SupportedLocales) == 0 || asked.SupportedLocales[0] != "en-US" { + t.Errorf("supportedLocales = %v, want the default first", asked.SupportedLocales) + } + if asked.SupportedLocales[len(asked.SupportedLocales)-1] != "es-ES" { + t.Errorf("supportedLocales = %v, want the proof locale offered", asked.SupportedLocales) + } +} + func TestSetLocaleRoundTripsForASignedInCaller(t *testing.T) { t.Parallel() From c54bc65874de23cb459cf65bafa49104f0cbc64b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 01:00:56 +0200 Subject: [PATCH 23/59] feat(frontend): let the reader choose the language from the served list --- frontend/src/gql/gql.ts | 12 ++++ frontend/src/gql/graphql.ts | 14 ++++ frontend/src/i18n/LanguageScreen.tsx | 75 ++++++++++++++++++++++ frontend/src/i18n/localeOperations.ts | 15 +++++ frontend/src/i18n/nav.tsx | 21 ++++++ frontend/src/menu/coreNav.tsx | 3 +- frontend/src/router.tsx | 8 +++ frontend/src/test/language-route.test.tsx | 78 +++++++++++++++++++++++ frontend/src/test/outline.test.tsx | 1 + 9 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 frontend/src/i18n/LanguageScreen.tsx create mode 100644 frontend/src/i18n/localeOperations.ts create mode 100644 frontend/src/i18n/nav.tsx create mode 100644 frontend/src/test/language-route.test.tsx diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index c466be11..1125ec84 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -27,6 +27,8 @@ type Documents = { "\n\tmutation DeleteContactIdentity($contactId: UUID!, $identityId: UUID!) {\n\t\tdeleteContactIdentity(contactId: $contactId, identityId: $identityId)\n\t}\n": typeof types.DeleteContactIdentityDocument, "\n\tquery ContactDetail($id: UUID!, $first: Int, $after: String) {\n\t\tcontact(id: $id) {\n\t\t\tid\n\t\t\tname\n\t\t\tcreatedAt\n\t\t\tidentities {\n\t\t\t\tid\n\t\t\t\tchannel\n\t\t\t\tidentifier\n\t\t\t\tdisplayName\n\t\t\t}\n\t\t\ttasks(status: \"open\", first: $first, after: $after) {\n\t\t\t\tedges {\n\t\t\t\t\tnode {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\tstatus\n\t\t\t\t\t\tpriority\n\t\t\t\t\t\tdueOn\n\t\t\t\t\t}\n\t\t\t\t\tcursor\n\t\t\t\t}\n\t\t\t\tpageInfo {\n\t\t\t\t\thasNextPage\n\t\t\t\t\tendCursor\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n": typeof types.ContactDetailDocument, "\n\tquery Contacts($q: String, $first: Int, $after: String) {\n\t\tcontacts(q: $q, first: $first, after: $after) {\n\t\t\tedges {\n\t\t\t\tnode {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tcreatedAt\n\t\t\t\t}\n\t\t\t\tcursor\n\t\t\t}\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t\tendCursor\n\t\t\t}\n\t\t}\n\t}\n": typeof types.ContactsDocument, + "\n\tquery SupportedLocales {\n\t\tsupportedLocales\n\t}\n": typeof types.SupportedLocalesDocument, + "\n\tmutation SetLocale($locale: String!) {\n\t\tsetLocale(locale: $locale)\n\t}\n": typeof types.SetLocaleDocument, "\n\tmutation CreateTask($input: CreateTaskInput!) {\n\t\tcreateTask(input: $input) {\n\t\t\ttask {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\tstatus\n\t\t\t\tpriority\n\t\t\t\tdueOn\n\t\t\t}\n\t\t\treplay\n\t\t}\n\t}\n": typeof types.CreateTaskDocument, "\n\tmutation UpdateTask($id: UUID!, $input: UpdateTaskInput!) {\n\t\tupdateTask(id: $id, input: $input) {\n\t\t\tid\n\t\t\ttitle\n\t\t\tstatus\n\t\t\tpriority\n\t\t\tdueOn\n\t\t}\n\t}\n": typeof types.UpdateTaskDocument, "\n\tquery TaskDetail($id: UUID!) {\n\t\ttask(id: $id) {\n\t\t\tid\n\t\t\ttitle\n\t\t\tstatus\n\t\t\tpriority\n\t\t\tdueOn\n\t\t\tcontactId\n\t\t\tcontact {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t}\n\t\t}\n\t}\n": typeof types.TaskDetailDocument, @@ -51,6 +53,8 @@ const documents: Documents = { "\n\tmutation DeleteContactIdentity($contactId: UUID!, $identityId: UUID!) {\n\t\tdeleteContactIdentity(contactId: $contactId, identityId: $identityId)\n\t}\n": types.DeleteContactIdentityDocument, "\n\tquery ContactDetail($id: UUID!, $first: Int, $after: String) {\n\t\tcontact(id: $id) {\n\t\t\tid\n\t\t\tname\n\t\t\tcreatedAt\n\t\t\tidentities {\n\t\t\t\tid\n\t\t\t\tchannel\n\t\t\t\tidentifier\n\t\t\t\tdisplayName\n\t\t\t}\n\t\t\ttasks(status: \"open\", first: $first, after: $after) {\n\t\t\t\tedges {\n\t\t\t\t\tnode {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\tstatus\n\t\t\t\t\t\tpriority\n\t\t\t\t\t\tdueOn\n\t\t\t\t\t}\n\t\t\t\t\tcursor\n\t\t\t\t}\n\t\t\t\tpageInfo {\n\t\t\t\t\thasNextPage\n\t\t\t\t\tendCursor\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n": types.ContactDetailDocument, "\n\tquery Contacts($q: String, $first: Int, $after: String) {\n\t\tcontacts(q: $q, first: $first, after: $after) {\n\t\t\tedges {\n\t\t\t\tnode {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tcreatedAt\n\t\t\t\t}\n\t\t\t\tcursor\n\t\t\t}\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t\tendCursor\n\t\t\t}\n\t\t}\n\t}\n": types.ContactsDocument, + "\n\tquery SupportedLocales {\n\t\tsupportedLocales\n\t}\n": types.SupportedLocalesDocument, + "\n\tmutation SetLocale($locale: String!) {\n\t\tsetLocale(locale: $locale)\n\t}\n": types.SetLocaleDocument, "\n\tmutation CreateTask($input: CreateTaskInput!) {\n\t\tcreateTask(input: $input) {\n\t\t\ttask {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\tstatus\n\t\t\t\tpriority\n\t\t\t\tdueOn\n\t\t\t}\n\t\t\treplay\n\t\t}\n\t}\n": types.CreateTaskDocument, "\n\tmutation UpdateTask($id: UUID!, $input: UpdateTaskInput!) {\n\t\tupdateTask(id: $id, input: $input) {\n\t\t\tid\n\t\t\ttitle\n\t\t\tstatus\n\t\t\tpriority\n\t\t\tdueOn\n\t\t}\n\t}\n": types.UpdateTaskDocument, "\n\tquery TaskDetail($id: UUID!) {\n\t\ttask(id: $id) {\n\t\t\tid\n\t\t\ttitle\n\t\t\tstatus\n\t\t\tpriority\n\t\t\tdueOn\n\t\t\tcontactId\n\t\t\tcontact {\n\t\t\t\tid\n\t\t\t\tname\n\t\t\t}\n\t\t}\n\t}\n": types.TaskDetailDocument, @@ -128,6 +132,14 @@ export function graphql(source: "\n\tquery ContactDetail($id: UUID!, $first: Int * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n\tquery Contacts($q: String, $first: Int, $after: String) {\n\t\tcontacts(q: $q, first: $first, after: $after) {\n\t\t\tedges {\n\t\t\t\tnode {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tcreatedAt\n\t\t\t\t}\n\t\t\t\tcursor\n\t\t\t}\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t\tendCursor\n\t\t\t}\n\t\t}\n\t}\n"): (typeof documents)["\n\tquery Contacts($q: String, $first: Int, $after: String) {\n\t\tcontacts(q: $q, first: $first, after: $after) {\n\t\t\tedges {\n\t\t\t\tnode {\n\t\t\t\t\tid\n\t\t\t\t\tname\n\t\t\t\t\tcreatedAt\n\t\t\t\t}\n\t\t\t\tcursor\n\t\t\t}\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t\tendCursor\n\t\t\t}\n\t\t}\n\t}\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n\tquery SupportedLocales {\n\t\tsupportedLocales\n\t}\n"): (typeof documents)["\n\tquery SupportedLocales {\n\t\tsupportedLocales\n\t}\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n\tmutation SetLocale($locale: String!) {\n\t\tsetLocale(locale: $locale)\n\t}\n"): (typeof documents)["\n\tmutation SetLocale($locale: String!) {\n\t\tsetLocale(locale: $locale)\n\t}\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index be936145..31f889b9 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -122,6 +122,18 @@ export type ContactsQueryVariables = Exact<{ export type ContactsQuery = { contacts: { edges: Array<{ cursor: string, node: { id: string, name: string, createdAt: string } }>, pageInfo: { hasNextPage: boolean, endCursor: string | null } } }; +export type SupportedLocalesQueryVariables = Exact<{ [key: string]: never; }>; + + +export type SupportedLocalesQuery = { supportedLocales: Array }; + +export type SetLocaleMutationVariables = Exact<{ + locale: string; +}>; + + +export type SetLocaleMutation = { setLocale: string }; + export type CreateTaskMutationVariables = Exact<{ input: CreateTaskInput; }>; @@ -204,6 +216,8 @@ export const AddContactIdentityDocument = {"kind":"Document","definitions":[{"ki export const DeleteContactIdentityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteContactIdentity"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteContactIdentity"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"contactId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contactId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityId"}}}]}]}}]} as unknown as DocumentNode; export const ContactDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ContactDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contact"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"identities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"status"},"value":{"kind":"StringValue","value":"open","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"dueOn"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ContactsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Contacts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"q"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contacts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"q"},"value":{"kind":"Variable","name":{"kind":"Name","value":"q"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; +export const SupportedLocalesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SupportedLocales"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"supportedLocales"}}]}}]} as unknown as DocumentNode; +export const SetLocaleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetLocale"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setLocale"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}]}}]} as unknown as DocumentNode; export const CreateTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"task"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"dueOn"}}]}},{"kind":"Field","name":{"kind":"Name","value":"replay"}}]}}]}}]} as unknown as DocumentNode; export const UpdateTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTask"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"dueOn"}}]}}]}}]} as unknown as DocumentNode; export const TaskDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TaskDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"task"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"dueOn"}},{"kind":"Field","name":{"kind":"Name","value":"contactId"}},{"kind":"Field","name":{"kind":"Name","value":"contact"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/src/i18n/LanguageScreen.tsx b/frontend/src/i18n/LanguageScreen.tsx new file mode 100644 index 00000000..22fe784f --- /dev/null +++ b/frontend/src/i18n/LanguageScreen.tsx @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + Button, + ErrorNotice, + LoadingRows, + PageScreen, + SelectControl, + Stack, + Text, + __, + displayLocale, + graphError, + useGraphMutation, + useGraphQuery, +} from '@alphone/frontend-sdk' +import { useState } from 'react' + +import { setLocaleMutation, supportedLocalesQuery } from './localeOperations' + +/** + * Renders the screen where a reader picks the language the interface speaks. + * @returns The language screen element. + */ +export function LanguageScreen() { + const [asked] = useGraphQuery({ query: supportedLocalesQuery }) + const [, runSetLocale] = useGraphMutation(setLocaleMutation) + const [chosen, setChosen] = useState(displayLocale()) + const [saved, setSaved] = useState(false) + const [notice, setNotice] = useState('') + if (asked.error !== undefined) { + return ( + + {__('The languages could not be read.', 'alphone')} + + ) + } + if (asked.data === undefined) { + return ( + + + + ) + } + const offered = asked.data.supportedLocales.map((locale) => ({ label: locale, value: locale })) + const choose = (picked: string) => { + setSaved(false) + setChosen(picked) + } + const submit = async () => { + const answered = await runSetLocale({ locale: chosen }) + if (answered.data) { + setNotice('') + setSaved(true) + return + } + setSaved(false) + setNotice(graphError(answered.error)?.message ?? __('The choice could not be saved.', 'alphone')) + } + return ( + + + option.value === chosen)} + items={offered} + onValueChange={(item) => item?.value != null && choose(item.value)} + /> + + {saved && {__('The language changes when the page next loads.', 'alphone')}} + {notice !== '' && {notice}} + + + ) +} diff --git a/frontend/src/i18n/localeOperations.ts b/frontend/src/i18n/localeOperations.ts new file mode 100644 index 00000000..6e0aafee --- /dev/null +++ b/frontend/src/i18n/localeOperations.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { graphql } from '../gql' + +export const supportedLocalesQuery = graphql(` + query SupportedLocales { + supportedLocales + } +`) + +export const setLocaleMutation = graphql(` + mutation SetLocale($locale: String!) { + setLocale(locale: $locale) + } +`) diff --git a/frontend/src/i18n/nav.tsx b/frontend/src/i18n/nav.tsx new file mode 100644 index 00000000..e9929820 --- /dev/null +++ b/frontend/src/i18n/nav.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { NavItem } from '@alphone/frontend-sdk' + +const globePath = + 'M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm7.9 9h-3a15.6 15.6 0 0 0-1.2-5.4A8 8 0 0 1 19.9 11z' + + 'M12 4.1c.9 1.2 1.7 3.4 1.9 6.9h-3.8c.2-3.5 1-5.7 1.9-6.9zM4.1 13h3a15.6 15.6 0 0 0 1.2 5.4A8 8 0 0 1 4.1 13z' + + 'm3-2h-3a8 8 0 0 1 4.2-5.4A15.6 15.6 0 0 0 7.1 11zM12 19.9c-.9-1.2-1.7-3.4-1.9-6.9h3.8c-.2 3.5-1 5.7-1.9 6.9z' + + 'm3.7-1.5a15.6 15.6 0 0 0 1.2-5.4h3a8 8 0 0 1-4.2 5.4z' + +const languageIcon = ( + +) + +export const languageNavItem: NavItem = { + label: 'Language', + to: '/language', + icon: languageIcon, +} diff --git a/frontend/src/menu/coreNav.tsx b/frontend/src/menu/coreNav.tsx index 4a899fde..688e2c5c 100644 --- a/frontend/src/menu/coreNav.tsx +++ b/frontend/src/menu/coreNav.tsx @@ -4,6 +4,7 @@ import type { NavItem } from '@alphone/frontend-sdk' import { usersNavItem } from '@gopherium/react-auth/wpds' import { contactsNavItem } from '../contacts/nav' +import { languageNavItem } from '../i18n/nav' import { tasksNavItem } from '../tasks/nav' -export const coreNav: NavItem[] = [tasksNavItem, contactsNavItem, usersNavItem] +export const coreNav: NavItem[] = [tasksNavItem, contactsNavItem, usersNavItem, languageNavItem] diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 686db199..47c0cb80 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -9,6 +9,7 @@ import { import type { RouterHistory } from '@tanstack/react-router' import { ContactRoute, ContactsRoute, NewContactRoute } from './contactRoutes' +import { LanguageScreen } from './i18n/LanguageScreen' import { Layout } from './Layout' import { plugins } from './plugins' import { NewTaskRoute, TaskRoute, TasksRoute } from './taskRoutes' @@ -90,8 +91,15 @@ const newTokenRoute = createRoute({ component: NewTokenRoute, }) +const languageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/language', + component: LanguageScreen, +}) + const routeTree = rootRoute.addChildren([ homeRoute, + languageRoute, tasksRoute, newTaskRoute, taskRoute, diff --git a/frontend/src/test/language-route.test.tsx b/frontend/src/test/language-route.test.tsx new file mode 100644 index 00000000..63a23cf9 --- /dev/null +++ b/frontend/src/test/language-route.test.tsx @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { HttpResponse, graphql, server } from '@alphone/frontend-sdk/testing' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test } from 'vitest' + +import { renderAt } from './render' + +beforeEach(() => { + server.use( + graphql.query('SupportedLocales', () => + HttpResponse.json({ data: { supportedLocales: ['en-US', 'es-ES'] } })), + ) +}) + +test('offers every locale the server serves', async () => { + renderAt('/language') + + await userEvent.click(await screen.findByRole('combobox', { name: 'Language' })) + + expect(await screen.findByRole('option', { name: 'en-US' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'es-ES' })).toBeInTheDocument() +}) + +test('stores the chosen locale and confirms the change', async () => { + let stored = '' + server.use( + graphql.mutation('SetLocale', ({ variables }) => { + stored = variables.locale as string + return HttpResponse.json({ data: { setLocale: variables.locale } }) + }), + ) + renderAt('/language') + + await userEvent.click(await screen.findByRole('combobox', { name: 'Language' })) + await userEvent.click(await screen.findByRole('option', { name: 'es-ES' })) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByRole('status')).toHaveTextContent('The language changes when the page next loads.') + expect(stored).toBe('es-ES') +}) + +test('reports a refused choice rather than failing quietly', async () => { + server.use( + graphql.mutation('SetLocale', () => + HttpResponse.json({ errors: [{ message: 'that locale is not served' }] })), + ) + renderAt('/language') + + await userEvent.click(await screen.findByRole('combobox', { name: 'Language' })) + await userEvent.click(await screen.findByRole('option', { name: 'es-ES' })) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('that locale is not served') +}) + +test('reports an unreadable language list inside the page shell', async () => { + server.use( + graphql.query('SupportedLocales', () => + HttpResponse.json({ errors: [{ message: 'boom' }] })), + ) + renderAt('/language') + + expect(await screen.findByText('The languages could not be read.')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Language' })).toBeInTheDocument() +}) + +test('reports a save that answered nothing at all', async () => { + server.use(graphql.mutation('SetLocale', () => HttpResponse.json({ data: null }))) + renderAt('/language') + + await userEvent.click(await screen.findByRole('combobox', { name: 'Language' })) + await userEvent.click(await screen.findByRole('option', { name: 'es-ES' })) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('The choice could not be saved.') +}) diff --git a/frontend/src/test/outline.test.tsx b/frontend/src/test/outline.test.tsx index 1d21eb3e..f00582bc 100644 --- a/frontend/src/test/outline.test.tsx +++ b/frontend/src/test/outline.test.tsx @@ -45,6 +45,7 @@ const corePaths: Record = { '/users/new': '/users/new', '/users/tokens': '/users/tokens', '/users/tokens/new': '/users/tokens/new', + '/language': '/language', } /** paths is every leaf route of the composed router, core plus both plugin roots. */ From ab5acf8eb0cc1bd131a0b93de37ceacc1fd5c1c5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 01:01:09 +0200 Subject: [PATCH 24/59] docs: describe the supported locales the graph lists --- docs/src/content/docs/reference/graphql-api.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index 2695a692..c07ea1e8 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -200,6 +200,9 @@ can ask before anyone signs in. query { locale } ``` +`supportedLocales` lists every locale AlphOne serves, the default first, so a +screen can offer the choice without hardcoding the list. + `setLocale` stores the calling account's choice and answers it back. It takes a locale from the supported list and refuses anything else with the reason `locale_unknown`, naming the list in `meta.supported`. @@ -500,7 +503,7 @@ cannot drift. Point a client at the endpoint, or read | Area | Reads | Writes | | ---- | ----- | ------ | | Session | `me` | `login`, `logout` | -| Locale | `locale` | `setLocale` | +| Locale | `locale`, `supportedLocales` | `setLocale` | | Users | `users` | `createUser`, `setUserDisabled`, `setUserRole` | | Contacts | `contacts`, `contact` | `createContact`, `renameContact`, `addContactIdentity`, `deleteContactIdentity` | | Tasks | `tasks`, `task` | `createTask`, `updateTask` | From e2f8a52c07f69d24a491dcab201aeb313c49617b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 01:23:16 +0200 Subject: [PATCH 25/59] build(frontend): refuse a bare string in JSX --- frontend/.oxlintrc.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 6fa991da..ac6d6a3d 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -3,6 +3,13 @@ "plugins": ["react", "typescript", "oxc"], "rules": { "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } + "react/only-export-components": ["warn", { "allowConstantExport": true }], + "react/jsx-no-literals": "error" + }, + "overrides": [ + { + "files": ["**/test/**", "**/*.test.ts", "**/*.test.tsx"], + "rules": { "react/jsx-no-literals": "off" } + } + ] } From 9a9cff2cf6e1e9726587cabae99dd210517e7097 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 24 Aug 2026 01:23:45 +0200 Subject: [PATCH 26/59] feat(frontend): wrap every core string under the alphone domain --- frontend/src/RailContent.tsx | 7 +-- frontend/src/contacts/ContactScreen.tsx | 42 +++++++++--------- frontend/src/contacts/ContactsScreen.tsx | 23 +++++----- frontend/src/contacts/NewContactScreen.tsx | 9 ++-- frontend/src/contacts/channel.ts | 19 +++++--- frontend/src/contacts/nav.tsx | 5 ++- frontend/src/main.tsx | 4 +- frontend/src/tasks/ContactTasks.tsx | 17 ++++---- frontend/src/tasks/NewTaskScreen.tsx | 20 +++++---- frontend/src/tasks/PrioritySelect.tsx | 23 ++++++---- frontend/src/tasks/TaskScreen.tsx | 19 ++++---- frontend/src/tasks/TasksScreen.tsx | 50 ++++++++++++---------- frontend/src/tasks/nav.tsx | 5 ++- frontend/src/test/channel.test.ts | 4 +- frontend/src/users/NewTokenScreen.tsx | 33 ++++++++------ frontend/src/users/NewUserScreen.tsx | 16 +++---- frontend/src/users/TokensScreen.tsx | 34 ++++++++------- frontend/src/users/UsersScreen.tsx | 42 ++++++++++-------- frontend/src/users/tokenFormat.ts | 27 +++++++----- 19 files changed, 228 insertions(+), 171 deletions(-) diff --git a/frontend/src/RailContent.tsx b/frontend/src/RailContent.tsx index f998c385..4580e6e8 100644 --- a/frontend/src/RailContent.tsx +++ b/frontend/src/RailContent.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { Stack, Text } from '@alphone/frontend-sdk' +import { + __, Stack, Text } from '@alphone/frontend-sdk' import { AccountPanel } from '@gopherium/react-auth/wpds' import { Link, useRouterState } from '@tanstack/react-router' @@ -23,9 +24,9 @@ export function RailContent() { <> - AlphOne + {'AlphOne'} -