Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e480a0c4 | ||
|
|
95378a1199 | ||
|
|
e004696362 |
@@ -573,6 +573,34 @@ 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.
|
||||||
|
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: http.StatusBadGateway,
|
||||||
|
What: what, Message: err.Error(), UserID: userID,
|
||||||
|
RequestID: middleware.GetReqID(r.Context()),
|
||||||
|
})
|
||||||
|
|
||||||
|
writeError(w, http.StatusBadGateway, 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
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
email, err := gmail.NewClient(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)
|
||||||
@@ -184,7 +187,7 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
created, err := s.SyncGmail(r.Context(), user)
|
created, err := s.SyncGmail(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
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})
|
||||||
@@ -425,3 +428,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 := gmail.NewClient(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,70 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
})
|
||||||
|
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 != http.StatusBadGateway {
|
||||||
|
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(http.StatusBadGateway) {
|
||||||
|
t.Errorf("status = %v, want 502", row["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,56 @@ 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) {
|
||||||
|
// The argument of one of the three, quoted or backticked. Deliberately narrow:
|
||||||
|
// a general scan of every string literal in a Svelte script block would drown
|
||||||
|
// in class names, keys and query fragments.
|
||||||
|
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(\s*['` + "`" + `]([^'` + "`" + `]{4,})['` + "`" + `]`)
|
||||||
|
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|opgave|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)
|
||||||
|
for _, m := range call.FindAllStringSubmatch(string(body), -1) {
|
||||||
|
if danish.MatchString(m[1]) {
|
||||||
|
found = append(found, rel+": "+m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -646,5 +646,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.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -641,5 +641,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.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -125,7 +125,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 +153,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 +162,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 +175,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