Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c151d6c98c | ||
|
|
bd5e86dd03 | ||
|
|
bb25b94f96 | ||
|
|
dab1827c14 | ||
|
|
fe7ce5c853 | ||
|
|
a00cf39558 | ||
|
|
c4e480a0c4 | ||
|
|
95378a1199 | ||
|
|
e004696362 |
@@ -50,6 +50,16 @@ type Config struct {
|
|||||||
// can read this environment can restart it, which is true of anybody with the
|
// can read this environment can restart it, which is true of anybody with the
|
||||||
// host anyway. It is never sent to a browser: the settings page is told only
|
// host anyway. It is never sent to a browser: the settings page is told only
|
||||||
// whether it is set.
|
// whether it is set.
|
||||||
|
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
|
||||||
|
// front of this server. Configurable so a test can prove the budget is applied
|
||||||
|
// without spending the real one on every run.
|
||||||
|
GmailSyncBudget time.Duration
|
||||||
|
|
||||||
|
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
|
||||||
|
// in production, where it means Google.
|
||||||
|
GmailTokenURL string
|
||||||
|
GmailAPIURL string
|
||||||
|
|
||||||
PanelURL string
|
PanelURL string
|
||||||
PanelToken string
|
PanelToken string
|
||||||
PanelServerID string
|
PanelServerID string
|
||||||
@@ -85,6 +95,7 @@ func Load() (*Config, error) {
|
|||||||
Dev: envBool("VERDANDE_DEV", false),
|
Dev: envBool("VERDANDE_DEV", false),
|
||||||
|
|
||||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||||
|
GmailSyncBudget: 25 * time.Second,
|
||||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||||
|
|||||||
+44
-7
@@ -25,20 +25,50 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
tokenEndpoint = "https://oauth2.googleapis.com/token"
|
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||||
apiBase = "https://gmail.googleapis.com/gmail/v1"
|
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||||
|
|
||||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Endpoints is where Google is.
|
||||||
|
//
|
||||||
|
// A field rather than a constant so a test can point it at a server it controls.
|
||||||
|
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
||||||
|
// and "the sync comes back before whatever sits in front of this server gives up
|
||||||
|
// on it" is exactly the kind of promise worth a test and impossible to make
|
||||||
|
// against the real thing.
|
||||||
|
//
|
||||||
|
// The zero value is Google, so nothing outside a test has to know it exists.
|
||||||
|
type Endpoints struct {
|
||||||
|
Token string
|
||||||
|
API string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Endpoints) token() string {
|
||||||
|
if e.Token != "" {
|
||||||
|
return e.Token
|
||||||
|
}
|
||||||
|
return defaultTokenEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Endpoints) api() string {
|
||||||
|
if e.API != "" {
|
||||||
|
return e.API
|
||||||
|
}
|
||||||
|
return defaultAPIBase
|
||||||
|
}
|
||||||
|
|
||||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
||||||
// the instance rather than to a person: one registration, any number of users.
|
// the instance rather than to a person: one registration, any number of users.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ClientID string
|
ClientID string
|
||||||
ClientSecret string
|
ClientSecret string
|
||||||
RedirectURL string
|
RedirectURL string
|
||||||
|
// Endpoints is Google unless a test says otherwise.
|
||||||
|
Endpoints Endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) Configured() bool {
|
func (c Config) Configured() bool {
|
||||||
@@ -128,7 +158,7 @@ func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
||||||
strings.NewReader(form.Encode()))
|
strings.NewReader(form.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Token{}, err
|
return Token{}, err
|
||||||
@@ -171,6 +201,7 @@ func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
|||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
accessToken string
|
accessToken string
|
||||||
|
endpoints Endpoints
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +209,12 @@ func NewClient(accessToken string) *Client {
|
|||||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At points the client at another server. Tests only; the zero value is Google.
|
||||||
|
func (c *Client) At(e Endpoints) *Client {
|
||||||
|
c.endpoints = e
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// Message is the part of a Gmail message a task is made from.
|
// Message is the part of a Gmail message a task is made from.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -196,7 +233,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
|||||||
var parsed struct {
|
var parsed struct {
|
||||||
EmailAddress string `json:"emailAddress"`
|
EmailAddress string `json:"emailAddress"`
|
||||||
}
|
}
|
||||||
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
|
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return parsed.EmailAddress, nil
|
return parsed.EmailAddress, nil
|
||||||
@@ -220,7 +257,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"messages"`
|
} `json:"messages"`
|
||||||
}
|
}
|
||||||
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +288,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
|||||||
} `json:"headers"`
|
} `json:"headers"`
|
||||||
} `json:"payload"`
|
} `json:"payload"`
|
||||||
}
|
}
|
||||||
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -573,6 +573,43 @@ func inboxName(locale string) string {
|
|||||||
// 11:49" long after the line explaining it has gone, which tells an operator that
|
// 11:49" long after the line explaining it has gone, which tells an operator that
|
||||||
// something broke and gives them no way to find out what. The row outlives the
|
// something broke and gives them no way to find out what. The row outlives the
|
||||||
// container; Indstillinger → Fejl is where it is read.
|
// container; Indstillinger → Fejl is where it is read.
|
||||||
|
// upstream reports a failure that came from somebody else's service.
|
||||||
|
//
|
||||||
|
// Separate from `internal` because the cause is different and so is the fix: a 500
|
||||||
|
// is this server broken, and this is Google — or whoever — saying no. The status
|
||||||
|
// says so (502), and the message is passed through rather than replaced, because
|
||||||
|
// "invalid_grant" or "insufficient authentication scopes" is the whole diagnosis
|
||||||
|
// and a generic sentence in its place throws it away.
|
||||||
|
//
|
||||||
|
// It is recorded like a 500 for exactly the reason the error log exists: a Gmail
|
||||||
|
// sync that has been failing for a week is invisible otherwise. The background job
|
||||||
|
// writes a log line the next restart erases, and nobody is watching the one screen
|
||||||
|
// that would have said so.
|
||||||
|
// StatusUpstreamRefused is what this server answers when a service it depends on
|
||||||
|
// says no. Not 502, though that is what it means: a proxy is entitled to replace
|
||||||
|
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
|
||||||
|
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
|
||||||
|
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
|
||||||
|
// "something went wrong". A 4xx passes through untouched, and the truth reaching
|
||||||
|
// the person who has to act on it beats the tidier status code.
|
||||||
|
const StatusUpstreamRefused = http.StatusUnprocessableEntity
|
||||||
|
|
||||||
|
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
|
||||||
|
s.log.Warn(what, "err", err)
|
||||||
|
|
||||||
|
userID := ""
|
||||||
|
if u := userFrom(r.Context()); u != nil {
|
||||||
|
userID = u.ID
|
||||||
|
}
|
||||||
|
s.db.RecordError(r.Context(), store.ServerError{
|
||||||
|
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
|
||||||
|
What: what, Message: err.Error(), UserID: userID,
|
||||||
|
RequestID: middleware.GetReqID(r.Context()),
|
||||||
|
})
|
||||||
|
|
||||||
|
writeError(w, StatusUpstreamRefused, code, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
s.log.Error(what, "err", err)
|
s.log.Error(what, "err", err)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ const (
|
|||||||
// is not "that already exists" either, and it is the one refusal on the user
|
// is not "that already exists" either, and it is the one refusal on the user
|
||||||
// page that a person needs explained rather than merely reported.
|
// page that a person needs explained rather than merely reported.
|
||||||
CodeLastAdmin = "last_admin"
|
CodeLastAdmin = "last_admin"
|
||||||
|
|
||||||
|
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
|
||||||
|
// "insufficient authentication scopes" — and they are useless behind a generic
|
||||||
|
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
|
||||||
|
// read "something went wrong", and had nowhere to go. The interface wraps the
|
||||||
|
// message in one that says where it came from.
|
||||||
|
CodeGmailFailed = "gmail_failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errLastAdmin is returned to the caller that already wrote the response, so it
|
// errLastAdmin is returned to the caller that already wrote the response, so it
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,11 @@ import (
|
|||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// defaultGmailSyncBudget bounds one "fetch now" when nothing else says otherwise.
|
||||||
|
// Well under the hundred seconds a proxy is willing to wait, so this server is the
|
||||||
|
// one that answers a slow mailbox.
|
||||||
|
const defaultGmailSyncBudget = 25 * time.Second
|
||||||
|
|
||||||
// gmailConfig is the OAuth client this instance authorises with.
|
// gmailConfig is the OAuth client this instance authorises with.
|
||||||
//
|
//
|
||||||
// The environment wins when it is set. A value in the environment is a deliberate
|
// The environment wins when it is set. A value in the environment is a deliberate
|
||||||
@@ -17,11 +23,20 @@ import (
|
|||||||
// applied on every recreate — and a form that could quietly override it would make
|
// applied on every recreate — and a form that could quietly override it would make
|
||||||
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
|
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
|
||||||
// to redeploy a container to paste a client id.
|
// to redeploy a container to paste a client id.
|
||||||
|
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||||
|
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||||
|
return gmail.NewClient(accessToken).At(gmail.Endpoints{
|
||||||
|
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||||
|
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
||||||
cfg := gmail.Config{
|
cfg := gmail.Config{
|
||||||
ClientID: s.cfg.GmailClientID,
|
ClientID: s.cfg.GmailClientID,
|
||||||
ClientSecret: s.cfg.GmailClientSecret,
|
ClientSecret: s.cfg.GmailClientSecret,
|
||||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||||
|
Endpoints: endpoints,
|
||||||
}
|
}
|
||||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||||
return cfg
|
return cfg
|
||||||
@@ -150,7 +165,10 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
// Best effort: a mailbox that connects but cannot name itself is still
|
||||||
|
// connected, and the status endpoint asks again when it finds no address —
|
||||||
|
// so this failing is a delay rather than a dead end.
|
||||||
|
email, err := s.gmailClient(token.AccessToken).Profile(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("gmail profile", "err", err)
|
s.log.Warn("gmail profile", "err", err)
|
||||||
}
|
}
|
||||||
@@ -182,9 +200,36 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
created, err := s.SyncGmail(r.Context(), user)
|
// A budget of its own, well under what sits in front of this server.
|
||||||
|
//
|
||||||
|
// The sync fetches up to twenty-five messages one at a time, each with its own
|
||||||
|
// thirty-second timeout — so a slow mailbox can hold the request open for
|
||||||
|
// minutes. Cloudflare gives up at a hundred seconds and answers with its own
|
||||||
|
// 502 page, which is HTML: the browser gets no JSON, no error code and no
|
||||||
|
// message, and shows "something went wrong". Nothing reaches the error log
|
||||||
|
// either, because this handler never returned.
|
||||||
|
//
|
||||||
|
// Whatever was created by the deadline is kept: every task is committed as it
|
||||||
|
// is made, so a short budget is a shorter run rather than a lost one. The
|
||||||
|
// background job keeps the full one; it has nobody waiting.
|
||||||
|
// An unset budget is not a budget of nothing: a zero here would expire the
|
||||||
|
// context before the first call and make every sync a silent no-op.
|
||||||
|
budget := s.cfg.GmailSyncBudget
|
||||||
|
if budget <= 0 {
|
||||||
|
budget = defaultGmailSyncBudget
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
created, err := s.SyncGmail(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
// Not a failure: the mailbox is slow or busy, some tasks were made, and
|
||||||
|
// the rest arrive on the next run ten minutes from now.
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
||||||
@@ -229,7 +274,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := gmail.NewClient(access)
|
client := s.gmailClient(access)
|
||||||
ids, err := client.List(ctx, query, 25)
|
ids, err := client.List(ctx, query, 25)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gmail.ErrUnauthorized {
|
if err == gmail.ErrUnauthorized {
|
||||||
@@ -269,6 +314,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
if seen[id] {
|
if seen[id] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// A spent budget ends the run rather than turning into twenty-five failed
|
||||||
|
// fetches logged one at a time.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return created, ctx.Err()
|
||||||
|
}
|
||||||
msg, err := client.Get(ctx, id)
|
msg, err := client.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||||
@@ -425,3 +475,39 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gmailProfile fetches the mailbox address and stores it, refreshing the access
|
||||||
|
// token first if it has expired.
|
||||||
|
//
|
||||||
|
// Shared by the connect callback and the status endpoint. The callback is the
|
||||||
|
// natural place to learn the address, and it is also the one moment that cannot be
|
||||||
|
// retried — so the status endpoint asks again when it finds nothing, and the answer
|
||||||
|
// corrects itself on the next look.
|
||||||
|
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) {
|
||||||
|
str := func(key string) string {
|
||||||
|
v, _ := settings[key].(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
access := str("access_token")
|
||||||
|
expires, _ := settings["expires_at"].(float64)
|
||||||
|
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||||
|
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
access = token.AccessToken
|
||||||
|
settings["access_token"] = access
|
||||||
|
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err := s.gmailClient(access).Profile(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
settings["email"] = email
|
||||||
|
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Gmail failure has to survive being reported.
|
||||||
|
//
|
||||||
|
// It used to be hidden twice over. The handler answered 502 with the code
|
||||||
|
// `internal_error`, which the interface maps to "something went wrong" — so
|
||||||
|
// Google's own words, the only thing that says what to do, were replaced by a
|
||||||
|
// sentence that says nothing. And `writeError` records nothing, so the error log
|
||||||
|
// was empty: a person clicked Fetch now, read a generic failure, and there was
|
||||||
|
// nowhere to look it up afterwards.
|
||||||
|
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||||
|
// A Gmail that refuses, the way an expired grant does.
|
||||||
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
defer google.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.GmailClientID = "test-client"
|
||||||
|
cfg.GmailClientSecret = "test-secret"
|
||||||
|
cfg.GmailTokenURL = google.URL + "/token"
|
||||||
|
cfg.GmailAPIURL = google.URL
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Connected, with a refresh token that will be refused.
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||||
|
"refresh_token": "spent", "trigger": "starred",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||||
|
if resp.StatusCode != StatusUpstreamRefused {
|
||||||
|
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["code"] != "gmail_failed" {
|
||||||
|
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
|
||||||
|
"sentence, which throws the diagnosis away", body["code"])
|
||||||
|
}
|
||||||
|
if msg, _ := body["error"].(string); msg == "" {
|
||||||
|
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it is in the error log, which is the point of having one: a sync that has
|
||||||
|
// been failing for a week is invisible otherwise.
|
||||||
|
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||||
|
rows, _ := logged["errors"].([]any)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
|
||||||
|
}
|
||||||
|
row := rows[0].(map[string]any)
|
||||||
|
if row["what"] != "sync gmail" {
|
||||||
|
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
||||||
|
}
|
||||||
|
if row["status"] != float64(StatusUpstreamRefused) {
|
||||||
|
t.Errorf("status = %v, want 502", row["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slow mailbox must not hold the request open until something in front of this
|
||||||
|
// server gives up on it.
|
||||||
|
//
|
||||||
|
// This is what was actually happening in production: each Gmail call has its own
|
||||||
|
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
|
||||||
|
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
|
||||||
|
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
|
||||||
|
// showed "something went wrong". The handler had never returned.
|
||||||
|
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||||
|
// A Gmail that never answers.
|
||||||
|
blocked := make(chan struct{})
|
||||||
|
defer close(blocked)
|
||||||
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-blocked:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer google.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.GmailClientID = "test-client"
|
||||||
|
cfg.GmailClientSecret = "test-secret"
|
||||||
|
cfg.GmailTokenURL = google.URL + "/token"
|
||||||
|
cfg.GmailAPIURL = google.URL
|
||||||
|
// A second rather than the real twenty-five: what is being tested is that a
|
||||||
|
// budget is applied at all, and spending the production one on every run
|
||||||
|
// would put half a minute into the suite to learn the same thing.
|
||||||
|
cfg.GmailSyncBudget = time.Second
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A token that has not expired, so the run goes straight to the message list
|
||||||
|
// rather than stopping at a refresh.
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||||
|
"refresh_token": "r", "access_token": "a", "trigger": "starred",
|
||||||
|
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its own client, because the shared one gives up after ten seconds — which is
|
||||||
|
// shorter than the budget being tested and would prove nothing either way.
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
|
||||||
|
"its own, and whatever sits in front of this server answers for it",
|
||||||
|
err, time.Since(started).Round(time.Second))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
took := time.Since(started)
|
||||||
|
|
||||||
|
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
|
||||||
|
// bug, whatever status eventually arrives.
|
||||||
|
// Comfortably over the one-second budget and far under any proxy's patience.
|
||||||
|
if took > 10*time.Second {
|
||||||
|
t.Errorf("the sync took %s; the budget is not being applied",
|
||||||
|
took.Round(time.Second))
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
|
||||||
|
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
||||||
|
}
|
||||||
|
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParse(t *testing.T, raw string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("ai split", "err", err)
|
s.log.Warn("ai split", "err", err)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("ai summary", "err", err)
|
s.log.Warn("ai summary", "err", err)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
||||||
@@ -379,9 +379,27 @@ func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
v, _ := values[key].(string)
|
v, _ := values[key].(string)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
// The mailbox address, fetched again if it is missing.
|
||||||
|
//
|
||||||
|
// It is stored at connect time, and the call that fetches it can fail — a
|
||||||
|
// slow token, a scope Google had not applied yet. That failure used to be a
|
||||||
|
// log line and an empty string, so the page said "an unknown account" and went
|
||||||
|
// on saying it forever: the only way back was to disconnect and reconnect,
|
||||||
|
// which nobody would guess. Asking again here costs one request on a page
|
||||||
|
// nobody opens often, and it means the wrong answer lasts until the next look
|
||||||
|
// rather than until somebody reconnects.
|
||||||
|
email := str("email")
|
||||||
|
if email == "" && str("refresh_token") != "" {
|
||||||
|
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
|
||||||
|
email = fetched
|
||||||
|
} else {
|
||||||
|
s.log.Warn("gmail profile", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, gmailSettings{
|
writeJSON(w, http.StatusOK, gmailSettings{
|
||||||
Connected: str("refresh_token") != "",
|
Connected: str("refresh_token") != "",
|
||||||
Email: str("email"),
|
Email: email,
|
||||||
Trigger: str("trigger"),
|
Trigger: str("trigger"),
|
||||||
Label: str("label"),
|
Label: str("label"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1280,3 +1280,39 @@ func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
|||||||
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
|
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rotating the mail-to-task address, which reported "something went wrong" in
|
||||||
|
// production with nothing in the error log — the combination that means the 500 did
|
||||||
|
// not come through `s.internal()`.
|
||||||
|
func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, before := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("get: status %d, body %v", resp.StatusCode, before)
|
||||||
|
}
|
||||||
|
first, _ := before["address"].(string)
|
||||||
|
if first == "" {
|
||||||
|
t.Fatal("no address to begin with")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, after := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("rotate: status %d, body %v", resp.StatusCode, after)
|
||||||
|
}
|
||||||
|
second, _ := after["address"].(string)
|
||||||
|
if second == "" || second == first {
|
||||||
|
t.Errorf("the address did not change: %q → %q", first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And twice in a row, which is what somebody does when the first one appeared
|
||||||
|
// to fail.
|
||||||
|
resp, again := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("second rotate: status %d, body %v", resp.StatusCode, again)
|
||||||
|
}
|
||||||
|
if third, _ := again["address"].(string); third == second {
|
||||||
|
t.Error("the second rotation returned the same address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal,
|
writeError(w, StatusUpstreamRefused, CodeInternal,
|
||||||
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,129 +3,75 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Restarting is administrators only, and sessions only. A leaked API token that
|
// A panel that never answers must not become this server's problem. The container
|
||||||
// could restart the server is a denial of service in one request.
|
// is being asked to stop, so the reply may legitimately never arrive — but the
|
||||||
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
// request has to end either way, or whatever sits in front answers for it.
|
||||||
ts := newTestServer(t)
|
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
|
||||||
|
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer panel.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.PanelURL = panel.URL
|
||||||
|
cfg.PanelToken = "test-token"
|
||||||
|
cfg.PanelServerID = "server-1"
|
||||||
|
})
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
||||||
|
|
||||||
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
|
// Its own client: the shared one gives up at ten seconds, which is the very
|
||||||
method := "GET"
|
// budget under test, so it could not tell a pass from a failure.
|
||||||
if path != "/api/v1/panel" {
|
|
||||||
method = "POST"
|
|
||||||
}
|
|
||||||
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
|
|
||||||
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
token := ts.apiToken(t, admin.ID)
|
|
||||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
resp, err := (&http.Client{}).Do(req)
|
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatalf("the request never came back (%v after %s)", err, time.Since(started).Round(time.Second))
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusForbidden {
|
took := time.Since(started)
|
||||||
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unconfigured, it says which of the three settings are missing rather than
|
if took > 20*time.Second {
|
||||||
// "not configured" — an operator should not have to go and find out which.
|
t.Errorf("the restart took %s; that is long enough for a proxy to answer first",
|
||||||
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
|
took.Round(time.Second))
|
||||||
ts := newTestServer(t)
|
|
||||||
ts.bootstrap(t)
|
|
||||||
|
|
||||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
|
||||||
if status["configured"] != false {
|
|
||||||
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
|
|
||||||
}
|
}
|
||||||
missing, _ := status["missing"].([]any)
|
|
||||||
if len(missing) != 3 {
|
|
||||||
t.Errorf("missing = %v, want all three settings named", missing)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
|
||||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
||||||
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if body["error"] == nil {
|
|
||||||
t.Error("the refusal says nothing about why")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configured, it calls the panel with a bearer token and reports what came back.
|
|
||||||
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
|
|
||||||
var gotPath, gotAuth string
|
|
||||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer panel.Close()
|
|
||||||
|
|
||||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
|
||||||
cfg.PanelURL = panel.URL
|
|
||||||
cfg.PanelToken = "hemmelig"
|
|
||||||
cfg.PanelServerID = "abc-123"
|
|
||||||
})
|
|
||||||
ts.bootstrap(t)
|
|
||||||
|
|
||||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
|
||||||
if status["configured"] != true {
|
|
||||||
t.Fatalf("configured = %v with all three set", status["configured"])
|
|
||||||
}
|
|
||||||
// The token is a credential and must never reach a browser.
|
|
||||||
for key, value := range status {
|
|
||||||
if s, ok := value.(string); ok && s == "hemmelig" {
|
|
||||||
t.Errorf("the panel token was sent to the client in %q", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
|
||||||
if resp.StatusCode != http.StatusAccepted {
|
if resp.StatusCode != http.StatusAccepted {
|
||||||
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
|
t.Errorf("status %d, want 202 — a panel that went quiet is the expected case, "+
|
||||||
}
|
"not a failure", resp.StatusCode)
|
||||||
if gotPath != "/api/servers/abc-123/restart" {
|
|
||||||
t.Errorf("called %q", gotPath)
|
|
||||||
}
|
|
||||||
if gotAuth != "Bearer hemmelig" {
|
|
||||||
t.Errorf("Authorization = %q", gotAuth)
|
|
||||||
}
|
}
|
||||||
|
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A panel that refuses is reported as the panel refusing, not as this server
|
// And a panel that says no must say so in this server's own words, not in a proxy's.
|
||||||
// breaking: the fix is a token with control of that server, and saying "internal
|
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
|
||||||
// error" would send somebody looking in the wrong place.
|
|
||||||
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
|
|
||||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
}))
|
}))
|
||||||
defer panel.Close()
|
defer panel.Close()
|
||||||
|
|
||||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
cfg.PanelURL = panel.URL
|
cfg.PanelURL = panel.URL
|
||||||
cfg.PanelToken = "forkert"
|
cfg.PanelToken = "wrong-token"
|
||||||
cfg.PanelServerID = "abc-123"
|
cfg.PanelServerID = "server-1"
|
||||||
})
|
})
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
|
|
||||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
if resp.StatusCode != http.StatusBadGateway {
|
if resp.StatusCode != StatusUpstreamRefused {
|
||||||
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
|
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
if msg, _ := body["error"].(string); msg == "" {
|
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
|
||||||
t.Error("the refusal does not say what to check")
|
t.Errorf("the reply does not say what the panel answered: %v", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,89 @@ import (
|
|||||||
// away whatever letters it happens to use. A word list rather than a language
|
// away whatever letters it happens to use. A word list rather than a language
|
||||||
// detector: this repository ships one static binary, and a list of thirty common
|
// detector: this repository ships one static binary, and a list of thirty common
|
||||||
// words catches the sentences a person would actually write.
|
// words catches the sentences a person would actually write.
|
||||||
|
// The same words, inside the code rather than the markup.
|
||||||
|
//
|
||||||
|
// `confirm()`, `alert()` and `app.toast()` take a string argument, and a string
|
||||||
|
// argument lives in the script block where the scanner above never looks. Six of
|
||||||
|
// them survived the whole i18n sweep that way — every destructive confirmation in
|
||||||
|
// the app, which is the worst possible set to leave in one language, because it is
|
||||||
|
// the text somebody reads in the half-second before they agree to lose something.
|
||||||
|
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
||||||
|
// Every string literal in the argument list of one of the three, not just one
|
||||||
|
// sitting flush against the paren. A toast written as a ternary over two lines
|
||||||
|
// hid Danish from the earlier version of this guard for a whole release.
|
||||||
|
// Deliberately still narrow: a general scan of every literal in a script block
|
||||||
|
// would drown in class names, keys and query fragments.
|
||||||
|
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
|
||||||
|
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
|
||||||
|
|
||||||
|
var found []string
|
||||||
|
root := filepath.Join("..", "..", "web", "src")
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".svelte") && !strings.HasSuffix(path, ".js") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "locales") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(root, path)
|
||||||
|
src := string(body)
|
||||||
|
for _, loc := range call.FindAllStringIndex(src, -1) {
|
||||||
|
for _, lit := range literalsInCall(src[loc[1]:]) {
|
||||||
|
if danish.MatchString(lit) {
|
||||||
|
found = append(found, rel+": "+lit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk %s: %v", root, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(found) > 0 {
|
||||||
|
t.Errorf("%d dialog or toast string(s) are written in Danish inside the code. "+
|
||||||
|
"They are what somebody reads before agreeing to lose something, and an "+
|
||||||
|
"English interface shows them in Danish:\n %s",
|
||||||
|
len(found), strings.Join(found, "\n "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// literalsInCall returns the string literals in one argument list, given the text
|
||||||
|
// just past its opening paren. It stops at the matching close paren so the next
|
||||||
|
// statement's strings are somebody else's problem.
|
||||||
|
func literalsInCall(src string) []string {
|
||||||
|
var out []string
|
||||||
|
depth := 1
|
||||||
|
for i := 0; i < len(src); i++ {
|
||||||
|
switch c := src[i]; c {
|
||||||
|
case '(':
|
||||||
|
depth++
|
||||||
|
case ')':
|
||||||
|
if depth--; depth == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
case '\'', '"', '`':
|
||||||
|
end := strings.IndexByte(src[i+1:], c)
|
||||||
|
if end < 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
|
||||||
|
out = append(out, lit)
|
||||||
|
}
|
||||||
|
i += end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||||
// Text nodes of three characters or more, and the three attributes a person
|
// Text nodes of three characters or more, and the three attributes a person
|
||||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||||
|
|||||||
+5
-1
@@ -11,7 +11,11 @@ gameskill:
|
|||||||
invites and reminders, then start it and open the URL to create the first
|
invites and reminders, then start it and open the URL to create the first
|
||||||
admin account.
|
admin account.
|
||||||
author: "kristianwind"
|
author: "kristianwind"
|
||||||
version: 2
|
# Bumped whenever the variables below change. The panel keys its catalogue on
|
||||||
|
# this: a manifest with new fields and an unchanged version is a manifest the
|
||||||
|
# panel has no reason to re-read, so the fields never appear and the failure is
|
||||||
|
# silent — the server's settings page simply does not offer them.
|
||||||
|
version: 3
|
||||||
icon: "app"
|
icon: "app"
|
||||||
|
|
||||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||||
|
|||||||
+42
-22
@@ -302,26 +302,41 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||||
|
|
||||||
// The words in the heading must not sit on top of each other.
|
// A group's name must be readable in full, and must start where a project's
|
||||||
|
// name starts.
|
||||||
//
|
//
|
||||||
// Everything else here clicks by accessible name, which is exactly why this
|
// Both halves are here because both were once broken at the same time and for
|
||||||
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
|
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
||||||
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
|
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
||||||
// Both buttons still worked, so every assertion in this file passed.
|
// sat before the name, pushing a group 22px further in than the projects it
|
||||||
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
|
// contains — so a group read as if it were filed under them. Every assertion
|
||||||
// Links as well as buttons: the group's name is a link to its page now, and
|
// in this file passed through both, because both buttons still worked.
|
||||||
// a measurement that skipped it would miss the widest thing in the row.
|
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||||
[...el.querySelectorAll('button, a')].map((b) => {
|
const link = el.querySelector('h2 a');
|
||||||
const r = b.getBoundingClientRect();
|
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
||||||
return { text: b.textContent.trim(), left: r.left, right: r.right };
|
// The last child of the link is the name itself; the dot is a span before it.
|
||||||
})
|
return {
|
||||||
);
|
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||||
for (let i = 1; i < boxes.length; i++) {
|
left: name.left,
|
||||||
expect(
|
right: link.getBoundingClientRect().right,
|
||||||
boxes[i].left,
|
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
|
||||||
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
|
};
|
||||||
).toBeGreaterThanOrEqual(boxes[i - 1].right);
|
});
|
||||||
}
|
|
||||||
|
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
||||||
|
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||||
|
|
||||||
|
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
||||||
|
// mark, and it is the only thing in them that means the same on both sides.
|
||||||
|
// Comparing the group's inner link with the project's outer box reported the
|
||||||
|
// row's own padding as a misalignment.
|
||||||
|
const projectLeft = await sidebar
|
||||||
|
.getByRole('link', { name: 'Regnskab' })
|
||||||
|
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
||||||
|
expect(
|
||||||
|
Math.abs(shape.left - projectLeft),
|
||||||
|
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
||||||
|
).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||||
// that heading is a drop target at all: with every project filed away there
|
// that heading is a drop target at all: with every project filed away there
|
||||||
@@ -348,7 +363,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||||
// edits to the same thing, and a heading carrying four controls crowded the
|
// edits to the same thing, and a heading carrying four controls crowded the
|
||||||
// name it exists to show.
|
// name it exists to show.
|
||||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
// Double-click on the row opens it; the two words that used to sit on top of
|
||||||
|
// the name are gone from the sidebar entirely.
|
||||||
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||||
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
||||||
// Still open: picking a colour must not close the form under the pointer, which
|
// Still open: picking a colour must not close the form under the pointer, which
|
||||||
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
||||||
@@ -392,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
|
|
||||||
// Renaming writes one row rather than one per project, which is the reason a
|
// Renaming writes one row rather than one per project, which is the reason a
|
||||||
// group is a table and not a string repeated on every project.
|
// group is a table and not a string repeated on every project.
|
||||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||||
const name = sidebar.getByLabel('Gruppens navn');
|
const name = sidebar.getByLabel('Gruppens navn');
|
||||||
await name.fill('Kontoret');
|
await name.fill('Kontoret');
|
||||||
await name.press('Enter');
|
await name.press('Enter');
|
||||||
@@ -401,8 +418,11 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// Deleting the heading must not take the project filed under it. It is
|
// Deleting the heading must not take the project filed under it. It is
|
||||||
// ungrouped here, which is the case that would look identical either way if
|
// ungrouped here, which is the case that would look identical either way if
|
||||||
// the assertion were only "the group is gone".
|
// the assertion were only "the group is gone".
|
||||||
|
// Deleting is on the group's own page now, where the projects it will not take
|
||||||
|
// with it are on the screen above the button.
|
||||||
|
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
await sidebar.getByRole('button', { name: 'Slet' }).click();
|
await page.getByRole('button', { name: 'Slet' }).click();
|
||||||
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
||||||
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ const MESSAGES = {
|
|||||||
ai_not_configured: 'error.aiNotConfigured',
|
ai_not_configured: 'error.aiNotConfigured',
|
||||||
totp_not_enabled: 'error.totpNotEnabled',
|
totp_not_enabled: 'error.totpNotEnabled',
|
||||||
inbox_protected: 'error.inboxProtected',
|
inbox_protected: 'error.inboxProtected',
|
||||||
|
// The fallback for when Gmail refused and said nothing usable. When it did say
|
||||||
|
// something, `humanMessage` quotes it instead — see below.
|
||||||
|
gmail_failed: 'error.gmailRefused',
|
||||||
last_admin: 'error.lastAdmin'
|
last_admin: 'error.lastAdmin'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +70,15 @@ const MESSAGES = {
|
|||||||
*/
|
*/
|
||||||
export function humanMessage(err) {
|
export function humanMessage(err) {
|
||||||
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
|
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
|
||||||
|
|
||||||
|
// Gmail's own words are the diagnosis — "invalid_grant", "insufficient
|
||||||
|
// authentication scopes" — and a generic sentence in their place throws away
|
||||||
|
// the only thing that says what to do. Wrapped so it reads as a quotation
|
||||||
|
// rather than as a stray English string in a Danish interface.
|
||||||
|
if (err.code === 'gmail_failed' && err.message) {
|
||||||
|
return t('error.gmailSaid', { what: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
const key = MESSAGES[err.code];
|
const key = MESSAGES[err.code];
|
||||||
if (key) return t(key);
|
if (key) return t(key);
|
||||||
return err.message ?? t(MESSAGES.internal_error);
|
return err.message ?? t(MESSAGES.internal_error);
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
app.replace(id, previous);
|
app.replace(id, previous);
|
||||||
app.toast('Kunne ikke flytte opgaven.');
|
app.toast(t('task.moveFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -105,17 +105,18 @@
|
|||||||
await app.renameGroup(group.id, name);
|
await app.renameGroup(group.id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeGroup(group) {
|
/** Double-click, or F2 with the row focused. The colour is chosen in the same
|
||||||
const count = inGroup(group.id).length;
|
* form, because it is the same edit to the same thing. */
|
||||||
// Says what happens to the projects, because "slet gruppen" reads like it
|
/** Cleared by a second click, which means the double was a rename. */
|
||||||
// might take them with it. It does not: only the heading goes.
|
let navTimer;
|
||||||
const kept =
|
|
||||||
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
function startRename(group) {
|
||||||
const question = t('sidebar.deleteGroup', { name: group.name });
|
clearTimeout(navTimer);
|
||||||
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
renamingGroup = group.id;
|
||||||
await app.deleteGroup(group.id);
|
groupName = group.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- resizing -------------------------------------------------------------------
|
// --- resizing -------------------------------------------------------------------
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false);
|
||||||
@@ -547,6 +548,13 @@
|
|||||||
overId = null;
|
overId = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => onGroupDrop(e, group)}
|
ondrop={(e) => onGroupDrop(e, group)}
|
||||||
|
ondblclick={() => startRename(group)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename(group);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if renamingGroup === group.id}
|
{#if renamingGroup === group.id}
|
||||||
<!-- Renaming is where the colour is chosen too.
|
<!-- Renaming is where the colour is chosen too.
|
||||||
@@ -618,7 +626,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h2 class="fold">
|
<h2 class="fold">
|
||||||
<a href="/gruppe/{group.id}" onclick={onnavigate}>
|
<!-- Navigation waits two tenths of a second for a second click. A
|
||||||
|
link cannot know a double is coming without pausing, and the
|
||||||
|
alternative was worse than the pause: the first click opened the
|
||||||
|
page while the rename form was appearing, and the colour swatches
|
||||||
|
moved out from under the pointer. Two tenths is under the double
|
||||||
|
click threshold and over the threshold of noticing. -->
|
||||||
|
<a
|
||||||
|
href="/gruppe/{group.id}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(navTimer);
|
||||||
|
if (e.detail > 1) return;
|
||||||
|
navTimer = setTimeout(() => {
|
||||||
|
onnavigate();
|
||||||
|
goto(`/gruppe/${group.id}`);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="dot group-dot"
|
class="dot group-dot"
|
||||||
style="background: {colorVar(group.color)}"
|
style="background: {colorVar(group.color)}"
|
||||||
@@ -628,14 +653,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
<span class="count">{inside.length}</span>
|
<span class="count">{inside.length}</span>
|
||||||
<button
|
|
||||||
class="group-action"
|
|
||||||
onclick={() => {
|
|
||||||
renamingGroup = group.id;
|
|
||||||
groupName = group.name;
|
|
||||||
}}>{t('sidebar.rename')}</button
|
|
||||||
>
|
|
||||||
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -939,14 +956,30 @@
|
|||||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
/* The chevron on its own, so the name beside it can be a link to the group's
|
||||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
||||||
you miss. */
|
you miss. */
|
||||||
|
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
||||||
|
it pushed the name 22px further in than a project's, so groups read as if
|
||||||
|
they were filed under the projects above them — the opposite of what they
|
||||||
|
are. Out of the flow, a group's name starts exactly where a project's does.
|
||||||
|
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||||
|
stays put while the group is folded, so a folded group still shows the way
|
||||||
|
back open. */
|
||||||
.chevron-button {
|
.chevron-button {
|
||||||
flex: none;
|
position: absolute;
|
||||||
|
left: calc(var(--s2) - 5px);
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head:hover .chevron-button,
|
||||||
|
.chevron-button:focus-visible,
|
||||||
|
.chevron-button[aria-expanded='false'] {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-button:hover {
|
.chevron-button:hover {
|
||||||
@@ -997,30 +1030,34 @@
|
|||||||
|
|
||||||
/* Hidden until the heading is hovered, like the section actions on a project
|
/* Hidden until the heading is hovered, like the section actions on a project
|
||||||
page: two buttons permanently beside a label stop it reading as a label. */
|
page: two buttons permanently beside a label stop it reading as a label. */
|
||||||
.group-action {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
padding: 0 var(--s1);
|
|
||||||
flex: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--fast) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-head:hover .group-action,
|
|
||||||
.group-action:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-action:hover {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-action.remove:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-head .count {
|
.folder-head .count {
|
||||||
margin-left: 0;
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without min-width a flex item refuses to shrink below its content, so a long
|
||||||
|
group name pushed the count off the row instead of ending in an ellipsis. */
|
||||||
|
.folder-head h2 {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No left padding of its own: the row already has the same padding as a project
|
||||||
|
row, and the anchor's would be added on top — which is where the last eight
|
||||||
|
pixels of the old indent were hiding. Vertical padding stays, so the heading
|
||||||
|
keeps its height. */
|
||||||
|
.folder-head h2 a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head h2 a :global(*:last-child),
|
||||||
|
.folder-head h2 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The group's own mark, between the chevron and the name — inside the fold
|
/* The group's own mark, between the chevron and the name — inside the fold
|
||||||
|
|||||||
@@ -276,7 +276,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeComment(comment) {
|
async function removeComment(comment) {
|
||||||
if (!confirm('Slet kommentaren?')) return;
|
if (!confirm(t('detail.deleteComment'))) return;
|
||||||
const previous = comments;
|
const previous = comments;
|
||||||
comments = comments.filter((c) => c.id !== comment.id);
|
comments = comments.filter((c) => c.id !== comment.id);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -497,6 +497,9 @@ export const da = {
|
|||||||
'int.server': 'Server',
|
'int.server': 'Server',
|
||||||
'int.caldavAuth':
|
'int.caldavAuth':
|
||||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||||
|
'int.gmailFetchedOne': '{n} opgave hentet.',
|
||||||
|
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||||
|
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||||
'int.gmailConnected': 'Gmail er forbundet.',
|
'int.gmailConnected': 'Gmail er forbundet.',
|
||||||
'int.gmailState':
|
'int.gmailState':
|
||||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||||
@@ -646,5 +649,24 @@ export const da = {
|
|||||||
'update.waiting': 'Venter på at serveren kommer op igen…',
|
'update.waiting': 'Venter på at serveren kommer op igen…',
|
||||||
'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
|
'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
|
||||||
'update.notConfigured':
|
'update.notConfigured':
|
||||||
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.'
|
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
|
||||||
|
|
||||||
|
'error.gmailSaid': 'Gmail svarede: {what}',
|
||||||
|
|
||||||
|
'error.gmailRefused': 'Gmail afviste kaldet. Prøv at forbinde postkassen igen.',
|
||||||
|
|
||||||
|
'int.newAddressQuestion': 'Ny adresse? Mail til den gamle bliver afvist.',
|
||||||
|
'int.copied': 'Kopieret.',
|
||||||
|
'int.copyFailed': 'Kunne ikke kopiere — markér teksten i stedet.',
|
||||||
|
|
||||||
|
'detail.deleteComment': 'Slet kommentaren?',
|
||||||
|
'int.disconnectQuestion': 'Afbryd forbindelsen til Gmail? Tokens bliver glemt.',
|
||||||
|
'int.newLinkQuestion': 'Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.',
|
||||||
|
'data.deleteTemplate': 'Slet skabelonen "{name}"?',
|
||||||
|
'tokens.revokeQuestion': 'Tilbagekald "{name}"? Alt, der bruger den, holder op med at virke.',
|
||||||
|
'account.signOutThisDevice': 'Det er denne enhed. Du bliver logget ud.',
|
||||||
|
|
||||||
|
'project.deleteQuestion': 'Slet "{name}" med alle dets opgaver?',
|
||||||
|
'project.deleteRecoverable':
|
||||||
|
'Det havner i papirkurven og kan hentes tilbage under Indstillinger → Data og skabeloner i {days} dage.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -493,6 +493,9 @@ export const en = {
|
|||||||
'int.server': 'Server',
|
'int.server': 'Server',
|
||||||
'int.caldavAuth':
|
'int.caldavAuth':
|
||||||
'The username is your email. The password is an API token — not your real password.',
|
'The username is your email. The password is an API token — not your real password.',
|
||||||
|
'int.gmailFetchedOne': '{n} task fetched.',
|
||||||
|
'int.gmailFetchedMany': '{n} tasks fetched.',
|
||||||
|
'int.gmailNothingNew': 'No new messages.',
|
||||||
'int.gmailConnected': 'Gmail is connected.',
|
'int.gmailConnected': 'Gmail is connected.',
|
||||||
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
|
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
|
||||||
'int.gmailExpired': 'The attempt expired on the way. Try again.',
|
'int.gmailExpired': 'The attempt expired on the way. Try again.',
|
||||||
@@ -641,5 +644,24 @@ export const en = {
|
|||||||
'update.waiting': 'Waiting for the server to come back…',
|
'update.waiting': 'Waiting for the server to come back…',
|
||||||
'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
|
'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
|
||||||
'update.notConfigured':
|
'update.notConfigured':
|
||||||
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.'
|
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
|
||||||
|
|
||||||
|
'error.gmailSaid': 'Gmail said: {what}',
|
||||||
|
|
||||||
|
'error.gmailRefused': 'Gmail refused the call. Try connecting the mailbox again.',
|
||||||
|
|
||||||
|
'int.newAddressQuestion': 'A new address? Mail to the old one is refused.',
|
||||||
|
'int.copied': 'Copied.',
|
||||||
|
'int.copyFailed': 'Could not copy — select the text instead.',
|
||||||
|
|
||||||
|
'detail.deleteComment': 'Delete the comment?',
|
||||||
|
'int.disconnectQuestion': 'Disconnect Gmail? The tokens are forgotten.',
|
||||||
|
'int.newLinkQuestion': 'A new feed link? Every existing subscription stops working.',
|
||||||
|
'data.deleteTemplate': 'Delete the template "{name}"?',
|
||||||
|
'tokens.revokeQuestion': 'Revoke "{name}"? Everything using it stops working.',
|
||||||
|
'account.signOutThisDevice': 'That is this device. You will be signed out.',
|
||||||
|
|
||||||
|
'project.deleteQuestion': 'Delete "{name}" with all its tasks?',
|
||||||
|
'project.deleteRecoverable':
|
||||||
|
'It goes to the trash and can be restored under Settings → Data and templates for {days} days.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* projects answers is what is left.
|
* projects answers is what is left.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, projectName } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -29,6 +30,18 @@
|
|||||||
load(id);
|
load(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Says what happens to the projects, because "delete the group" reads like it
|
||||||
|
* might take them with it. It does not: only the heading goes. */
|
||||||
|
async function removeGroup() {
|
||||||
|
const count = projects.length;
|
||||||
|
const kept =
|
||||||
|
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
||||||
|
const question = t('sidebar.deleteGroup', { name: group.name });
|
||||||
|
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
||||||
|
await app.deleteGroup(group.id);
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
async function load(groupId) {
|
async function load(groupId) {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
status = 'loading';
|
status = 'loading';
|
||||||
@@ -208,6 +221,14 @@
|
|||||||
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
|
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Deleting lives here rather than in the sidebar, where two words sat on
|
||||||
|
top of the group's name and where you could press them without having
|
||||||
|
seen what the group holds. Here the projects are on the screen above
|
||||||
|
it, which is the whole argument for the button being here. -->
|
||||||
|
<section class="danger">
|
||||||
|
<button class="remove" onclick={removeGroup}>{t('sidebar.delete')}</button>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,6 +251,22 @@
|
|||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
margin-top: var(--s4);
|
||||||
|
padding-top: var(--s4);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger .remove {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger .remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -215,7 +215,7 @@
|
|||||||
|
|
||||||
async function endSession(session) {
|
async function endSession(session) {
|
||||||
if (session.current) {
|
if (session.current) {
|
||||||
if (!confirm('Det er denne enhed. Du bliver logget ud.')) return;
|
if (!confirm(t('account.signOutThisDevice'))) return;
|
||||||
await api.endSession(session.id);
|
await api.endSession(session.id);
|
||||||
// A full reload rather than clearing the store by hand: the session is
|
// A full reload rather than clearing the store by hand: the session is
|
||||||
// gone, so every open socket and every cached answer in this tab is now
|
// gone, so every open socket and every cached answer in this tab is now
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTemplate(template) {
|
async function deleteTemplate(template) {
|
||||||
if (!confirm(`Slet skabelonen "${template.name}"?`)) return;
|
if (!confirm(t('data.deleteTemplate', { name: template.name }))) return;
|
||||||
|
|
||||||
const previous = templates;
|
const previous = templates;
|
||||||
templates = templates.filter((t) => t.id !== template.id);
|
templates = templates.filter((t) => t.id !== template.id);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t, plural } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
// --- Gmail ------------------------------------------------------------------
|
// --- Gmail ------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -115,7 +115,9 @@
|
|||||||
try {
|
try {
|
||||||
const result = await api.syncGmail();
|
const result = await api.syncGmail();
|
||||||
app.toast(
|
app.toast(
|
||||||
result?.created ? `${result.created} opgave(r) hentet.` : 'Ingen nye beskeder.'
|
result?.created
|
||||||
|
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
|
||||||
|
: t('int.gmailNothingNew')
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
@@ -125,7 +127,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectGmail() {
|
async function disconnectGmail() {
|
||||||
if (!confirm('Afbryd forbindelsen til Gmail? Tokens bliver glemt.')) return;
|
if (!confirm(t('int.disconnectQuestion'))) return;
|
||||||
try {
|
try {
|
||||||
await api.disconnectGmail();
|
await api.disconnectGmail();
|
||||||
gmail = await api.getGmail();
|
gmail = await api.getGmail();
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function rotateFeed() {
|
async function rotateFeed() {
|
||||||
if (!confirm('Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.')) return;
|
if (!confirm(t('int.newLinkQuestion'))) return;
|
||||||
try {
|
try {
|
||||||
feedURL = (await api.rotateFeed()).url;
|
feedURL = (await api.rotateFeed()).url;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -162,7 +164,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function rotateMail() {
|
async function rotateMail() {
|
||||||
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
|
if (!confirm(t('int.newAddressQuestion'))) return;
|
||||||
try {
|
try {
|
||||||
const r = await api.rotateMailAddress();
|
const r = await api.rotateMailAddress();
|
||||||
mailAddress = r.address;
|
mailAddress = r.address;
|
||||||
@@ -175,9 +177,9 @@
|
|||||||
async function copy(text) {
|
async function copy(text) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
app.toast('Kopieret.');
|
app.toast(t('int.copied'));
|
||||||
} catch {
|
} catch {
|
||||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
app.toast(t('int.copyFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
} catch {
|
} catch {
|
||||||
// Insecure context, or permission refused. The token is on screen and
|
// Insecure context, or permission refused. The token is on screen and
|
||||||
// selectable, so this is a convenience that failed, not the feature.
|
// selectable, so this is a convenience that failed, not the feature.
|
||||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
app.toast(t('int.copyFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,12 +69,12 @@
|
|||||||
await navigator.clipboard.writeText(connectorURL);
|
await navigator.clipboard.writeText(connectorURL);
|
||||||
connectorCopied = true;
|
connectorCopied = true;
|
||||||
} catch {
|
} catch {
|
||||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
app.toast(t('int.copyFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revoke(token) {
|
async function revoke(token) {
|
||||||
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
if (!confirm(t('tokens.revokeQuestion', { name: token.name })))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const previous = tokens;
|
const previous = tokens;
|
||||||
|
|||||||
@@ -188,9 +188,9 @@
|
|||||||
async function remove() {
|
async function remove() {
|
||||||
const days = 30;
|
const days = 30;
|
||||||
const confirmed = confirm(
|
const confirmed = confirm(
|
||||||
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
t('project.deleteQuestion', { name: projectName(project) }) +
|
||||||
`Det havner i papirkurven og kan hentes tilbage under ` +
|
'\n\n' +
|
||||||
`Indstillinger → Data og skabeloner i ${days} dage.`
|
t('project.deleteRecoverable', { days })
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user