@app-vitals/agent-admin 0.9.0 → 0.9.1
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/package.json +2 -2
- package/src/admin-local-agent.smoke.test.ts +6 -2
- package/src/admin-ui-pages.ts +15 -2
- package/src/admin-ui-push-test.ts +5 -1
- package/src/admin-ui.smoke.test.ts +69 -6
- package/src/admin-ui.ts +44 -9
- package/src/chat.smoke.test.ts +21 -16
- package/src/push-toggle.ts +6 -2
- package/src/pwa.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.9.
|
|
46
|
+
"@app-vitals/agent-lib": "0.9.1",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
|
@@ -388,9 +388,13 @@ describe("admin UI — new local agent create flow", () => {
|
|
|
388
388
|
|
|
389
389
|
it("GET /admin/agents/new — unauthenticated redirects to /admin/login", async () => {
|
|
390
390
|
const app = createAdminUIApp(makeMockDeps());
|
|
391
|
-
const res = await app.request("/admin/agents/new"
|
|
391
|
+
const res = await app.request("/admin/agents/new", {
|
|
392
|
+
headers: { Accept: "text/html" },
|
|
393
|
+
});
|
|
392
394
|
expect(res.status).toBe(302);
|
|
393
|
-
expect(res.headers.get("Location")).toBe(
|
|
395
|
+
expect(res.headers.get("Location")).toBe(
|
|
396
|
+
"/admin/login?returnTo=%2Fadmin%2Fagents%2Fnew",
|
|
397
|
+
);
|
|
394
398
|
});
|
|
395
399
|
|
|
396
400
|
// ── POST /admin/agents ────────────────────────────────────────────────────
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -3059,8 +3059,15 @@ export function renderChatThreadPage(
|
|
|
3059
3059
|
}
|
|
3060
3060
|
|
|
3061
3061
|
function poll() {
|
|
3062
|
-
fetch(messagesJsonUrl)
|
|
3063
|
-
.then(function(r) {
|
|
3062
|
+
fetch(messagesJsonUrl, { headers: { 'Accept': 'application/json' } })
|
|
3063
|
+
.then(function(r) {
|
|
3064
|
+
if (r.status === 401) {
|
|
3065
|
+
stopPolling();
|
|
3066
|
+
window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
|
|
3067
|
+
return { messages: [] };
|
|
3068
|
+
}
|
|
3069
|
+
return r.json();
|
|
3070
|
+
})
|
|
3064
3071
|
.then(function(data) {
|
|
3065
3072
|
var msgs = data.messages || [];
|
|
3066
3073
|
|
|
@@ -3246,8 +3253,14 @@ export function renderChatThreadPage(
|
|
|
3246
3253
|
// POST multipart to the upload endpoint
|
|
3247
3254
|
fetch(uploadUrl, {
|
|
3248
3255
|
method: 'POST',
|
|
3256
|
+
headers: { 'Accept': 'application/json' },
|
|
3249
3257
|
body: fd
|
|
3250
3258
|
}).then(function(r) {
|
|
3259
|
+
if (r.status === 401) {
|
|
3260
|
+
stopAll();
|
|
3261
|
+
window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3251
3264
|
if (!r.ok) {
|
|
3252
3265
|
return r.json().then(function(data) {
|
|
3253
3266
|
stopAll();
|
|
@@ -83,9 +83,13 @@ export function renderPushTestControl(): string {
|
|
|
83
83
|
btn.addEventListener('click', function() {
|
|
84
84
|
btn.disabled = true;
|
|
85
85
|
status.textContent = 'Sending…';
|
|
86
|
-
fetch(${JSON.stringify(PUSH_TEST_PATH)}, { method: 'POST', credentials: 'same-origin' })
|
|
86
|
+
fetch(${JSON.stringify(PUSH_TEST_PATH)}, { method: 'POST', credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
87
87
|
.then(function(res) { return res.json().then(function(body) { return { status: res.status, body: body }; }); })
|
|
88
88
|
.then(function(r) {
|
|
89
|
+
if (r.status === 401) {
|
|
90
|
+
window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
89
93
|
if (r.status !== 200 || !r.body || !r.body.ok) {
|
|
90
94
|
status.textContent = 'Push is not enabled on this server.';
|
|
91
95
|
} else if (r.body.delivered === 0) {
|
|
@@ -40,18 +40,81 @@ const TYPE = TYPE_MANIFEST.metadata.name;
|
|
|
40
40
|
// ─── Auth redirect tests ──────────────────────────────────────────────────────
|
|
41
41
|
|
|
42
42
|
describe("admin UI — unauthenticated redirects", () => {
|
|
43
|
-
|
|
43
|
+
const NAV = { "Sec-Fetch-Mode": "navigate" };
|
|
44
|
+
|
|
45
|
+
it("unauthenticated GET /admin/agents redirects to /admin/login with returnTo", async () => {
|
|
44
46
|
const app = createAdminUIApp(makeMockDeps());
|
|
45
|
-
const res = await app.request("/admin/agents");
|
|
47
|
+
const res = await app.request("/admin/agents", { headers: NAV });
|
|
46
48
|
expect(res.status).toBe(302);
|
|
47
|
-
expect(res.headers.get("Location")).toBe(
|
|
49
|
+
expect(res.headers.get("Location")).toBe(
|
|
50
|
+
"/admin/login?returnTo=%2Fadmin%2Fagents",
|
|
51
|
+
);
|
|
48
52
|
});
|
|
49
53
|
|
|
50
|
-
it("unauthenticated GET /admin/agents/:id redirects to /admin/login", async () => {
|
|
54
|
+
it("unauthenticated GET /admin/agents/:id redirects to /admin/login with returnTo", async () => {
|
|
51
55
|
const app = createAdminUIApp(makeMockDeps());
|
|
52
|
-
const res = await app.request(`/admin/agents/${AGENT_ID}
|
|
56
|
+
const res = await app.request(`/admin/agents/${AGENT_ID}`, {
|
|
57
|
+
headers: { Accept: "text/html,application/xhtml+xml" },
|
|
58
|
+
});
|
|
53
59
|
expect(res.status).toBe(302);
|
|
54
|
-
expect(res.headers.get("Location")).toBe(
|
|
60
|
+
expect(res.headers.get("Location")).toBe(
|
|
61
|
+
`/admin/login?returnTo=${encodeURIComponent(`/admin/agents/${AGENT_ID}`)}`,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("navigation redirect preserves the query string in returnTo", async () => {
|
|
66
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
67
|
+
const res = await app.request("/admin/agents?x=1&y=2", { headers: NAV });
|
|
68
|
+
expect(res.headers.get("Location")).toBe(
|
|
69
|
+
`/admin/login?returnTo=${encodeURIComponent("/admin/agents?x=1&y=2")}`,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("unauthenticated fetch-style request gets 401 signed_out JSON", async () => {
|
|
74
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
75
|
+
const res = await app.request("/admin/agents", {
|
|
76
|
+
headers: { Accept: "application/json", "Sec-Fetch-Mode": "cors" },
|
|
77
|
+
});
|
|
78
|
+
expect(res.status).toBe(401);
|
|
79
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("request with no navigation hints gets 401 signed_out JSON", async () => {
|
|
83
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
84
|
+
const res = await app.request("/admin/agents");
|
|
85
|
+
expect(res.status).toBe(401);
|
|
86
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("unauthenticated POST fetch gets 401 signed_out JSON", async () => {
|
|
90
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
91
|
+
const res = await app.request("/admin/agents", { method: "POST" });
|
|
92
|
+
expect(res.status).toBe(401);
|
|
93
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returnTo is never an open redirect (protocol-relative path dropped)", async () => {
|
|
97
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
98
|
+
const res = await app.request("//evil.com/x", { headers: NAV });
|
|
99
|
+
expect(res.headers.get("Location") ?? "").not.toContain("evil.com");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("admin UI — GET /admin/session.json", () => {
|
|
104
|
+
it("returns 200 {signedIn:true} with a valid session", async () => {
|
|
105
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
106
|
+
const res = await app.request("/admin/session.json", {
|
|
107
|
+
headers: { Cookie: `admin_session=${await makeSessionCookie()}` },
|
|
108
|
+
});
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect(await res.json()).toEqual({ signedIn: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns 401 without a session", async () => {
|
|
114
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
115
|
+
const res = await app.request("/admin/session.json");
|
|
116
|
+
expect(res.status).toBe(401);
|
|
117
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
55
118
|
});
|
|
56
119
|
});
|
|
57
120
|
|
package/src/admin-ui.ts
CHANGED
|
@@ -516,6 +516,28 @@ async function getSessionUser(
|
|
|
516
516
|
|
|
517
517
|
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
|
518
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Accepts a `returnTo` value only when it is a same-origin relative path:
|
|
521
|
+
* must start with a single "/", so protocol-relative (//evil.com), absolute
|
|
522
|
+
* (https://evil.com) and backslash-smuggled (/\\evil.com) values are dropped.
|
|
523
|
+
*/
|
|
524
|
+
export function validateReturnTo(
|
|
525
|
+
raw: string | undefined | null,
|
|
526
|
+
): string | undefined {
|
|
527
|
+
return raw?.startsWith("/") && !raw.startsWith("//") && !raw.includes("\\")
|
|
528
|
+
? raw
|
|
529
|
+
: undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** True for browser page navigations (as opposed to fetch/XHR calls). */
|
|
533
|
+
function isNavigationRequest(c: Context): boolean {
|
|
534
|
+
if (c.req.header("Sec-Fetch-Mode") === "navigate") return true;
|
|
535
|
+
return (
|
|
536
|
+
c.req.method === "GET" &&
|
|
537
|
+
(c.req.header("Accept") ?? "").includes("text/html")
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
519
541
|
export function createUIAuthMiddleware(
|
|
520
542
|
sessionSecret: string,
|
|
521
543
|
): MiddlewareHandler<AdminUIEnv> {
|
|
@@ -525,7 +547,20 @@ export function createUIAuthMiddleware(
|
|
|
525
547
|
? await getSessionUser(sessionToken, sessionSecret)
|
|
526
548
|
: null;
|
|
527
549
|
if (!user) {
|
|
528
|
-
|
|
550
|
+
if (!isNavigationRequest(c)) {
|
|
551
|
+
return c.json({ error: "signed_out" }, 401);
|
|
552
|
+
}
|
|
553
|
+
const url = new URL(c.req.url);
|
|
554
|
+
const returnTo =
|
|
555
|
+
c.req.method === "GET"
|
|
556
|
+
? validateReturnTo(url.pathname + url.search)
|
|
557
|
+
: undefined;
|
|
558
|
+
return c.redirect(
|
|
559
|
+
returnTo
|
|
560
|
+
? `/admin/login?returnTo=${encodeURIComponent(returnTo)}`
|
|
561
|
+
: "/admin/login",
|
|
562
|
+
302,
|
|
563
|
+
);
|
|
529
564
|
}
|
|
530
565
|
c.set("userEmail", user.email);
|
|
531
566
|
c.set("isAdmin", user.isAdmin);
|
|
@@ -862,10 +897,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
862
897
|
path: "/",
|
|
863
898
|
});
|
|
864
899
|
// Redirect to returnTo if it's a valid same-origin relative path, otherwise default.
|
|
865
|
-
const destination =
|
|
866
|
-
returnTo?.startsWith("/") && !returnTo.startsWith("//")
|
|
867
|
-
? returnTo
|
|
868
|
-
: "/admin/agents";
|
|
900
|
+
const destination = validateReturnTo(returnTo) ?? "/admin/agents";
|
|
869
901
|
return c.redirect(destination, 302);
|
|
870
902
|
}
|
|
871
903
|
|
|
@@ -881,10 +913,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
881
913
|
const nonce = crypto.randomUUID();
|
|
882
914
|
|
|
883
915
|
const rawReturnTo = c.req.query("returnTo");
|
|
884
|
-
const returnTo =
|
|
885
|
-
rawReturnTo?.startsWith("/") && !rawReturnTo.startsWith("//")
|
|
886
|
-
? rawReturnTo
|
|
887
|
-
: undefined;
|
|
916
|
+
const returnTo = validateReturnTo(rawReturnTo);
|
|
888
917
|
|
|
889
918
|
const oauthState = JSON.stringify({ nonce, returnTo });
|
|
890
919
|
setCookie(c, OAUTH_STATE_COOKIE, oauthState, {
|
|
@@ -1000,6 +1029,12 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
1000
1029
|
}
|
|
1001
1030
|
});
|
|
1002
1031
|
|
|
1032
|
+
// Cheap signed-in probe for client JS (a 401 {error:"signed_out"} from
|
|
1033
|
+
// requireAuth means the session is gone).
|
|
1034
|
+
app.get("/admin/session.json", requireAuth, (c) =>
|
|
1035
|
+
c.json({ signedIn: true }),
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1003
1038
|
app.get("/admin/login", (c) => {
|
|
1004
1039
|
const error = c.req.query("error") ?? undefined;
|
|
1005
1040
|
const returnTo = c.req.query("returnTo") ?? undefined;
|
package/src/chat.smoke.test.ts
CHANGED
|
@@ -413,9 +413,13 @@ function makeBaseDeps(overrides?: Partial<AdminUIDeps>): AdminUIDeps {
|
|
|
413
413
|
describe("GET /admin/chat — auth gate", () => {
|
|
414
414
|
it("returns 302 redirect to login without session cookie", async () => {
|
|
415
415
|
const app = createAdminUIApp(makeBaseDeps());
|
|
416
|
-
const res = await app.request("/admin/chat"
|
|
416
|
+
const res = await app.request("/admin/chat", {
|
|
417
|
+
headers: { Accept: "text/html" },
|
|
418
|
+
});
|
|
417
419
|
expect(res.status).toBe(302);
|
|
418
|
-
expect(res.headers.get("location")).
|
|
420
|
+
expect(res.headers.get("location")).toBe(
|
|
421
|
+
"/admin/login?returnTo=%2Fadmin%2Fchat",
|
|
422
|
+
);
|
|
419
423
|
});
|
|
420
424
|
});
|
|
421
425
|
|
|
@@ -616,14 +620,15 @@ describe("GET /admin/chat/:agentId/threads/:threadId — thread detail", () => {
|
|
|
616
620
|
sessionCookie = await makeSessionCookie();
|
|
617
621
|
});
|
|
618
622
|
|
|
619
|
-
it("
|
|
623
|
+
it("redirects navigations to login with returnTo without session cookie", async () => {
|
|
620
624
|
const chatClient = makeMockChatClient();
|
|
621
625
|
const app = createAdminUIApp(makeBaseDeps({ chatClient }));
|
|
622
626
|
const res = await app.request(
|
|
623
627
|
`/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
|
|
628
|
+
{ headers: { "Sec-Fetch-Mode": "navigate" } },
|
|
624
629
|
);
|
|
625
630
|
expect(res.status).toBe(302);
|
|
626
|
-
expect(res.headers.get("location")).toContain("/admin/login");
|
|
631
|
+
expect(res.headers.get("location")).toContain("/admin/login?returnTo=");
|
|
627
632
|
});
|
|
628
633
|
|
|
629
634
|
it("returns 200 with messages when chatClient present", async () => {
|
|
@@ -1026,14 +1031,14 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages.json", () => {
|
|
|
1026
1031
|
sessionCookie = await makeSessionCookie();
|
|
1027
1032
|
});
|
|
1028
1033
|
|
|
1029
|
-
it("returns
|
|
1034
|
+
it("returns 401 signed_out without session cookie", async () => {
|
|
1030
1035
|
const chatClient = makeMockChatClient();
|
|
1031
1036
|
const app = createAdminUIApp(makeBaseDeps({ chatClient }));
|
|
1032
1037
|
const res = await app.request(
|
|
1033
1038
|
`/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
|
|
1034
1039
|
);
|
|
1035
|
-
expect(res.status).toBe(
|
|
1036
|
-
expect(res.
|
|
1040
|
+
expect(res.status).toBe(401);
|
|
1041
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
1037
1042
|
});
|
|
1038
1043
|
|
|
1039
1044
|
it("returns 200 JSON with messages array when chatClient present", async () => {
|
|
@@ -1148,7 +1153,7 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages.json", () => {
|
|
|
1148
1153
|
sessionCookie = await makeSessionCookie();
|
|
1149
1154
|
});
|
|
1150
1155
|
|
|
1151
|
-
it("returns
|
|
1156
|
+
it("returns 401 signed_out without session cookie", async () => {
|
|
1152
1157
|
const chatClient = makeMockChatClient();
|
|
1153
1158
|
const app = createAdminUIApp(makeBaseDeps({ chatClient }));
|
|
1154
1159
|
const res = await app.request(
|
|
@@ -1159,8 +1164,8 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages.json", () => {
|
|
|
1159
1164
|
body: JSON.stringify({ body: "test message" }),
|
|
1160
1165
|
},
|
|
1161
1166
|
);
|
|
1162
|
-
expect(res.status).toBe(
|
|
1163
|
-
expect(res.
|
|
1167
|
+
expect(res.status).toBe(401);
|
|
1168
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
1164
1169
|
});
|
|
1165
1170
|
|
|
1166
1171
|
it("returns 200 JSON with message when chatClient present", async () => {
|
|
@@ -1237,7 +1242,7 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages/upload", () => {
|
|
|
1237
1242
|
sessionCookie = await makeSessionCookie();
|
|
1238
1243
|
});
|
|
1239
1244
|
|
|
1240
|
-
it("returns
|
|
1245
|
+
it("returns 401 signed_out without session cookie", async () => {
|
|
1241
1246
|
const chatClient = makeMockChatClient();
|
|
1242
1247
|
const app = createAdminUIApp(makeBaseDeps({ chatClient }));
|
|
1243
1248
|
const res = await app.request(
|
|
@@ -1248,8 +1253,8 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages/upload", () => {
|
|
|
1248
1253
|
body: "body=test+message",
|
|
1249
1254
|
},
|
|
1250
1255
|
);
|
|
1251
|
-
expect(res.status).toBe(
|
|
1252
|
-
expect(res.
|
|
1256
|
+
expect(res.status).toBe(401);
|
|
1257
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
1253
1258
|
});
|
|
1254
1259
|
|
|
1255
1260
|
it("returns 201 JSON with message when chatClient present", async () => {
|
|
@@ -1368,14 +1373,14 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages/:messageId/attachm
|
|
|
1368
1373
|
sessionCookie = await makeSessionCookie();
|
|
1369
1374
|
});
|
|
1370
1375
|
|
|
1371
|
-
it("returns
|
|
1376
|
+
it("returns 401 signed_out without session cookie", async () => {
|
|
1372
1377
|
const chatClient = makeMockChatClient();
|
|
1373
1378
|
const app = createAdminUIApp(makeBaseDeps({ chatClient }));
|
|
1374
1379
|
const res = await app.request(
|
|
1375
1380
|
`/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
|
|
1376
1381
|
);
|
|
1377
|
-
expect(res.status).toBe(
|
|
1378
|
-
expect(res.
|
|
1382
|
+
expect(res.status).toBe(401);
|
|
1383
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
1379
1384
|
});
|
|
1380
1385
|
|
|
1381
1386
|
it("streams bytes with an audio/* Content-Type for a .webm attachment", async () => {
|
package/src/push-toggle.ts
CHANGED
|
@@ -114,14 +114,18 @@ export function renderPushToggle(args: PushToggleArgs): string {
|
|
|
114
114
|
var json = subscription.toJSON();
|
|
115
115
|
return fetch(SUBSCRIBE_URL, {
|
|
116
116
|
method: 'POST',
|
|
117
|
-
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
118
118
|
body: JSON.stringify({
|
|
119
119
|
endpoint: subscription.endpoint,
|
|
120
120
|
p256dh: json.keys && json.keys.p256dh,
|
|
121
121
|
auth: json.keys && json.keys.auth,
|
|
122
122
|
}),
|
|
123
123
|
});
|
|
124
|
-
}).then(function() {
|
|
124
|
+
}).then(function(res) {
|
|
125
|
+
if (res && res.status === 401) {
|
|
126
|
+
window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
125
129
|
setStatus('Notifications enabled.');
|
|
126
130
|
btn.textContent = 'Notifications on';
|
|
127
131
|
});
|
package/src/pwa.ts
CHANGED