Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd6dca852 |
@@ -2079,6 +2079,53 @@ paths:
|
|||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/push/test:
|
||||||
|
post:
|
||||||
|
tags: [notifications]
|
||||||
|
summary: Send one notification to your own devices, and say what happened.
|
||||||
|
description: |
|
||||||
|
The answer to "no notifications arrive". A push that a service refuses is
|
||||||
|
the ordinary failure — a stale subscription, a browser that revoked
|
||||||
|
permission without telling the server — and every one of them looks
|
||||||
|
exactly like silence from the outside.
|
||||||
|
|
||||||
|
It goes through the same path a real notification takes, so what it proves
|
||||||
|
is the thing that has to work. The title and body come from the caller
|
||||||
|
because the dictionaries live in the interface.
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title: { type: string, maxLength: 200 }
|
||||||
|
body: { type: string, maxLength: 500 }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: What each device did with it.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
subscriptions:
|
||||||
|
type: integer
|
||||||
|
description: Devices registered for this account.
|
||||||
|
sent:
|
||||||
|
type: integer
|
||||||
|
failed:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
description: The push service's host — never the endpoint, which is a credential.
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
description: What the service said.
|
||||||
|
|
||||||
/api/v1/sections/{sectionID}:
|
/api/v1/sections/{sectionID}:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- MCP -----------------------------------------------------------------------------
|
// --- MCP -----------------------------------------------------------------------------
|
||||||
@@ -1468,3 +1470,62 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
|
|||||||
t.Error("the second rotation returned the same address")
|
t.Error("the second rotation returned the same address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Der kommer ingen notifikationer" må kunne besvares.
|
||||||
|
//
|
||||||
|
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
|
||||||
|
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
|
||||||
|
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
|
||||||
|
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
|
||||||
|
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
|
||||||
|
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
|
||||||
|
"title": "Prøve", "body": "kom den frem?",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["subscriptions"] != float64(0) {
|
||||||
|
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
|
||||||
|
// som et navngivent afslag og ikke som tavshed.
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
|
||||||
|
UserID: user.ID,
|
||||||
|
Endpoint: "https://push.example.invalid/abc",
|
||||||
|
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
|
||||||
|
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
|
||||||
|
if body["subscriptions"] != float64(1) {
|
||||||
|
t.Fatalf("subscriptions = %v", body["subscriptions"])
|
||||||
|
}
|
||||||
|
failed, _ := body["failed"].([]any)
|
||||||
|
if len(failed) != 1 {
|
||||||
|
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
|
||||||
|
}
|
||||||
|
first, _ := failed[0].(map[string]any)
|
||||||
|
if first["service"] != "push.example.invalid" {
|
||||||
|
t.Errorf("service = %v", first["service"])
|
||||||
|
}
|
||||||
|
if first["reason"] == "" || first["reason"] == nil {
|
||||||
|
t.Error("afslaget kom uden en grund")
|
||||||
|
}
|
||||||
|
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
|
||||||
|
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
|
||||||
|
if strings.Contains(fmt.Sprint(first), "/abc") {
|
||||||
|
t.Errorf("endepunktet lækkede med ud: %v", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -106,18 +108,44 @@ func (s *Server) PushToUser(userID, title, body, projectID string) {
|
|||||||
s.pushToUser(userID, title, body, projectID)
|
s.pushToUser(userID, title, body, projectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
// pushOutcome is what happened, per device.
|
||||||
|
//
|
||||||
|
// It exists because "nothing arrives" was unanswerable. A push that a service
|
||||||
|
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
|
||||||
|
// service will not take, a browser that revoked permission without telling the
|
||||||
|
// server — and every one of them looked exactly like silence.
|
||||||
|
type pushOutcome struct {
|
||||||
|
Subscriptions int `json:"subscriptions"`
|
||||||
|
Sent int `json:"sent"`
|
||||||
|
Failed []pushFailure `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushFailure names the service and quotes what it said.
|
||||||
|
//
|
||||||
|
// The host and not the endpoint: the rest of that URL is the credential the push
|
||||||
|
// service issued for this device, and it does not belong in an answer, a log line
|
||||||
|
// or a screenshot.
|
||||||
|
type pushFailure struct {
|
||||||
|
Service string `json:"service"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
var out pushOutcome
|
||||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || len(subs) == 0 {
|
||||||
return
|
return out
|
||||||
}
|
}
|
||||||
|
out.Subscriptions = len(subs)
|
||||||
|
|
||||||
public, private, err := s.vapidKeys(ctx)
|
public, private, err := s.vapidKeys(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("vapid keys", "err", err)
|
s.log.Warn("vapid keys", "err", err)
|
||||||
return
|
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||||
@@ -128,13 +156,101 @@ func (s *Server) pushToUser(userID, title, body, projectID string) {
|
|||||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
out.Sent++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
|
// Warn, not Debug.
|
||||||
|
//
|
||||||
|
// This was the one line that would have said why no notification ever
|
||||||
|
// arrived, and at Debug it is not printed on an instance running at the
|
||||||
|
// default level. A delivery that fails silently and a delivery that does
|
||||||
|
// not happen look the same from the outside, and the difference is the
|
||||||
|
// whole diagnosis.
|
||||||
|
reason := pushReason(sub.Endpoint, err)
|
||||||
|
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
|
||||||
|
out.Failed = append(out.Failed, pushFailure{
|
||||||
|
Service: pushHost(sub.Endpoint),
|
||||||
|
Reason: reason,
|
||||||
|
})
|
||||||
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
||||||
s.log.Warn("record push failure", "err", recErr)
|
s.log.Warn("record push failure", "err", recErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushReason is what went wrong, with the endpoint taken back out of it.
|
||||||
|
//
|
||||||
|
// Go wraps a transport failure as `*url.Error`, and its message is `Post
|
||||||
|
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
|
||||||
|
// endpoint, which is the credential the push service issued for that device. It
|
||||||
|
// would have gone into the answer, the log line and any screenshot of either. A
|
||||||
|
// test written to prove the host was reported instead is what found it.
|
||||||
|
//
|
||||||
|
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
|
||||||
|
// anyway: the next error type to carry it will not come with a warning.
|
||||||
|
func pushReason(endpoint string, err error) string {
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||||
|
err = urlErr.Err
|
||||||
|
}
|
||||||
|
reason := err.Error()
|
||||||
|
if endpoint != "" {
|
||||||
|
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushHost is the service an endpoint belongs to, and nothing else from it.
|
||||||
|
func pushHost(endpoint string) string {
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return "ukendt"
|
||||||
|
}
|
||||||
|
return u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePushTest sends one notification to this account's devices and says what
|
||||||
|
// happened to it.
|
||||||
|
//
|
||||||
|
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
|
||||||
|
// settings page said "on for this device" as soon as a subscription existed, and a
|
||||||
|
// refusal from the push service went into a Debug line nobody sees. There was no
|
||||||
|
// way to tell "the browser never subscribed" from "the service refused it" from
|
||||||
|
// "nothing has come due yet" — three different problems with one appearance.
|
||||||
|
//
|
||||||
|
// It goes through the same path as a real notification rather than a shortcut, so
|
||||||
|
// what it proves is the thing that actually has to work.
|
||||||
|
//
|
||||||
|
// The words come from the client, which is where the dictionaries are. A server
|
||||||
|
// that carries its own copy of "This is a test" carries it in one language, and
|
||||||
|
// this one deliberately keeps its prose in the interface — see
|
||||||
|
// `internal/i18n`. The text is only ever shown back to the person who asked for
|
||||||
|
// it, on their own devices.
|
||||||
|
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(req.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = "verdande"
|
||||||
|
}
|
||||||
|
// Bounded: this is written into a notification on somebody's lock screen, and
|
||||||
|
// a megabyte of title is not a notification.
|
||||||
|
if len(title) > 200 {
|
||||||
|
title = title[:200]
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(req.Body)
|
||||||
|
if len(body) > 500 {
|
||||||
|
body = body[:500]
|
||||||
|
}
|
||||||
|
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/key", s.handlePushKey)
|
r.Get("/key", s.handlePushKey)
|
||||||
r.Post("/subscribe", s.handleSubscribePush)
|
r.Post("/subscribe", s.handleSubscribePush)
|
||||||
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
||||||
|
// One notification, to this account's own devices, reporting what
|
||||||
|
// each of them did with it. The answer to "der kommer ikke nogen".
|
||||||
|
r.Post("/test", s.handlePushTest)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
||||||
|
|||||||
@@ -366,6 +366,8 @@ export const api = {
|
|||||||
pushKey: () => get('/push/key'),
|
pushKey: () => get('/push/key'),
|
||||||
subscribePush: (subscription) => post('/push/subscribe', subscription),
|
subscribePush: (subscription) => post('/push/subscribe', subscription),
|
||||||
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
|
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
|
||||||
|
// Teksten kommer herfra, fordi ordbøgerne gør. Serveren har ingen.
|
||||||
|
testPush: (title, body) => post('/push/test', { title, body }),
|
||||||
|
|
||||||
// --- integrations
|
// --- integrations
|
||||||
getMailAddress: () => get('/mail-address'),
|
getMailAddress: () => get('/mail-address'),
|
||||||
|
|||||||
@@ -242,6 +242,13 @@ export const da = {
|
|||||||
'push.turnOff': 'Slå fra',
|
'push.turnOff': 'Slå fra',
|
||||||
'push.turnOn': 'Slå til på denne enhed',
|
'push.turnOn': 'Slå til på denne enhed',
|
||||||
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
|
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
|
||||||
|
'push.test': 'Send en prøve',
|
||||||
|
'push.testing': 'Sender …',
|
||||||
|
'push.testTitle': 'Prøve fra verdande',
|
||||||
|
'push.testBody': 'Kom den frem, virker notifikationer på denne enhed.',
|
||||||
|
'push.testSent': 'Sendt til {n} enhed(er). Kom den ikke frem, er det browseren eller styresystemet, der holder den tilbage — se om verdande må sende notifikationer.',
|
||||||
|
'push.testNone': 'Ingen enheder er tilmeldt endnu. Slå notifikationer til her på enheden først.',
|
||||||
|
'push.testFailed': '{service} afviste den: {reason}',
|
||||||
'push.recent': 'Seneste',
|
'push.recent': 'Seneste',
|
||||||
'push.none': 'Ingenting endnu.',
|
'push.none': 'Ingenting endnu.',
|
||||||
'push.open': 'Åbn',
|
'push.open': 'Åbn',
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ export const en = {
|
|||||||
'push.turnOff': 'Turn off',
|
'push.turnOff': 'Turn off',
|
||||||
'push.turnOn': 'Turn on for this device',
|
'push.turnOn': 'Turn on for this device',
|
||||||
'push.perBrowser': 'Applies only to the browser you are in right now.',
|
'push.perBrowser': 'Applies only to the browser you are in right now.',
|
||||||
|
'push.test': 'Send a test',
|
||||||
|
'push.testing': 'Sending …',
|
||||||
|
'push.testTitle': 'Test from verdande',
|
||||||
|
'push.testBody': 'If this arrived, notifications work on this device.',
|
||||||
|
'push.testSent': 'Sent to {n} device(s). If nothing arrived, the browser or the operating system is holding it back — check that verdande is allowed to send notifications.',
|
||||||
|
'push.testNone': 'No devices are registered yet. Turn notifications on here first.',
|
||||||
|
'push.testFailed': '{service} refused it: {reason}',
|
||||||
'push.recent': 'Recent',
|
'push.recent': 'Recent',
|
||||||
'push.none': 'Nothing yet.',
|
'push.none': 'Nothing yet.',
|
||||||
'push.open': 'Open',
|
'push.open': 'Open',
|
||||||
|
|||||||
@@ -35,6 +35,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let testing = $state(false);
|
||||||
|
let testResult = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Én notifikation, hele vejen gennem den rigtige rute, og et svar på hvad der
|
||||||
|
* skete med den.
|
||||||
|
*
|
||||||
|
* Den siger ikke "sendt" og lader det være. Kommer den frem, ved man det, fordi
|
||||||
|
* den ligger på skærmen; kommer den ikke, står der her, hvem der afviste den og
|
||||||
|
* med hvilke ord — og hvis ingen afviste den, står der, at det så er browseren
|
||||||
|
* eller styresystemet, der holder den tilbage. Det er de tre steder, den kan
|
||||||
|
* blive væk, og nu peger svaret på ét af dem.
|
||||||
|
*/
|
||||||
|
async function testPush() {
|
||||||
|
testing = true;
|
||||||
|
testResult = null;
|
||||||
|
try {
|
||||||
|
const r = await api.testPush(t('push.testTitle'), t('push.testBody'));
|
||||||
|
if (!r.subscriptions) {
|
||||||
|
testResult = { bad: true, text: t('push.testNone') };
|
||||||
|
} else if (r.failed?.length) {
|
||||||
|
testResult = {
|
||||||
|
bad: true,
|
||||||
|
text: r.failed
|
||||||
|
.map((f) => t('push.testFailed', { service: f.service, reason: f.reason }))
|
||||||
|
.join(' ')
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
testResult = { bad: false, text: t('push.testSent', { n: r.sent }) };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
testResult = { bad: true, text: humanMessage(e) };
|
||||||
|
} finally {
|
||||||
|
testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function disablePush() {
|
async function disablePush() {
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
@@ -165,7 +202,18 @@
|
|||||||
<p class="hint">{t('push.on')}</p>
|
<p class="hint">{t('push.on')}</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
|
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
|
||||||
|
<!-- "Til" er ikke det samme som "virker".
|
||||||
|
Ruden sagde til, så snart der var et abonnement, og en push, tjenesten
|
||||||
|
afviste, gik i en logline på Debug, som ingen ser. Tre forskellige
|
||||||
|
problemer — browseren tilmeldte sig aldrig, tjenesten afviste den,
|
||||||
|
der er ikke noget forfaldent endnu — så ens ud fra den her side. -->
|
||||||
|
<button class="secondary" onclick={testPush} disabled={busy || testing}>
|
||||||
|
{testing ? t('push.testing') : t('push.test')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if testResult}
|
||||||
|
<p class="hint" class:bad={testResult.bad}>{testResult.text}</p>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" onclick={enablePush} disabled={busy}>
|
<button class="primary" onclick={enablePush} disabled={busy}>
|
||||||
@@ -261,6 +309,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.what {
|
.what {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user