@synopackageland/cli 0.5.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/authorization-sdk.d.ts +5 -0
- package/dist/authorization-sdk.js +5 -0
- package/dist/build-spk/delete-app-data.d.ts +1 -1
- package/dist/build-spk/uninstall-wizard.d.ts +1 -1
- package/dist/build-spk/uninstall-wizard.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/error-registry.js +3 -3
- package/dist/host/dsm-port-profile.d.ts +1 -1
- package/dist/host/dsm-port-profile.js +1 -1
- package/dist/init.js +25 -4
- package/dist/standalone/authorization-window.d.ts +2 -0
- package/dist/standalone/authorization-window.js +28 -0
- package/dist/standalone/authorization.d.ts +48 -0
- package/dist/standalone/authorization.js +116 -0
- package/dist/standalone/build.js +18 -6
- package/dist/standalone/dsm-window.js +8 -1
- package/dist/standalone/helper.js +1 -1
- package/dist/standalone/inspect-helper.js +12 -2
- package/dist/standalone/inspect-spk.js +11 -0
- package/dist/standalone/lifecycle-helper.d.ts +1 -1
- package/dist/standalone/lifecycle-helper.js +2 -2
- package/dist/standalone/lifecycle.d.ts +1 -1
- package/dist/standalone/lifecycle.js +3 -3
- package/dist/standalone/root-ops.d.ts +2 -0
- package/dist/standalone/root-ops.js +10 -0
- package/dist/standalone/root-window.js +1 -1
- package/dist/standalone/spk.d.ts +2 -0
- package/dist/standalone/spk.js +14 -0
- package/dist/templates/_authorization/main.go +104 -0
- package/dist/templates/_authorization/main_test.go +35 -0
- package/dist/templates/native-go/README.md +25 -1
- package/dist/templates/native-go/backend/cmd/app/main.go +8 -6
- package/dist/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
- package/dist/templates/native-go/backend/cmd/helper/main.go +18 -1
- package/dist/templates/native-go/backend/internal/app/authorization_none.go +5 -0
- package/dist/templates/native-go/backend/internal/app/routes.go +5 -2
- package/dist/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
- package/dist/templates/native-go/backend/internal/helper/management.go +8 -1
- package/dist/templates/native-go/backend/internal/helper/purge.go +115 -0
- package/dist/templates/native-go/backend/internal/helper/purge_test.go +59 -0
- package/dist/templates/native-go/backend/internal/helper/revoke.go +109 -0
- package/dist/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
- package/dist/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
- package/dist/templates/native-go/backend/internal/helper/server_linux.go +11 -2
- package/dist/templates/native-go/dsm-package.yaml +1 -1
- package/dist/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
- package/dist/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
- package/dist/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
- package/dist/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
- package/dist/templates/native-go-vue/frontend/src/context.ts +3 -0
- package/dist/types.d.ts +1 -1
- package/package.json +5 -2
- package/skills/reference/create.md +2 -2
- package/templates/_authorization/main.go +104 -0
- package/templates/_authorization/main_test.go +35 -0
- package/templates/native-go/README.md +25 -1
- package/templates/native-go/backend/cmd/app/main.go +8 -6
- package/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
- package/templates/native-go/backend/cmd/helper/main.go +18 -1
- package/templates/native-go/backend/internal/app/authorization_none.go +5 -0
- package/templates/native-go/backend/internal/app/routes.go +5 -2
- package/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
- package/templates/native-go/backend/internal/helper/management.go +8 -1
- package/templates/native-go/backend/internal/helper/purge.go +115 -0
- package/templates/native-go/backend/internal/helper/purge_test.go +59 -0
- package/templates/native-go/backend/internal/helper/revoke.go +109 -0
- package/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
- package/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
- package/templates/native-go/backend/internal/helper/server_linux.go +11 -2
- package/templates/native-go/dsm-package.yaml +1 -1
- package/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
- package/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
- package/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
- package/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
- package/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
- package/templates/native-go-vue/frontend/src/context.ts +3 -0
- package/dist/templates/hello-web/compose.yaml +0 -24
- package/dist/templates/hello-web/html/app.js +0 -1
- package/dist/templates/hello-web/html/index.html +0 -13
- package/dist/templates/hello-web/synology-app.yaml +0 -16
- package/dist/templates/hello-web-page/compose.yaml +0 -24
- package/dist/templates/hello-web-page/html/app.js +0 -1
- package/dist/templates/hello-web-page/html/index.html +0 -13
- package/dist/templates/hello-web-page/synology-app.yaml +0 -16
- package/templates/hello-web/compose.yaml +0 -24
- package/templates/hello-web/html/app.js +0 -1
- package/templates/hello-web/html/index.html +0 -13
- package/templates/hello-web/synology-app.yaml +0 -16
- package/templates/hello-web-page/compose.yaml +0 -24
- package/templates/hello-web-page/html/app.js +0 -1
- package/templates/hello-web-page/html/index.html +0 -13
- package/templates/hello-web-page/synology-app.yaml +0 -16
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package helper
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"syscall"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestPackageRevokeDurablyDisablesExactInventory(t *testing.T) {
|
|
15
|
+
c, _ := controlFixture(t)
|
|
16
|
+
r := Record{Inventory: c.Expected, Enabled: true, Lifecycle: testLifecycle("running")}
|
|
17
|
+
record(t, c.Client, r)
|
|
18
|
+
if err := packageRevoke(c.Client, Request{Inventory: c.Expected, Op: "revoke", Nonce: strings.Repeat("a", 64)}); err != nil {
|
|
19
|
+
t.Fatal(err)
|
|
20
|
+
}
|
|
21
|
+
got, err := readRecord(c.StateDir, c.Owner)
|
|
22
|
+
if err != nil || got.Enabled || !got.Residual || got.Lifecycle.Phase != "fenced" {
|
|
23
|
+
t.Fatalf("not durably revoked: %+v %v", got, err)
|
|
24
|
+
}
|
|
25
|
+
if c.CheckStopped(context.Background()) == nil {
|
|
26
|
+
t.Fatal("revocation acknowledgement accepted as completion")
|
|
27
|
+
}
|
|
28
|
+
if err := c.Control(context.Background(), "recover"); err == nil {
|
|
29
|
+
t.Fatal("revoked approval recovered")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestRevokeAcknowledgementCannotCertifyQuiescence(t *testing.T) {
|
|
34
|
+
c, _ := controlFixture(t)
|
|
35
|
+
live(t, c.Client, c.Expected, func(req Request) Response { return Response{Inventory: c.Expected, Nonce: req.Nonce, OK: true} })
|
|
36
|
+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
37
|
+
defer cancel()
|
|
38
|
+
if c.Revoke(ctx) == nil {
|
|
39
|
+
t.Fatal("socket acknowledgement certified live process")
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func TestRevokeRejectsUntrustedPeerAndWrongNonce(t *testing.T) {
|
|
44
|
+
for _, which := range []string{"peer", "nonce"} {
|
|
45
|
+
t.Run(which, func(t *testing.T) {
|
|
46
|
+
c, _ := controlFixture(t)
|
|
47
|
+
live(t, c.Client, c.Expected, func(req Request) Response { return Response{Inventory: c.Expected, Nonce: "wrong", OK: true} })
|
|
48
|
+
if which == "peer" {
|
|
49
|
+
c.Peer = func(*net.UnixConn) error { return errTrust }
|
|
50
|
+
}
|
|
51
|
+
if c.Revoke(context.Background()) == nil {
|
|
52
|
+
t.Fatal("untrusted response accepted")
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func TestRevokeNewBuildConsumesOldDisabledTerminalWithoutMutation(t *testing.T) {
|
|
59
|
+
c, f := controlFixture(t)
|
|
60
|
+
l := testLifecycle("fenced")
|
|
61
|
+
if err := createLifetime(c.Client, l); err != nil {
|
|
62
|
+
t.Fatal(err)
|
|
63
|
+
}
|
|
64
|
+
record(t, c.Client, Record{Schema: 2, Inventory: c.Expected, Enabled: false, Residual: true, Lifecycle: l})
|
|
65
|
+
if err := cleanRuntime(c.Client, true); err != nil {
|
|
66
|
+
t.Fatal(err)
|
|
67
|
+
}
|
|
68
|
+
if err := completeRecord(c.Client, l.Epoch, f.current); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
before, _ := os.ReadFile(filepath.Join(c.StateDir, "approval.json"))
|
|
72
|
+
c.Expected.Version = "1.0.0-0002"
|
|
73
|
+
if err := c.Revoke(context.Background()); err != nil {
|
|
74
|
+
t.Fatal(err)
|
|
75
|
+
}
|
|
76
|
+
after, _ := os.ReadFile(filepath.Join(c.StateDir, "approval.json"))
|
|
77
|
+
if string(before) != string(after) {
|
|
78
|
+
t.Fatal("new build rewrote old publication")
|
|
79
|
+
}
|
|
80
|
+
if err := os.Remove(filepath.Join(c.StateDir, "completion-"+l.Epoch+".json")); err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
if c.Revoke(context.Background()) == nil {
|
|
84
|
+
t.Fatal("missing terminal proof accepted")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestPackageRevokeRejectsWrongBuildInvalidFrameAndConcurrentEnable(t *testing.T) {
|
|
89
|
+
for _, kind := range []string{"build", "nonce", "operation", "busy"} {
|
|
90
|
+
t.Run(kind, func(t *testing.T) {
|
|
91
|
+
c, _ := controlFixture(t)
|
|
92
|
+
r := Record{Inventory: c.Expected, Enabled: true, Lifecycle: testLifecycle("running")}
|
|
93
|
+
record(t, c.Client, r)
|
|
94
|
+
req := Request{Inventory: c.Expected, Op: "revoke", Nonce: strings.Repeat("a", 64)}
|
|
95
|
+
switch kind {
|
|
96
|
+
case "build":
|
|
97
|
+
req.Inventory.Version = "0.1.0-0002"
|
|
98
|
+
case "nonce":
|
|
99
|
+
req.Nonce = ""
|
|
100
|
+
case "operation":
|
|
101
|
+
req.Op = "enable"
|
|
102
|
+
case "busy":
|
|
103
|
+
f, err := os.OpenFile(filepath.Join(c.StateDir, "control.lock"), os.O_CREATE|os.O_RDWR, 0600)
|
|
104
|
+
if err != nil {
|
|
105
|
+
t.Fatal(err)
|
|
106
|
+
}
|
|
107
|
+
defer f.Close()
|
|
108
|
+
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
109
|
+
t.Fatal(err)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if packageRevoke(c.Client, req) == nil {
|
|
113
|
+
t.Fatal("unsafe revoke accepted")
|
|
114
|
+
}
|
|
115
|
+
got, _ := readRecord(c.StateDir, c.Owner)
|
|
116
|
+
if !got.Enabled || got.Residual || got.Lifecycle.Phase != "running" {
|
|
117
|
+
t.Fatal("rejected request changed approval")
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -53,8 +53,8 @@ func serveLinux(c Client) error {
|
|
|
53
53
|
if err := os.Chmod(socket, 0660); err != nil {
|
|
54
54
|
return err
|
|
55
55
|
}
|
|
56
|
-
// Bound concurrency and each frame
|
|
57
|
-
//
|
|
56
|
+
// Bound concurrency and each frame. The authenticated package UID may only
|
|
57
|
+
// withdraw authority; activation remains an exact approved root task.
|
|
58
58
|
slots := make(chan struct{}, 8)
|
|
59
59
|
for {
|
|
60
60
|
conn, err := l.AcceptUnix()
|
|
@@ -105,6 +105,15 @@ func serveLinux(c Client) error {
|
|
|
105
105
|
if health.Decode(&req) != nil {
|
|
106
106
|
return
|
|
107
107
|
}
|
|
108
|
+
if req.Op == "revoke" {
|
|
109
|
+
err := packageRevoke(c, req)
|
|
110
|
+
res := Response{Inventory: c.Expected, Nonce: req.Nonce, OK: err == nil}
|
|
111
|
+
if err != nil {
|
|
112
|
+
res.Error = "revoke-refused"
|
|
113
|
+
}
|
|
114
|
+
json.NewEncoder(conn).Encode(res)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
108
117
|
r, err := readRecord(c.StateDir, 0)
|
|
109
118
|
readiness := "fenced"
|
|
110
119
|
if err == nil && r.Enabled && r.Inventory == c.Expected && r.Lifecycle != nil && r.Lifecycle.Phase == "running" {
|
|
@@ -53,8 +53,8 @@ func serveLinux(c Client) error {
|
|
|
53
53
|
if err := os.Chmod(socket, 0660); err != nil {
|
|
54
54
|
return err
|
|
55
55
|
}
|
|
56
|
-
// Bound concurrency and each frame
|
|
57
|
-
//
|
|
56
|
+
// Bound concurrency and each frame. The authenticated package UID may only
|
|
57
|
+
// withdraw authority; activation remains an exact approved root task.
|
|
58
58
|
slots := make(chan struct{}, 8)
|
|
59
59
|
for {
|
|
60
60
|
conn, err := l.AcceptUnix()
|
|
@@ -80,6 +80,15 @@ func serveLinux(c Client) error {
|
|
|
80
80
|
if d.Decode(&req) != nil {
|
|
81
81
|
return
|
|
82
82
|
}
|
|
83
|
+
if req.Op == "revoke" {
|
|
84
|
+
err := packageRevoke(c, req)
|
|
85
|
+
res := Response{Inventory: c.Expected, Nonce: req.Nonce, OK: err == nil}
|
|
86
|
+
if err != nil {
|
|
87
|
+
res.Error = "revoke-refused"
|
|
88
|
+
}
|
|
89
|
+
json.NewEncoder(conn).Encode(res)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
83
92
|
r, err := readRecord(c.StateDir, 0)
|
|
84
93
|
readiness := "fenced"
|
|
85
94
|
if err == nil && r.Enabled && r.Inventory == c.Expected && r.Lifecycle != nil && r.Lifecycle.Phase == "running" {
|
|
@@ -35,7 +35,7 @@ ui:
|
|
|
35
35
|
# UI languages: i18n/messages/<locale>.json for en, zh-TW, zh-CN, ja, ko, de,
|
|
36
36
|
# fr, es and it (DSM's other languages fall back to English). build-spk checks
|
|
37
37
|
# that every language has the en.json keys and placeholders, and that the keys
|
|
38
|
-
# listed in the glossary use
|
|
38
|
+
# listed in the glossary use DSM's own wording.
|
|
39
39
|
i18n:
|
|
40
40
|
messages: i18n/messages
|
|
41
41
|
glossary: i18n/glossary.json
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"dsmapp/internal/api"
|
|
7
|
+
"encoding/base64"
|
|
8
|
+
"encoding/hex"
|
|
9
|
+
"encoding/json"
|
|
10
|
+
"errors"
|
|
11
|
+
"io"
|
|
12
|
+
"net"
|
|
13
|
+
"net/http"
|
|
14
|
+
"net/url"
|
|
15
|
+
"os"
|
|
16
|
+
"strconv"
|
|
17
|
+
"strings"
|
|
18
|
+
"time"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Compiled into the CGI. Package-owned JSON is never the source of trust.
|
|
22
|
+
type authorizationTask struct {
|
|
23
|
+
Op string `json:"op"`
|
|
24
|
+
Name string `json:"name"`
|
|
25
|
+
Header string `json:"header"`
|
|
26
|
+
Script string `json:"script"`
|
|
27
|
+
Output bool `json:"output"`
|
|
28
|
+
}
|
|
29
|
+
type authorizationBuild struct {
|
|
30
|
+
Schema int `json:"schema"`
|
|
31
|
+
Package string `json:"package"`
|
|
32
|
+
Version string `json:"version"`
|
|
33
|
+
Pin string `json:"bootstrapSha256"`
|
|
34
|
+
Inventory struct {
|
|
35
|
+
Files []struct {
|
|
36
|
+
Path string `json:"path"`
|
|
37
|
+
Size int64 `json:"size"`
|
|
38
|
+
Hash string `json:"sha256"`
|
|
39
|
+
} `json:"files"`
|
|
40
|
+
} `json:"inventory"`
|
|
41
|
+
Tasks []authorizationTask `json:"tasks"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (cfg Config) authorizationRoutes() []api.Route {
|
|
45
|
+
if cfg.RootAuthorization == "" {
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
return []api.Route{api.UserRequest("authorization.status", cfg.authorizationStatus), api.Admin("authorization.request", cfg.authorizationRequest), api.Admin("authorization.revoke", cfg.authorizationRevoke)}
|
|
49
|
+
}
|
|
50
|
+
func authorizationDocument(state string, reason any) any {
|
|
51
|
+
return map[string]any{"command": "authorization", "schema_version": 1, "state": state, "reason": reason}
|
|
52
|
+
}
|
|
53
|
+
func (cfg Config) authorizationPlan() (authorizationBuild, error) {
|
|
54
|
+
var p authorizationBuild
|
|
55
|
+
raw, decode := base64.StdEncoding.DecodeString(cfg.RootAuthorization)
|
|
56
|
+
if decode != nil || json.Unmarshal(raw, &p) != nil || p.Schema != 2 || p.Package != cfg.PackageID || p.Version != cfg.Version || len(p.Tasks) == 0 || len(p.Pin) != 64 {
|
|
57
|
+
return p, errors.New("invalid build authorization")
|
|
58
|
+
}
|
|
59
|
+
if b, e := hex.DecodeString(p.Pin); e != nil || len(b) != 32 {
|
|
60
|
+
return p, errors.New("invalid bootstrap pin")
|
|
61
|
+
}
|
|
62
|
+
return p, nil
|
|
63
|
+
}
|
|
64
|
+
func (cfg Config) authorizationRequest(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
|
|
65
|
+
if e := decodeParams(params, &struct{}{}); e != nil {
|
|
66
|
+
return nil, e
|
|
67
|
+
}
|
|
68
|
+
p, e := cfg.authorizationPlan()
|
|
69
|
+
if e != nil {
|
|
70
|
+
return nil, e
|
|
71
|
+
}
|
|
72
|
+
if cfg.authorizationTrusted(p) != nil {
|
|
73
|
+
return nil, &api.Error{Code: "authorization_required", Message: "Build payload cannot be trusted; reinstall this package"}
|
|
74
|
+
}
|
|
75
|
+
ops := []string{}
|
|
76
|
+
for _, t := range p.Tasks {
|
|
77
|
+
if t.Op == "helper_enable" {
|
|
78
|
+
ops = []string{t.Op}
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
ops = append(ops, t.Op)
|
|
82
|
+
}
|
|
83
|
+
return map[string]any{"command": "authorization", "schema_version": 1, "phase": "plan", "operations": ops, "run": cfg.HelperInventory.Package != ""}, nil
|
|
84
|
+
}
|
|
85
|
+
func (cfg Config) authorizationRevoke(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
|
|
86
|
+
if e := decodeParams(params, &struct{}{}); e != nil {
|
|
87
|
+
return nil, e
|
|
88
|
+
}
|
|
89
|
+
p, e := cfg.authorizationPlan()
|
|
90
|
+
if e != nil {
|
|
91
|
+
return nil, e
|
|
92
|
+
}
|
|
93
|
+
// Root task still pins bootstrap. Corrupt UI must not prevent safe cleanup.
|
|
94
|
+
ops := []string{}
|
|
95
|
+
purge := false
|
|
96
|
+
for _, t := range p.Tasks {
|
|
97
|
+
ops = append(ops, t.Op)
|
|
98
|
+
if cfg.HelperInventory.Package != "" && t.Op == "helper_purge" {
|
|
99
|
+
purge = true
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return map[string]any{"command": "authorization", "schema_version": 1, "phase": "plan", "operations": ops, "disable": cfg.HelperInventory.Package != "", "purge": purge}, nil
|
|
103
|
+
}
|
|
104
|
+
func (cfg Config) authorizationTrusted(p authorizationBuild) error {
|
|
105
|
+
root, e := os.OpenRoot("/var/packages/" + cfg.PackageID + "/target")
|
|
106
|
+
if e != nil {
|
|
107
|
+
return e
|
|
108
|
+
}
|
|
109
|
+
defer root.Close()
|
|
110
|
+
verify := func(path string, size int64, want string) error {
|
|
111
|
+
f, e := root.Open(path)
|
|
112
|
+
if e != nil {
|
|
113
|
+
return e
|
|
114
|
+
}
|
|
115
|
+
defer f.Close()
|
|
116
|
+
st, e := f.Stat()
|
|
117
|
+
if e != nil || !st.Mode().IsRegular() || st.Size() > 64<<20 || size >= 0 && st.Size() != size {
|
|
118
|
+
return errors.New("untrusted payload")
|
|
119
|
+
}
|
|
120
|
+
h := sha256.New()
|
|
121
|
+
_, e = io.Copy(h, io.LimitReader(f, (64<<20)+1))
|
|
122
|
+
if e != nil || hex.EncodeToString(h.Sum(nil)) != want {
|
|
123
|
+
return errors.New("untrusted payload")
|
|
124
|
+
}
|
|
125
|
+
return nil
|
|
126
|
+
}
|
|
127
|
+
if e = verify("authorization/bootstrap", -1, p.Pin); e != nil {
|
|
128
|
+
return e
|
|
129
|
+
}
|
|
130
|
+
for _, f := range p.Inventory.Files {
|
|
131
|
+
if e = verify(f.Path, f.Size, f.Hash); e != nil {
|
|
132
|
+
return e
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return nil
|
|
136
|
+
}
|
|
137
|
+
func (cfg Config) authorizationStatus(ctx context.Context, caller api.Caller, r *http.Request, params json.RawMessage) (any, error) {
|
|
138
|
+
if e := decodeParams(params, &struct{}{}); e != nil {
|
|
139
|
+
return nil, e
|
|
140
|
+
}
|
|
141
|
+
p, e := cfg.authorizationPlan()
|
|
142
|
+
if e != nil {
|
|
143
|
+
return authorizationDocument("unavailable", "service_unavailable"), nil
|
|
144
|
+
}
|
|
145
|
+
if cfg.authorizationTrusted(p) != nil {
|
|
146
|
+
return authorizationDocument("authorization_required", "helper_untrusted"), nil
|
|
147
|
+
}
|
|
148
|
+
if cfg.HelperInventory.Package != "" {
|
|
149
|
+
s := cfg.integrationState(ctx)
|
|
150
|
+
if s.State == "ready" {
|
|
151
|
+
return authorizationDocument("ready", nil), nil
|
|
152
|
+
}
|
|
153
|
+
if !caller.Admin() {
|
|
154
|
+
return authorizationDocument("not_admin", "not_admin"), nil
|
|
155
|
+
}
|
|
156
|
+
switch s.State {
|
|
157
|
+
case "incompatible":
|
|
158
|
+
return authorizationDocument("authorization_required", "grant_incompatible"), nil
|
|
159
|
+
case "pending":
|
|
160
|
+
return authorizationDocument("unavailable", "health_probe_timeout"), nil
|
|
161
|
+
case "failed":
|
|
162
|
+
return authorizationDocument("unavailable", "service_unavailable"), nil
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if !caller.Admin() {
|
|
166
|
+
return authorizationDocument("not_admin", "not_admin"), nil
|
|
167
|
+
}
|
|
168
|
+
state, reason := authorizationScheduler(ctx, r, p.Tasks)
|
|
169
|
+
if cfg.HelperInventory.Package != "" && state == "ready" {
|
|
170
|
+
state = "authorization_required"
|
|
171
|
+
reason = "grant_missing"
|
|
172
|
+
}
|
|
173
|
+
return authorizationDocument(state, reason), nil
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Read-only scheduler observations; never accept caller-selected URLs or task IDs.
|
|
177
|
+
// One modern probe; legacy fallback only for explicit missing method (103).
|
|
178
|
+
func authorizationScheduler(ctx context.Context, r *http.Request, tasks []authorizationTask) (string, any) {
|
|
179
|
+
return authorizationSchedulerAt(ctx, r, tasks, "http://127.0.0.1:5000")
|
|
180
|
+
}
|
|
181
|
+
func authorizationSchedulerAt(ctx context.Context, r *http.Request, tasks []authorizationTask, endpoint string) (string, any) {
|
|
182
|
+
tr := &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, DisableKeepAlives: true, DisableCompression: true, ResponseHeaderTimeout: 5 * time.Second, MaxResponseHeaderBytes: 32 << 10}
|
|
183
|
+
defer tr.CloseIdleConnections()
|
|
184
|
+
client := &http.Client{Transport: tr, Timeout: 8 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
185
|
+
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
186
|
+
defer cancel()
|
|
187
|
+
call := func(method string, version int, params url.Values) (json.RawMessage, int, error) {
|
|
188
|
+
params.Set("api", "SYNO.Core.TaskScheduler")
|
|
189
|
+
params.Set("method", method)
|
|
190
|
+
params.Set("version", strconv.Itoa(version))
|
|
191
|
+
req, e := http.NewRequestWithContext(ctx, "GET", endpoint+"/webapi/entry.cgi?"+params.Encode(), nil)
|
|
192
|
+
if e != nil {
|
|
193
|
+
return nil, 0, e
|
|
194
|
+
}
|
|
195
|
+
cookies := r.CookiesNamed("id")
|
|
196
|
+
if len(cookies) != 1 {
|
|
197
|
+
return nil, 0, errors.New("session")
|
|
198
|
+
}
|
|
199
|
+
req.AddCookie(cookies[0])
|
|
200
|
+
req.Header.Set("X-SYNO-TOKEN", r.Header.Get("X-SYNO-TOKEN"))
|
|
201
|
+
response, e := client.Do(req)
|
|
202
|
+
if e != nil {
|
|
203
|
+
return nil, 0, e
|
|
204
|
+
}
|
|
205
|
+
defer response.Body.Close()
|
|
206
|
+
b, e := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
|
207
|
+
if e != nil || len(b) > 1<<20 || response.StatusCode != 200 {
|
|
208
|
+
return nil, 0, errors.New("scheduler unavailable")
|
|
209
|
+
}
|
|
210
|
+
var wire struct {
|
|
211
|
+
Success bool `json:"success"`
|
|
212
|
+
Data json.RawMessage `json:"data"`
|
|
213
|
+
Error struct {
|
|
214
|
+
Code int `json:"code"`
|
|
215
|
+
} `json:"error"`
|
|
216
|
+
}
|
|
217
|
+
if json.Unmarshal(b, &wire) != nil {
|
|
218
|
+
return nil, 0, errors.New("scheduler schema")
|
|
219
|
+
}
|
|
220
|
+
if !wire.Success {
|
|
221
|
+
return nil, wire.Error.Code, errors.New("scheduler refused")
|
|
222
|
+
}
|
|
223
|
+
return wire.Data, 0, nil
|
|
224
|
+
}
|
|
225
|
+
data, code, e := call("list", 3, url.Values{"start": {"0"}, "limit": {"500"}})
|
|
226
|
+
legacy := code == 103
|
|
227
|
+
if legacy {
|
|
228
|
+
data, code, e = call("list", 2, url.Values{"offset": {"0"}, "limit": {"500"}})
|
|
229
|
+
}
|
|
230
|
+
if e != nil {
|
|
231
|
+
if code == 106 || code == 107 || code == 119 {
|
|
232
|
+
return "session_expired", "session_expired"
|
|
233
|
+
}
|
|
234
|
+
return "unavailable", "scheduler_unavailable"
|
|
235
|
+
}
|
|
236
|
+
type item struct {
|
|
237
|
+
ID json.Number `json:"id"`
|
|
238
|
+
Name string `json:"name"`
|
|
239
|
+
Owner string `json:"owner"`
|
|
240
|
+
RealOwner string `json:"real_owner"`
|
|
241
|
+
Enable *bool `json:"enable"`
|
|
242
|
+
Type string `json:"type"`
|
|
243
|
+
Extra struct {
|
|
244
|
+
Script string `json:"script"`
|
|
245
|
+
} `json:"extra"`
|
|
246
|
+
}
|
|
247
|
+
var list struct {
|
|
248
|
+
Tasks []item `json:"tasks"`
|
|
249
|
+
Total *int `json:"total"`
|
|
250
|
+
}
|
|
251
|
+
if json.Unmarshal(data, &list) != nil || list.Total == nil || *list.Total != len(list.Tasks) || len(list.Tasks) > 500 {
|
|
252
|
+
return "unavailable", "scheduler_unavailable"
|
|
253
|
+
}
|
|
254
|
+
for _, expected := range tasks {
|
|
255
|
+
// Helper control tasks are individually approved on demand. Enable is the
|
|
256
|
+
// activation anchor; live helper health remains required separately.
|
|
257
|
+
if strings.HasPrefix(expected.Op, "helper_") && expected.Op != "helper_enable" {
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
var found *item
|
|
261
|
+
for n := range list.Tasks {
|
|
262
|
+
if list.Tasks[n].Name == expected.Name {
|
|
263
|
+
if found != nil {
|
|
264
|
+
return "authorization_required", "maintenance_task_mismatch"
|
|
265
|
+
}
|
|
266
|
+
found = &list.Tasks[n]
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if found == nil {
|
|
270
|
+
return "authorization_required", "grant_missing"
|
|
271
|
+
}
|
|
272
|
+
valid := func(t item) bool {
|
|
273
|
+
return t.Owner == "root" && t.RealOwner == "root" && t.Enable != nil && !*t.Enable && t.Type == "script"
|
|
274
|
+
}
|
|
275
|
+
if !valid(*found) {
|
|
276
|
+
return "authorization_required", "maintenance_task_mismatch"
|
|
277
|
+
}
|
|
278
|
+
version := 4
|
|
279
|
+
if legacy {
|
|
280
|
+
version = 3
|
|
281
|
+
}
|
|
282
|
+
detail, _, e := call("get", version, url.Values{"id": {string(found.ID)}, "real_owner": {"root"}})
|
|
283
|
+
if e != nil {
|
|
284
|
+
return "unavailable", "scheduler_unavailable"
|
|
285
|
+
}
|
|
286
|
+
var full item
|
|
287
|
+
if json.Unmarshal(detail, &full) != nil || full.ID != found.ID || full.Name != expected.Name || !valid(full) {
|
|
288
|
+
return "authorization_required", "maintenance_task_mismatch"
|
|
289
|
+
}
|
|
290
|
+
if full.Extra.Script != expected.Script {
|
|
291
|
+
if strings.HasPrefix(full.Extra.Script, expected.Header) {
|
|
292
|
+
return "authorization_required", "grant_incompatible"
|
|
293
|
+
}
|
|
294
|
+
return "authorization_required", "maintenance_task_mismatch"
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return "ready", nil
|
|
298
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"dsmapp/internal/api"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"slices"
|
|
11
|
+
"testing"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestAuthorizationFailClosed(t *testing.T) {
|
|
15
|
+
cfg := Config{PackageID: "RootDemo", Version: "0.1.0-0001", RootAuthorization: `{"schema":2}`}
|
|
16
|
+
if _, e := cfg.authorizationPlan(); e == nil {
|
|
17
|
+
t.Fatal("accepted missing pin")
|
|
18
|
+
}
|
|
19
|
+
if _, e := cfg.authorizationRequest(context.Background(), api.Caller{}, json.RawMessage(`{"op":"arbitrary"}`)); e == nil {
|
|
20
|
+
t.Fatal("accepted arbitrary params")
|
|
21
|
+
}
|
|
22
|
+
if len((Config{}).authorizationRoutes()) != 0 {
|
|
23
|
+
t.Fatal("default registered root routes")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
func TestAuthorizationDocuments(t *testing.T) {
|
|
27
|
+
for _, state := range []string{"ready", "authorization_required", "not_admin", "unavailable"} {
|
|
28
|
+
b, e := json.Marshal(authorizationDocument(state, nil))
|
|
29
|
+
if e != nil {
|
|
30
|
+
t.Fatal(e)
|
|
31
|
+
}
|
|
32
|
+
var d map[string]any
|
|
33
|
+
json.Unmarshal(b, &d)
|
|
34
|
+
if d["command"] != "authorization" || d["schema_version"] != float64(1) || d["state"] != state {
|
|
35
|
+
t.Fatal(string(b))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestSchedulerRejectsChangedTaskAndFallsBackOnlyOn103(t *testing.T) {
|
|
41
|
+
for _, legacy := range []bool{false, true} {
|
|
42
|
+
altered := false
|
|
43
|
+
var versions []string
|
|
44
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
45
|
+
q := r.URL.Query()
|
|
46
|
+
versions = append(versions, q.Get("method")+":"+q.Get("version"))
|
|
47
|
+
if q.Get("method") == "list" && q.Get("version") == "3" && legacy {
|
|
48
|
+
io.WriteString(w, `{"success":false,"error":{"code":103}}`)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
script := "approved"
|
|
52
|
+
if altered {
|
|
53
|
+
script = "unapproved"
|
|
54
|
+
}
|
|
55
|
+
item := map[string]any{"id": 1, "name": "RootDemo root: whoami", "owner": "root", "real_owner": "root", "enable": false, "type": "script", "extra": map[string]string{"script": script}}
|
|
56
|
+
var data any = item
|
|
57
|
+
if q.Get("method") == "list" {
|
|
58
|
+
data = map[string]any{"tasks": []any{item}, "total": 1}
|
|
59
|
+
}
|
|
60
|
+
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
|
|
61
|
+
}))
|
|
62
|
+
request := httptest.NewRequest("POST", "/", nil)
|
|
63
|
+
request.AddCookie(&http.Cookie{Name: "id", Value: "fixture-session"})
|
|
64
|
+
tasks := []authorizationTask{{Op: "whoami", Name: "RootDemo root: whoami", Script: "approved", Header: "header"}}
|
|
65
|
+
state, _ := authorizationSchedulerAt(context.Background(), request, tasks, server.URL)
|
|
66
|
+
if state != "ready" {
|
|
67
|
+
t.Fatal(state)
|
|
68
|
+
}
|
|
69
|
+
if legacy && !slices.Contains(versions, "get:3") {
|
|
70
|
+
t.Fatal(versions)
|
|
71
|
+
}
|
|
72
|
+
if !legacy && slices.Contains(versions, "get:3") {
|
|
73
|
+
t.Fatal(versions)
|
|
74
|
+
}
|
|
75
|
+
altered = true
|
|
76
|
+
state, reason := authorizationSchedulerAt(context.Background(), request, tasks, server.URL)
|
|
77
|
+
if state != "authorization_required" || reason != "maintenance_task_mismatch" {
|
|
78
|
+
t.Fatal(state, reason)
|
|
79
|
+
}
|
|
80
|
+
server.Close()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
func TestSchedulerPermissionFailureIsNotLegacyFallback(t *testing.T) {
|
|
84
|
+
calls := 0
|
|
85
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
86
|
+
calls++
|
|
87
|
+
io.WriteString(w, `{"success":false,"error":{"code":105}}`)
|
|
88
|
+
}))
|
|
89
|
+
defer server.Close()
|
|
90
|
+
request := httptest.NewRequest("POST", "/", nil)
|
|
91
|
+
request.AddCookie(&http.Cookie{Name: "id", Value: "fixture-session"})
|
|
92
|
+
state, _ := authorizationSchedulerAt(context.Background(), request, nil, server.URL)
|
|
93
|
+
if state != "unavailable" || calls != 1 {
|
|
94
|
+
t.Fatal(state, calls)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {computed,onMounted,onUnmounted,ref} from 'vue'
|
|
3
|
+
import {Button,Callout,Dialog,Surface} from '@synopackageland/ui'
|
|
4
|
+
import {ShieldCheck,TriangleAlert} from '@lucide/vue'
|
|
5
|
+
import type {AuthorizationStatus,Context} from '../context'
|
|
6
|
+
import {authorizationCopy} from './i18n'
|
|
7
|
+
const props=defineProps<{ctx:Context}>()
|
|
8
|
+
const emit=defineEmits<{ready:[]}>()
|
|
9
|
+
const state=ref<AuthorizationStatus>()
|
|
10
|
+
const pendingState=ref<AuthorizationStatus>()
|
|
11
|
+
const busy=ref(false)
|
|
12
|
+
const failure=ref(false)
|
|
13
|
+
const confirmRevoke=ref(false)
|
|
14
|
+
let disposed=false,sequence=0,pendingRecheck=false
|
|
15
|
+
let unsubscribe:(()=>void)|undefined
|
|
16
|
+
const text=computed(()=>authorizationCopy(props.ctx.locale,props.ctx.displayName||'__DISPLAY_NAME__'))
|
|
17
|
+
const ready=computed(()=>state.value?.state==='ready')
|
|
18
|
+
const shown=computed(()=>state.value||pendingState.value)
|
|
19
|
+
const required=computed(()=>shown.value?.state==='authorization_required')
|
|
20
|
+
const untrusted=computed(()=>['helper_untrusted','cli_untrusted'].includes(shown.value?.reason||''))
|
|
21
|
+
const canAuthorize=computed(()=>required.value&&!untrusted.value)
|
|
22
|
+
const unavailableKey=computed(()=>shown.value?.reason==='credential_not_ready'?'credential':'unavailable')
|
|
23
|
+
const title=computed(()=>!shown.value?text.value('checking'):required.value?text.value('title'):shown.value.state==='ready'?text.value('ready'):shown.value.state==='not_admin'?text.value('not_admin.title'):shown.value.state==='session_expired'?text.value('session_expired.title'):text.value(unavailableKey.value+'.title'))
|
|
24
|
+
const body=computed(()=>required.value?text.value('purpose'):shown.value?.state==='not_admin'?text.value('not_admin.body'):shown.value?.state==='session_expired'?text.value('session_expired.body'):shown.value?.state==='unavailable'?text.value(unavailableKey.value+'.body'):text.value('purpose'))
|
|
25
|
+
const reasons=['grant_missing','helper_untrusted','cli_untrusted','grant_incompatible','package_identity_changed','maintenance_task_missing','maintenance_task_mismatch','helper_health_rejected','capability_missing']
|
|
26
|
+
const reason=computed(()=>reasons.includes(shown.value?.reason||'')?shown.value!.reason!:'unknown')
|
|
27
|
+
const next=computed(()=>untrusted.value?'next.reinstall':reason.value==='grant_missing'?'next.install':reason.value==='grant_incompatible'?'next.update':'next.repair')
|
|
28
|
+
async function action(method:'status'|'request'|'revoke'){
|
|
29
|
+
if(busy.value||disposed)return
|
|
30
|
+
const current=++sequence
|
|
31
|
+
pendingState.value=method==='request'&&state.value?.state==='authorization_required'?state.value:undefined
|
|
32
|
+
busy.value=true;failure.value=false
|
|
33
|
+
// Mutation/revalidation immediately hides application content. A request
|
|
34
|
+
// keeps its prior reason visible while DSM's confirmation is pending.
|
|
35
|
+
state.value=undefined
|
|
36
|
+
try {
|
|
37
|
+
const result=await props.ctx.authorization?.[method]()
|
|
38
|
+
if(disposed||current!==sequence)return
|
|
39
|
+
if(!result||result.command!=='authorization'||result.schema_version!==1||!['ready','authorization_required','not_admin','session_expired','unavailable'].includes(result.state))throw Error('invalid status')
|
|
40
|
+
state.value=result
|
|
41
|
+
if(result.state==='ready')emit('ready')
|
|
42
|
+
} catch(e){if(!disposed&¤t===sequence){
|
|
43
|
+
state.value=(e as {code?:string}).code==='session_required'?{command:'authorization',schema_version:1,state:'session_expired',reason:'session_expired'}:pendingState.value||{command:'authorization',schema_version:1,state:'unavailable',reason:'service_unavailable'}
|
|
44
|
+
failure.value=true
|
|
45
|
+
}} finally{if(!disposed&¤t===sequence){busy.value=false;pendingState.value=undefined;if(pendingRecheck){pendingRecheck=false;void action('status')}}}
|
|
46
|
+
}
|
|
47
|
+
onMounted(()=>{unsubscribe=props.ctx.reopen(()=>{if(busy.value)pendingRecheck=true;else void action('status')});void action('status')})
|
|
48
|
+
onUnmounted(()=>{disposed=true;sequence++;if(typeof unsubscribe==='function')unsubscribe()})
|
|
49
|
+
</script>
|
|
50
|
+
<template>
|
|
51
|
+
<p class="authorization-announcement" role="status" aria-live="polite" aria-atomic="true">{{ busy ? text('authorizing') : title }}</p>
|
|
52
|
+
<div v-if="!ready" class="authorization-view" :aria-busy="busy" :data-authorization-state="state?.state || 'checking'">
|
|
53
|
+
<Surface class="authorization-card" padding="lg" raised :aria-label="title">
|
|
54
|
+
<span class="authorization-icon" aria-hidden="true"><component :is="shown?.state==='unavailable'||shown?.state==='session_expired'?TriangleAlert:ShieldCheck" class="authorization-icon-glyph" /></span>
|
|
55
|
+
<h1 class="authorization-title">{{ title }}</h1>
|
|
56
|
+
<p class="authorization-body">{{ body }}</p>
|
|
57
|
+
<Callout v-if="required" class="authorization-status" :tone="reason==='grant_missing'?'info':'warning'" :title="text('reason.'+reason)">{{ text(next) }}</Callout>
|
|
58
|
+
<Callout v-if="failure" class="authorization-failed" tone="warning">{{ text('failed') }}</Callout>
|
|
59
|
+
<div class="authorization-actions">
|
|
60
|
+
<Button v-if="canAuthorize" variant="primary" :loading="busy" :disabled="busy" @click="action('request')">{{ busy ? text('authorizing') : text('authorize') }}</Button>
|
|
61
|
+
<Button v-else variant="primary" :loading="busy" :disabled="busy" @click="action('status')">{{ busy ? text('checking') : text('retry') }}</Button>
|
|
62
|
+
</div>
|
|
63
|
+
</Surface>
|
|
64
|
+
</div>
|
|
65
|
+
<div v-else class="authorization-authorized">
|
|
66
|
+
<div class="authorization-ready"><ShieldCheck :size="16" aria-hidden="true"/><span>{{ text('ready') }}</span><Button v-if="ctx.authorization?.canManage?.()" variant="secondary" :disabled="busy" @click="confirmRevoke=true">{{ text('revoke') }}</Button></div>
|
|
67
|
+
<slot />
|
|
68
|
+
</div>
|
|
69
|
+
<Dialog v-model:open="confirmRevoke" :title="text('revoke.title')" :description="text('revoke.body')">
|
|
70
|
+
<template #footer><Button variant="secondary" @click="confirmRevoke=false">{{ text('cancel') }}</Button><Button variant="danger" :disabled="busy" @click="confirmRevoke=false;action('revoke')">{{ text('revoke') }}</Button></template>
|
|
71
|
+
</Dialog>
|
|
72
|
+
</template>
|
|
73
|
+
<style scoped>
|
|
74
|
+
.authorization-announcement{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
75
|
+
/* T0156: auto margins center the whole card but preserve top scrolling in a short window. */
|
|
76
|
+
.authorization-view{display:flex;align-items:flex-start;justify-content:center;min-height:100%;height:100%;box-sizing:border-box;padding:24px;overflow-y:auto;color:var(--ui-text);font-family:var(--ui-font-sans)}
|
|
77
|
+
.authorization-card{display:flex;flex-direction:column;align-items:center;gap:12px;width:100%;max-width:28rem;margin:auto;text-align:center}
|
|
78
|
+
.authorization-icon{display:flex;align-items:center;justify-content:center;width:48px;height:48px;flex-shrink:0;border-radius:9999px;background:var(--ui-primary-soft);color:var(--ui-primary)}
|
|
79
|
+
.authorization-icon-glyph{width:24px;height:24px}
|
|
80
|
+
.authorization-title{font-size:16px;font-weight:600;line-height:1.4;color:var(--ui-text);margin:0;text-wrap:balance}
|
|
81
|
+
.authorization-body{max-width:24rem;font-size:13px;line-height:1.6;color:var(--ui-text);margin:0;text-align:center;text-wrap:pretty}
|
|
82
|
+
.authorization-status,.authorization-failed{width:100%;text-align:left}
|
|
83
|
+
/* Essential recovery text uses the full foreground; DSM's muted token is below 4.5:1. */
|
|
84
|
+
.authorization-status,.authorization-failed{color:var(--ui-text)}
|
|
85
|
+
.authorization-actions{display:flex;justify-content:center;width:100%;margin-top:4px}
|
|
86
|
+
.authorization-authorized{display:flex;flex-direction:column;height:100%;min-height:0}.authorization-authorized :deep(.app-shell){flex:1;min-height:0;height:auto}
|
|
87
|
+
.authorization-ready{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:8px 16px;color:var(--ui-text);font:13px var(--ui-font-sans)}
|
|
88
|
+
@media(max-width:480px){.authorization-view{padding:16px}}
|
|
89
|
+
</style>
|