@nano133/nanoterra 0.11.0 → 0.12.0
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/CHANGELOG.md +26 -0
- package/README.md +84 -11
- package/ai/SPEC.md +11 -4
- package/dist/admin/guard.d.ts +9 -1
- package/dist/admin/guard.js +12 -4
- package/dist/admin/panel/card.d.ts +5 -0
- package/dist/admin/panel/card.js +3 -0
- package/dist/admin/panel/icons.d.ts +9 -0
- package/dist/admin/panel/icons.js +1 -0
- package/dist/admin/panel/index.d.ts +1 -1
- package/dist/admin/panel/mount.d.ts +7 -0
- package/dist/admin/panel/mount.js +4 -1
- package/dist/admin-signin/adapters/d1.d.ts +3 -1
- package/dist/admin-signin/adapters/d1.js +58 -3
- package/dist/admin-signin/adapters/firestore.d.ts +5 -2
- package/dist/admin-signin/adapters/firestore.js +74 -5
- package/dist/admin-signin/conformance.d.ts +8 -1
- package/dist/admin-signin/conformance.js +88 -3
- package/dist/admin-signin/handler.d.ts +7 -0
- package/dist/admin-signin/handler.js +134 -6
- package/dist/admin-signin/index.d.ts +2 -2
- package/dist/admin-signin/index.js +3 -2
- package/dist/admin-signin/memory.d.ts +6 -2
- package/dist/admin-signin/memory.js +47 -1
- package/dist/admin-signin/panel/index.d.ts +3 -0
- package/dist/admin-signin/panel/index.js +4 -2
- package/dist/admin-signin/panel/passkeys.d.ts +43 -0
- package/dist/admin-signin/panel/passkeys.js +202 -0
- package/dist/admin-signin/panel/view.d.ts +12 -0
- package/dist/admin-signin/panel/view.js +48 -1
- package/dist/admin-signin/types.d.ts +57 -7
- package/dist/admin-signin/types.js +20 -3
- package/package.json +1 -1
- package/src/admin/guard.ts +19 -5
- package/src/admin/panel/card.ts +5 -0
- package/src/admin/panel/icons.ts +1 -0
- package/src/admin/panel/index.ts +1 -1
- package/src/admin/panel/mount.ts +11 -1
- package/src/admin/panel/style.css +44 -0
- package/src/admin-signin/adapters/d1.sql +20 -3
- package/src/admin-signin/adapters/d1.ts +58 -5
- package/src/admin-signin/adapters/firestore.ts +73 -6
- package/src/admin-signin/conformance.ts +99 -3
- package/src/admin-signin/handler.ts +131 -4
- package/src/admin-signin/index.ts +7 -2
- package/src/admin-signin/memory.ts +43 -2
- package/src/admin-signin/panel/index.ts +6 -2
- package/src/admin-signin/panel/passkeys.ts +245 -0
- package/src/admin-signin/panel/view.ts +63 -1
- package/src/admin-signin/types.ts +62 -11
- package/src/player-signin/panel/style.css +3 -2
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The owner's passkeys in the browser (since 0.12.0): the "Passkey" way of the admin's sign-in, and the "Owner passkeys" card for /admin (`ownerPasskeys()`,
|
|
2
|
+
// a card for `mountAdmin`'s `cards`). A passkey is only a key to /admin: the money stays in the owner's wallet.
|
|
3
|
+
//
|
|
4
|
+
// - Sign in: the passkey purse (the game's FROZEN scheme, the player sheet's) signs "ADMIN of <host>: sign in as <address> at <ms>"; the server lets it in
|
|
5
|
+
// only while the passkey is linked to the game's address.
|
|
6
|
+
// - Add: only with a WALLET admin session. A new passkey is made (or one the owner has is used), and it signs "Link this passkey to the ADMIN of <host>:
|
|
7
|
+
// ...": the server writes the link only with both proofs, the wallet session and this signature.
|
|
8
|
+
// - Remove: only with a wallet admin session; the passkey's admin sessions end too.
|
|
9
|
+
//
|
|
10
|
+
// The passkey code (@nano133/signin/passkey) and the purse's signing code (@nano133/signin/purse) are loaded only when a passkey is used: a game without
|
|
11
|
+
// passkeys never loads them. The seed lives in local variables of one press only: never in storage, a log, an error text or an address.
|
|
12
|
+
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
13
|
+
import { h, shortAddress } from "../../admin/panel/dom.js";
|
|
14
|
+
import { icon } from "../../admin/panel/icons.js";
|
|
15
|
+
import { checkScheme } from "../../player-signin/scheme-check.js";
|
|
16
|
+
import { ADMIN_SIGNIN_BASE, adminLinkDomain, adminPasskeyDomain } from "../types.js";
|
|
17
|
+
/** What the owner reads when a passkey prompt fails, by the fault's code (@nano133/signin/passkey's PasskeyFault). */
|
|
18
|
+
export function passkeyFaultText(e) {
|
|
19
|
+
const code = e?.code;
|
|
20
|
+
const name = e?.name;
|
|
21
|
+
const c = typeof code === "string" ? code : name === "NotAllowedError" || name === "AbortError" ? "cancelled" : name === "SecurityError" ? "wrong-host" : name === "NotSupportedError" ? "unsupported" : name === "InvalidStateError" ? "exists" : "failed";
|
|
22
|
+
if (c === "cancelled")
|
|
23
|
+
return "The passkey was closed. Try again.";
|
|
24
|
+
if (c === "no-prf" || c === "other-form" || c === "unsupported")
|
|
25
|
+
return "This browser or device cannot use a passkey here. Use your wallet.";
|
|
26
|
+
if (c === "not-a-purse")
|
|
27
|
+
return "That passkey is not one of this game's. Choose another one.";
|
|
28
|
+
if (c === "wrong-host")
|
|
29
|
+
return "A passkey works only on the game's own address.";
|
|
30
|
+
if (c === "exists")
|
|
31
|
+
return "This device has a passkey for this game already. Press Use a passkey I have.";
|
|
32
|
+
return "The passkey did not work. Try again.";
|
|
33
|
+
}
|
|
34
|
+
const dayText = (ms) => new Date(ms).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
35
|
+
function setupOf(o) {
|
|
36
|
+
return { rpId: o.rpId ?? location.hostname, rpName: o.name, scheme: o.scheme, userName: `${o.name} admin · ${dayText(Date.now())}`, exclude: [] };
|
|
37
|
+
}
|
|
38
|
+
const apiOf = (o) => createApi(o.base ?? ADMIN_SIGNIN_BASE, o.fetch ?? ((...a) => fetch(...a)));
|
|
39
|
+
/**
|
|
40
|
+
* Signs in to /admin with a linked passkey: the device asks for the passkey, the purse signs the ADMIN text, the server checks the link. Resolves with the
|
|
41
|
+
* passkey's address; throws an ApiError (the server's words) or a passkey fault (`passkeyFaultText`).
|
|
42
|
+
*/
|
|
43
|
+
export async function passkeyAdminSignIn(o) {
|
|
44
|
+
checkScheme(o.scheme);
|
|
45
|
+
const { openPasskey } = await import("@nano133/signin/passkey");
|
|
46
|
+
const p = await openPasskey(setupOf(o), o.signal);
|
|
47
|
+
const { signinBody } = await import("@nano133/signin/purse");
|
|
48
|
+
return apiOf(o).post("/passkey", { ...signinBody(adminPasskeyDomain(location.host), p.seed), ...(o.turnstile ? { turnstile: o.turnstile } : {}) });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The "Owner passkeys" card for /admin: the passkeys linked to the game's address, "Add a passkey" and Remove (both only with a wallet sign-in). Give it to
|
|
52
|
+
* `mountAdmin`'s `cards`; it reads the list each time the owner signs in.
|
|
53
|
+
*
|
|
54
|
+
* mountAdmin(el, { looks, signIn: adminSignIn({ passkeys: true, scheme, name }), signOut: adminSignOut(), cards: [ownerPasskeys({ scheme, name })] });
|
|
55
|
+
*/
|
|
56
|
+
export function ownerPasskeys(options) {
|
|
57
|
+
checkScheme(options.scheme);
|
|
58
|
+
const api = apiOf(options);
|
|
59
|
+
let list = null;
|
|
60
|
+
let prompt = null;
|
|
61
|
+
const status = h("p", { class: "fine passkey-status", role: "status", "aria-live": "polite" });
|
|
62
|
+
const items = h("ul", { class: "signin-list passkey-list" });
|
|
63
|
+
const note = h("p", { class: "fine" });
|
|
64
|
+
const add = h("div", { class: "passkey-add" });
|
|
65
|
+
const el = h("section", { class: "admin-card owner-passkeys", "aria-labelledby": "n133-passkeys-title" }, h("h2", { id: "n133-passkeys-title" }, "Owner passkeys"), h("p", { class: "fine" }, "A passkey opens this admin page with Face ID, Touch ID or your device PIN, without your wallet. It cannot move money: your money stays in your wallet."), items, note, add, status);
|
|
66
|
+
el.hidden = true;
|
|
67
|
+
const say = (text) => (status.textContent = text);
|
|
68
|
+
const key = (text, onclick, kind = "plain") => h("button", { class: `btn${kind === "primary" ? " btn-primary" : ""}`, type: "button", onclick }, text);
|
|
69
|
+
const busy = (on) => {
|
|
70
|
+
for (const b of Array.from(el.querySelectorAll("button")))
|
|
71
|
+
b.disabled = on;
|
|
72
|
+
el.toggleAttribute("aria-busy", on);
|
|
73
|
+
};
|
|
74
|
+
const words = (e, fallback) => (e instanceof ApiError ? e.message : e && typeof e === "object" && ("code" in e || "name" in e) ? passkeyFaultText(e) : fallback);
|
|
75
|
+
function draw() {
|
|
76
|
+
if (!list)
|
|
77
|
+
return;
|
|
78
|
+
const l = list;
|
|
79
|
+
items.replaceChildren(...l.passkeys.map((p) => {
|
|
80
|
+
const row = h("li", { class: "passkey-row" });
|
|
81
|
+
const what = h("span", { class: "passkey-what" }, icon("key", 18), h("strong", {}, h("code", { title: p.address }, shortAddress(p.address))), h("span", {}, ` · added ${dayText(p.createdAt)}`), p.address === l.you && l.via === "passkey" ? h("span", { class: "chip" }, "This passkey") : null);
|
|
82
|
+
row.append(what);
|
|
83
|
+
if (l.canChange)
|
|
84
|
+
row.append(key("Remove", () => confirmRemove(row, p.address)));
|
|
85
|
+
return row;
|
|
86
|
+
}));
|
|
87
|
+
if (!l.passkeys.length)
|
|
88
|
+
items.append(h("li", { class: "fine" }, "No passkey is linked yet."));
|
|
89
|
+
note.textContent = l.canChange ? `You can link up to ${l.limit} passkeys. Each one proves its key when you add it.` : "You signed in with a passkey. Sign in with your wallet to add or remove a passkey.";
|
|
90
|
+
showAdd();
|
|
91
|
+
}
|
|
92
|
+
function showAdd() {
|
|
93
|
+
if (!list?.canChange)
|
|
94
|
+
return add.replaceChildren();
|
|
95
|
+
const full = list.passkeys.length >= list.limit;
|
|
96
|
+
const b = key("Add a passkey", () => chooseAdd(), "primary");
|
|
97
|
+
b.disabled = full;
|
|
98
|
+
add.replaceChildren(b, full ? h("p", { class: "fine" }, `You have ${list.limit} passkeys. Remove one to add another.`) : "");
|
|
99
|
+
}
|
|
100
|
+
function chooseAdd() {
|
|
101
|
+
say("");
|
|
102
|
+
add.replaceChildren(h("p", {}, "Make a new passkey on this device, or use a passkey you have for this game."), h("div", { class: "passkey-keys" }, key("Make a new passkey", () => void makeNew(), "primary"), key("Use a passkey I have", () => void useMine()), key("Cancel", () => showAdd())));
|
|
103
|
+
add.querySelector("button")?.focus({ preventScroll: true });
|
|
104
|
+
}
|
|
105
|
+
/** Links the passkey of `seed`: it signs the LINK text, and the server writes the link with this wallet session. */
|
|
106
|
+
async function linkSeed(seed) {
|
|
107
|
+
const { signinBody } = await import("@nano133/signin/purse");
|
|
108
|
+
list = await api.post("/passkey/link", signinBody(adminLinkDomain(location.host), seed));
|
|
109
|
+
draw();
|
|
110
|
+
say("Passkey added. It opens this admin page from now on.");
|
|
111
|
+
}
|
|
112
|
+
async function makeNew() {
|
|
113
|
+
busy(true);
|
|
114
|
+
say("Making a passkey… Follow your device.");
|
|
115
|
+
prompt = new AbortController();
|
|
116
|
+
try {
|
|
117
|
+
const pk = await import("@nano133/signin/passkey");
|
|
118
|
+
const made = await pk.makePasskey(setupOf(options), prompt.signal);
|
|
119
|
+
await firstOpen(made, false);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
busy(false);
|
|
123
|
+
showAdd();
|
|
124
|
+
say(words(e, "The passkey was not added. Try again."));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The first use of a new passkey gives its seed. Where the browser allows one prompt per press, one more key finishes it. */
|
|
128
|
+
async function firstOpen(made, pick) {
|
|
129
|
+
busy(true);
|
|
130
|
+
say("Opening the new passkey…");
|
|
131
|
+
prompt = new AbortController();
|
|
132
|
+
const pk = await import("@nano133/signin/passkey");
|
|
133
|
+
try {
|
|
134
|
+
const p = await pk.firstUse(setupOf(options), made, prompt.signal, pick);
|
|
135
|
+
await linkSeed(p.seed);
|
|
136
|
+
busy(false);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
busy(false);
|
|
140
|
+
const code = e?.code;
|
|
141
|
+
if (code !== "cancelled" && code !== "failed")
|
|
142
|
+
throw e;
|
|
143
|
+
add.replaceChildren(h("p", {}, "Your passkey is made. Open it once to add it."), h("div", { class: "passkey-keys" }, key("Open the new passkey", () => void firstOpen(made, true).catch(fault), "primary"), key("Cancel", () => showAdd())));
|
|
144
|
+
say("");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function fault(e) {
|
|
148
|
+
busy(false);
|
|
149
|
+
showAdd();
|
|
150
|
+
say(words(e, "The passkey was not added. Try again."));
|
|
151
|
+
}
|
|
152
|
+
async function useMine() {
|
|
153
|
+
busy(true);
|
|
154
|
+
say("Waiting for your passkey…");
|
|
155
|
+
prompt = new AbortController();
|
|
156
|
+
try {
|
|
157
|
+
const pk = await import("@nano133/signin/passkey");
|
|
158
|
+
const p = await pk.openPasskey(setupOf(options), prompt.signal);
|
|
159
|
+
await linkSeed(p.seed);
|
|
160
|
+
busy(false);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
fault(e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function confirmRemove(row, address) {
|
|
167
|
+
const keys = h("div", { class: "passkey-confirm" }, h("p", {}, "Remove this passkey? It cannot open this page any more, and its sign-ins end now."), key("Yes, remove", () => void remove(address), "primary"), key("Cancel", () => draw()));
|
|
168
|
+
row.replaceChildren(row.firstChild, keys);
|
|
169
|
+
keys.querySelector("button")?.focus({ preventScroll: true });
|
|
170
|
+
}
|
|
171
|
+
async function remove(address) {
|
|
172
|
+
busy(true);
|
|
173
|
+
say("Removing…");
|
|
174
|
+
try {
|
|
175
|
+
list = await api.post("/passkey/unlink", { address });
|
|
176
|
+
busy(false);
|
|
177
|
+
draw();
|
|
178
|
+
say("Passkey removed.");
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
busy(false);
|
|
182
|
+
draw();
|
|
183
|
+
say(words(e, "The game's server did not answer. Nothing was changed."));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function refresh() {
|
|
187
|
+
prompt?.abort();
|
|
188
|
+
prompt = null;
|
|
189
|
+
try {
|
|
190
|
+
list = await api.post("/passkeys");
|
|
191
|
+
el.hidden = false;
|
|
192
|
+
say("");
|
|
193
|
+
draw();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// No passkeys on this game (the route is off), or no session that may read them: the card does not show.
|
|
197
|
+
list = null;
|
|
198
|
+
el.hidden = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { el, refresh };
|
|
202
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PasskeyScheme } from "@nano133/signin/passkey";
|
|
1
2
|
export type AdminSigninViewOptions = {
|
|
2
3
|
/** Where the sign-in's routes are. Default "/api/admin-signin" (ADMIN_SIGNIN_BASE). */
|
|
3
4
|
base?: string;
|
|
@@ -6,6 +7,17 @@ export type AdminSigninViewOptions = {
|
|
|
6
7
|
* admin sign-in from a game sign-in in it: it is off unless the game turns it on on the server and here.
|
|
7
8
|
*/
|
|
8
9
|
nanchat?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Offer the owner's linked passkeys (the server's `passkeys: true`): a "Passkey" tile after the wallets. Default false. Needs `scheme` and `name`. Only a
|
|
12
|
+
* passkey that the owner linked in Settings while signed in with the wallet opens /admin.
|
|
13
|
+
*/
|
|
14
|
+
passkeys?: boolean;
|
|
15
|
+
/** The game's FROZEN passkey scheme: the same one the player sheet takes. Needed with `passkeys`. */
|
|
16
|
+
scheme?: PasskeyScheme;
|
|
17
|
+
/** The game's name (the device shows it with the passkey). Needed with `passkeys`. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** The passkeys' relying party id: the game's domain. Default: the page's host name. */
|
|
20
|
+
rpId?: string;
|
|
9
21
|
/** The person check (Turnstile): fills `box` and gives its token, before a start. No hook: no check. */
|
|
10
22
|
personCheck?: (box: HTMLElement) => Promise<string | null | undefined>;
|
|
11
23
|
/** Draws a QR code of `text` (a wallet's link), for a screen with a mouse. Default: the package's own QR code; a link too long for it is shown as a link. */
|
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Plain DOM, no framework, no package. The tiles show letters, not logos: no wallet's licence clearly allows its logo. Phone first: text 16 px, every
|
|
7
7
|
// key and tile 48 px or more each way. It uses the player sheet's stylesheet ("@nano133/nanoterra/admin-signin/style.css" is the same file).
|
|
8
|
+
//
|
|
9
|
+
// With `passkeys: true` (and the game's frozen `scheme` and `name`), a "Passkey" tile comes after the wallets: a passkey that the owner linked in Settings
|
|
10
|
+
// ("Owner passkeys", ./passkeys.ts) opens /admin. A passkey that is not linked is refused by the server.
|
|
8
11
|
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
9
12
|
import { h, shortAddress } from "../../admin/panel/dom.js";
|
|
10
13
|
import { icon } from "../../admin/panel/icons.js";
|
|
14
|
+
import { checkScheme } from "../../player-signin/scheme-check.js";
|
|
11
15
|
import { qrSvg } from "../../shop/panel/qr.js";
|
|
12
16
|
import { ADMIN_SIGNIN_BASE } from "../types.js";
|
|
17
|
+
import { passkeyAdminSignIn, passkeyFaultText } from "./passkeys.js";
|
|
13
18
|
/** The wallets, in every list's order: Berry Wallet first. */
|
|
14
19
|
const WALLETS = [
|
|
15
20
|
{ way: "berry", name: "Berry Wallet", mark: "B" },
|
|
@@ -25,6 +30,13 @@ export function adminSigninView(options = {}) {
|
|
|
25
30
|
const base = options.base ?? ADMIN_SIGNIN_BASE;
|
|
26
31
|
const api = createApi(base, options.fetch ?? ((...a) => fetch(...a)));
|
|
27
32
|
const wallets = WALLETS.filter((w) => w.way !== "nanchat" || options.nanchat === true);
|
|
33
|
+
const passkeysOn = options.passkeys === true;
|
|
34
|
+
if (passkeysOn) {
|
|
35
|
+
checkScheme(options.scheme);
|
|
36
|
+
if (typeof options.name !== "string" || !options.name.trim())
|
|
37
|
+
throw new Error("passkeys: true needs the game's name");
|
|
38
|
+
}
|
|
39
|
+
let prompt = null;
|
|
28
40
|
const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
|
|
29
41
|
let stopped = false;
|
|
30
42
|
let timer;
|
|
@@ -41,11 +53,46 @@ export function adminSigninView(options = {}) {
|
|
|
41
53
|
if (timer)
|
|
42
54
|
clearTimeout(timer);
|
|
43
55
|
timer = undefined;
|
|
56
|
+
prompt?.abort();
|
|
57
|
+
prompt = null;
|
|
44
58
|
}
|
|
45
59
|
function showTiles() {
|
|
46
60
|
stopWaiting();
|
|
47
61
|
const tiles = wallets.map((w) => h("button", { class: "n133s-tile", type: "button", "data-way": w.way, onclick: () => void choose(w.way) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, w.mark), h("span", { class: "n133s-name" }, w.name)));
|
|
48
|
-
|
|
62
|
+
// The owner's linked passkey comes after the wallets: a wallet makes the first admin login, and only it links a passkey.
|
|
63
|
+
if (passkeysOn)
|
|
64
|
+
tiles.push(h("button", { class: "n133s-tile", type: "button", "data-way": "passkey", onclick: () => showPasskey() }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, icon("key", 24)), h("span", { class: "n133s-name" }, "Passkey")));
|
|
65
|
+
area.replaceChildren(h("p", { class: "n133s-or" }, passkeysOn ? "Choose your wallet or your passkey" : "Choose your wallet"), h("div", { class: "n133s-tiles", "data-count": String(tiles.length) }, ...tiles));
|
|
66
|
+
}
|
|
67
|
+
// ---- the owner's linked passkey ----
|
|
68
|
+
function showPasskey(words = "") {
|
|
69
|
+
stopWaiting();
|
|
70
|
+
current = null;
|
|
71
|
+
const back = h("button", { class: "n133s-back", type: "button", onclick: () => showTiles() }, icon("back", 18), h("span", {}, "Back"));
|
|
72
|
+
area.replaceChildren(back, h("h3", { class: "n133s-step", tabindex: "-1" }, "Passkey"), h("p", { class: "n133s-text" }, "Use a passkey that you linked to the game's address in Settings. Your device asks for Face ID, Touch ID or its PIN."), h("div", { class: "n133s-keys" }, key("Use my passkey", () => void usePasskey(), "primary")), h("p", { class: "n133s-fine" }, "No passkey yet? Sign in with your wallet, then add one in Settings, Owner passkeys."));
|
|
73
|
+
say(words);
|
|
74
|
+
focusFirst();
|
|
75
|
+
}
|
|
76
|
+
async function usePasskey() {
|
|
77
|
+
stopWaiting();
|
|
78
|
+
for (const b of Array.from(area.querySelectorAll("button")))
|
|
79
|
+
b.disabled = true;
|
|
80
|
+
say("Waiting for your passkey…");
|
|
81
|
+
prompt = new AbortController();
|
|
82
|
+
try {
|
|
83
|
+
const turnstile = options.personCheck ? await options.personCheck(check) : undefined;
|
|
84
|
+
const r = await passkeyAdminSignIn({ scheme: options.scheme, name: options.name, rpId: options.rpId, base, fetch: options.fetch, turnstile, signal: prompt.signal });
|
|
85
|
+
if (stopped)
|
|
86
|
+
return;
|
|
87
|
+
area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, `Signed in with your passkey ${shortAddress(r.address)}`)));
|
|
88
|
+
say("");
|
|
89
|
+
options.onSignedIn?.(r.address);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
if (stopped)
|
|
93
|
+
return;
|
|
94
|
+
showPasskey(e instanceof ApiError ? e.message : passkeyFaultText(e));
|
|
95
|
+
}
|
|
49
96
|
}
|
|
50
97
|
async function choose(way) {
|
|
51
98
|
if (current?.way === way && Date.now() < current.expiresAt)
|
|
@@ -51,15 +51,38 @@ export declare const ADMIN_SIGNIN_LIMITS: {
|
|
|
51
51
|
readonly limit: 5;
|
|
52
52
|
readonly windowMs: number;
|
|
53
53
|
};
|
|
54
|
+
/** The owner's passkeys: the list, a link and a removal, per IP address (memory). A passkey's sign-in counts with the starts (`signin`, hook). */
|
|
55
|
+
readonly passkeys: {
|
|
56
|
+
readonly limit: 30;
|
|
57
|
+
readonly windowMs: 60000;
|
|
58
|
+
};
|
|
54
59
|
/** The biggest body the routes read. */
|
|
55
60
|
readonly bodyMax: 8192;
|
|
56
61
|
};
|
|
62
|
+
/** The most passkeys one owner may link. */
|
|
63
|
+
export declare const ADMIN_PASSKEY_LIMIT = 10;
|
|
64
|
+
/** How long a passkey's signature is kept as used (its 5-minute window, both ways, and some more). */
|
|
65
|
+
export declare const ADMIN_ONCE_MS: number;
|
|
66
|
+
/**
|
|
67
|
+
* What a passkey signs to OPEN /admin: `signinMessage(adminPasskeyDomain(host), address, at)`, "ADMIN of <host>: sign in as nano_... at <ms>". Never the
|
|
68
|
+
* host alone: a player's passkey signature names only the host, and it must never open /admin.
|
|
69
|
+
*/
|
|
70
|
+
export declare const adminPasskeyDomain: (host: string) => string;
|
|
71
|
+
/** What a passkey signs to be LINKED to the owner: "Link this passkey to the ADMIN of <host>: sign in as nano_... at <ms>". It opens nothing. */
|
|
72
|
+
export declare const adminLinkDomain: (host: string) => string;
|
|
73
|
+
/** One passkey linked to the owner: the passkey purse's address and when it was linked (ms). */
|
|
74
|
+
export type AdminPasskey = {
|
|
75
|
+
address: string;
|
|
76
|
+
createdAt: number;
|
|
77
|
+
};
|
|
78
|
+
/** What a link gives: "linked", "exists" (it was linked already: nothing changed) or "full" (the owner has ADMIN_PASSKEY_LIMIT already). */
|
|
79
|
+
export type AdminLinkResult = "linked" | "exists" | "full";
|
|
57
80
|
/** A token, a starter value: 32 random bytes as 64 hex characters. */
|
|
58
81
|
export declare const TOKEN_PATTERN: RegExp;
|
|
59
82
|
/**
|
|
60
83
|
* An admin session as a store keeps it. `idHash` is the SHA-256 (hex) of "admin:" and the cookie's token (`adminKey`): never the token, and never the
|
|
61
|
-
* plain hash a player's session has, so the SERVER keeps the two scopes apart and not only the cookies' names. `via` is "wallet" for
|
|
62
|
-
*
|
|
84
|
+
* plain hash a player's session has, so the SERVER keeps the two scopes apart and not only the cookies' names. `via` is "wallet" for a wallet's session
|
|
85
|
+
* and "passkey" for a linked passkey's (its `address` is the PASSKEY's, never the owner's); a store returns the word it holds.
|
|
63
86
|
*/
|
|
64
87
|
export type AdminSessionRecord = {
|
|
65
88
|
idHash: string;
|
|
@@ -68,16 +91,28 @@ export type AdminSessionRecord = {
|
|
|
68
91
|
expiresAt: number;
|
|
69
92
|
via: string;
|
|
70
93
|
};
|
|
71
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* The admin session as the game reads it (`createAdminHandler`'s `session` hook). `via` is "unknown" for a stored word other than "wallet", "nanoterra" and
|
|
96
|
+
* "passkey": the guard refuses it. A "passkey" session carries `linkedTo`, the owner's address, only while its link to the CURRENT owner exists (read on this
|
|
97
|
+
* request); without the link the sign-in gives no session at all.
|
|
98
|
+
*/
|
|
72
99
|
export type AdminSigninSession = {
|
|
73
100
|
address: string;
|
|
74
101
|
createdAt: number;
|
|
75
102
|
via: "wallet" | "nanoterra" | "unknown";
|
|
103
|
+
} | {
|
|
104
|
+
address: string;
|
|
105
|
+
createdAt: number;
|
|
106
|
+
via: "passkey";
|
|
107
|
+
linkedTo: string;
|
|
76
108
|
};
|
|
77
109
|
/**
|
|
78
|
-
* The game's database for the admin's sign-in. An adapter reads and writes ONLY its
|
|
79
|
-
* and only the ADMIN's rows of the first two (a game whose players sign in through the same tables
|
|
80
|
-
* Run `adminSigninStoreConformance` on a store you write yourself.
|
|
110
|
+
* The game's database for the admin's sign-in. An adapter reads and writes ONLY its tables or collections (requests, sessions, the sign-in list, and for
|
|
111
|
+
* the owner's passkeys the links and the used signatures), and only the ADMIN's rows of the first two (a game whose players sign in through the same tables
|
|
112
|
+
* keeps its rows apart: the `scope` column). Run `adminSigninStoreConformance` on a store you write yourself.
|
|
113
|
+
*
|
|
114
|
+
* The passkey methods (`createSession` to `useOnce`) are optional: a store without them works for wallets, and `createAdminSignin({ passkeys: true })`
|
|
115
|
+
* refuses it.
|
|
81
116
|
*/
|
|
82
117
|
export interface AdminSigninStore {
|
|
83
118
|
/** The admin requests of one wallet app. */
|
|
@@ -100,6 +135,21 @@ export interface AdminSigninStore {
|
|
|
100
135
|
recordLogin(login: AdminLogin): Promise<void>;
|
|
101
136
|
/** The last sign-ins, newest first, at most `limit`, with only `at`, `country` and `browser`. */
|
|
102
137
|
recentLogins(limit: number): Promise<AdminLogin[]>;
|
|
103
|
-
/** Deletes what nobody needs any more: admin requests 2 hours after they ended, admin sessions that ended, sign-in rows older than 30 days. */
|
|
138
|
+
/** Deletes what nobody needs any more: admin requests 2 hours after they ended, admin sessions that ended, sign-in rows older than 30 days, used passkey signatures past their time. */
|
|
104
139
|
clean(now: number): Promise<void>;
|
|
140
|
+
/** Writes an admin session (a linked passkey's sign-in). Throws when a session with this hash exists. */
|
|
141
|
+
createSession?(session: AdminSessionRecord): Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* Links the passkey `address` to `owner`, in ONE step that holds the limit: "linked"; "exists" when it is linked already (nothing changes); "full" when
|
|
144
|
+
* `owner` has ADMIN_PASSKEY_LIMIT links (nothing is written). Two calls at once never make more than the limit.
|
|
145
|
+
*/
|
|
146
|
+
linkPasskey?(owner: string, address: string, at: number): Promise<AdminLinkResult>;
|
|
147
|
+
/** Removes the link and ends every admin session of that passkey ("passkey" sessions of `address`). True when a link was there. */
|
|
148
|
+
unlinkPasskey?(owner: string, address: string): Promise<boolean>;
|
|
149
|
+
/** The passkeys linked to `owner`, oldest first. */
|
|
150
|
+
passkeys?(owner: string): Promise<AdminPasskey[]>;
|
|
151
|
+
/** True only when `address` is linked to `owner` NOW (the exact texts). */
|
|
152
|
+
linked?(owner: string, address: string): Promise<boolean>;
|
|
153
|
+
/** Records `key` (a used passkey signature) and answers true; false when it is there and its time is not over. The admin's own table, not the player's. */
|
|
154
|
+
useOnce?(key: string, ttlMs: number): Promise<boolean>;
|
|
105
155
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// The ADMIN's sign-in of a game: the fixed route, the times, the limits and the store contract. (The player's sign-in is another thing:
|
|
2
2
|
// "@nano133/nanoterra/player-signin".) No package and no DOM: the stores, the adapters and the browser panel read this file.
|
|
3
3
|
//
|
|
4
|
-
// The admin signs in with a wallet app
|
|
5
|
-
//
|
|
6
|
-
// on the admin's route; the guard (`adminAccess`) decides again on every call of /admin.
|
|
4
|
+
// The admin signs in with a wallet app: Berry Wallet or Nautilus (and NanChat only when the game turns it on: its text cannot name the ADMIN). What Berry
|
|
5
|
+
// Wallet and Nautilus sign names the ADMIN, so a link that a stranger sends to the owner does not look like any sign-in. Only the game's own address makes a
|
|
6
|
+
// wallet admin login, and only from a request that was started on the admin's route; the guard (`adminAccess`) decides again on every call of /admin.
|
|
7
|
+
//
|
|
8
|
+
// Since 0.12.0, with `passkeys: true`, the owner may also LINK passkeys (at most 10): a passkey is only a key to /admin, the money stays in the owner's
|
|
9
|
+
// wallet. A link is written only from a live WALLET admin session of the owner and with the passkey's own signature (both sides prove their key), and a
|
|
10
|
+
// passkey's admin login counts only while its link to the CURRENT owner exists (read again on every request).
|
|
7
11
|
/** The admin's sign-in route, the same in every game: a page, an AI helper and the kit's panel find it here. */
|
|
8
12
|
export const ADMIN_SIGNIN_BASE = "/api/admin-signin";
|
|
9
13
|
/** The page the admin signs in to: Berry Wallet signs this page's address ("URI: https://example.com/admin", with NO query). */
|
|
@@ -35,8 +39,21 @@ export const ADMIN_SIGNIN_LIMITS = {
|
|
|
35
39
|
nanchatRoute: { limit: 120, windowMs: 60_000 },
|
|
36
40
|
nanchatAll: { limit: 600, windowMs: 10 * 60_000 },
|
|
37
41
|
nanchatNonce: { limit: 5, windowMs: 10 * 60_000 },
|
|
42
|
+
/** The owner's passkeys: the list, a link and a removal, per IP address (memory). A passkey's sign-in counts with the starts (`signin`, hook). */
|
|
43
|
+
passkeys: { limit: 30, windowMs: 60_000 },
|
|
38
44
|
/** The biggest body the routes read. */
|
|
39
45
|
bodyMax: 8192,
|
|
40
46
|
};
|
|
47
|
+
/** The most passkeys one owner may link. */
|
|
48
|
+
export const ADMIN_PASSKEY_LIMIT = 10;
|
|
49
|
+
/** How long a passkey's signature is kept as used (its 5-minute window, both ways, and some more). */
|
|
50
|
+
export const ADMIN_ONCE_MS = 10 * 60_000;
|
|
51
|
+
/**
|
|
52
|
+
* What a passkey signs to OPEN /admin: `signinMessage(adminPasskeyDomain(host), address, at)`, "ADMIN of <host>: sign in as nano_... at <ms>". Never the
|
|
53
|
+
* host alone: a player's passkey signature names only the host, and it must never open /admin.
|
|
54
|
+
*/
|
|
55
|
+
export const adminPasskeyDomain = (host) => `ADMIN of ${host}`;
|
|
56
|
+
/** What a passkey signs to be LINKED to the owner: "Link this passkey to the ADMIN of <host>: sign in as nano_... at <ms>". It opens nothing. */
|
|
57
|
+
export const adminLinkDomain = (host) => `Link this passkey to the ADMIN of ${host}`;
|
|
41
58
|
/** A token, a starter value: 32 random bytes as 64 hex characters. */
|
|
42
59
|
export const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin and its wallet sign-in, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/admin/guard.ts
CHANGED
|
@@ -4,13 +4,22 @@
|
|
|
4
4
|
// DO NOT LOOSEN THIS. It is the game starter's `adminGuard` (src/worker/admin.ts), with the same five checks in the same order, and it may only
|
|
5
5
|
// refuse MORE than the starter does, never less (a test runs both over a grid of inputs and proves it). Deny by default: anything this function
|
|
6
6
|
// does not understand is a refusal, and a fault inside it is a refusal too.
|
|
7
|
+
// The ONE rule Wes approved on 2026-11-01 (the owner's linked passkey): a "passkey" session opens /admin only with `linkedTo` === the owner (checks 3 and 4).
|
|
7
8
|
|
|
8
9
|
/** An admin login a game has found for this request: the address that signed, when the login was made (ms), and how it was made. */
|
|
9
10
|
export type AdminSession = {
|
|
10
11
|
address: string;
|
|
11
12
|
createdAt: number;
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* "wallet" is a signature made with the address's key. "passkey" is a signature by a passkey that the owner linked: it counts only with `linkedTo`.
|
|
15
|
+
* Anything else (a sign-in from Nanoterra names an address but proves no key) never opens /admin.
|
|
16
|
+
*/
|
|
13
17
|
via: string;
|
|
18
|
+
/**
|
|
19
|
+
* For a "passkey" session only: the owner address its link points to, set by the sign-in only after it read the link on THIS request. The guard compares
|
|
20
|
+
* it with the owner instead of `address` (the passkey's own). A session of any other `via` is judged by its `address`; this field is not read.
|
|
21
|
+
*/
|
|
22
|
+
linkedTo?: string;
|
|
14
23
|
};
|
|
15
24
|
|
|
16
25
|
export type AdminAccessInput = {
|
|
@@ -75,10 +84,15 @@ function decide(input: AdminAccessInput): AdminAccess {
|
|
|
75
84
|
if (!session || typeof session !== "object") return refuse(401, "Sign in with your Nano address.");
|
|
76
85
|
const { address, createdAt, via } = session;
|
|
77
86
|
if (typeof address !== "string" || typeof via !== "string" || typeof createdAt !== "number") return refuse(401, "Sign in with your Nano address.");
|
|
78
|
-
// 3. It was made with a wallet signature
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
87
|
+
// 3. It was made with a wallet signature, or (the one rule of 2026-11-01) by a passkey whose link the sign-in read on this request. A sign-in from
|
|
88
|
+
// Nanoterra names an address but proves no key.
|
|
89
|
+
let linkedTo: unknown;
|
|
90
|
+
if (via === "passkey") {
|
|
91
|
+
linkedTo = session.linkedTo;
|
|
92
|
+
if (typeof linkedTo !== "string") return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
|
|
93
|
+
} else if (via !== "wallet") return refuse(403, "Sign in with your Nano wallet to open this page.", { reason: "nanoterra" });
|
|
94
|
+
// 4. It is the owner's address (the same text, no other form): a wallet's own address, or the address a passkey's link points to.
|
|
95
|
+
if ((via === "passkey" ? linkedTo : address) !== owner) return refuse(403, "Only the game's own address can open this page.", { reason: "other_address", signedInAs: address });
|
|
82
96
|
// 5. It is not older than the limit (one day at most), and not from the future.
|
|
83
97
|
const age = now - createdAt;
|
|
84
98
|
if (!(age >= -CLOCK_SKEW_MS && age <= max)) return refuse(401, "Sign in again: an admin sign-in counts for one day.");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A card of the game's own for the admin panel's Settings tab (`mountAdmin`'s `cards`). Its own file, so a card made elsewhere (the owner's passkeys, in
|
|
2
|
+
// "@nano133/nanoterra/admin-signin/panel") names the type without reaching the whole panel.
|
|
3
|
+
|
|
4
|
+
/** A card of the game's own in the Settings tab: its element, made once, and what to do each time the owner signs in (read its data again). */
|
|
5
|
+
export type AdminCard = { el: HTMLElement; refresh?: () => void | Promise<void> };
|
package/src/admin/panel/icons.ts
CHANGED
|
@@ -35,6 +35,7 @@ const SHAPES = {
|
|
|
35
35
|
gear: [["path", { d: "M19.23 9.65 21.77 9.86V14.14L19.23 14.35 18.77 15.45 20.42 17.39 17.39 20.42 15.45 18.77 14.35 19.23 14.14 21.77H9.86L9.65 19.23 8.55 18.77 6.61 20.42 3.58 17.39 5.23 15.45 4.77 14.35 2.23 14.14V9.86L4.77 9.65 5.23 8.55 3.58 6.61 6.61 3.58 8.55 5.23 9.65 4.77 9.86 2.23H14.14L14.35 4.77 15.45 5.23 17.39 3.58 20.42 6.61 18.77 8.55Z" }], ["circle", { cx: 12, cy: 12, r: 3.2 }]],
|
|
36
36
|
sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
|
|
37
37
|
smile: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M8.2 14.2a4.6 4.6 0 0 0 7.6 0" }], ["path", { d: "M9 9.6h.01M15 9.6h.01", "stroke-width": 2.6 }]],
|
|
38
|
+
key: [["path", { d: "M14.5 9.5a4.5 4.5 0 1 1-1.3-3.2" }], ["path", { d: "M13.2 6.3 21 6.3M18 6.3v3M21 6.3v2.2" }], ["circle", { cx: 10, cy: 9.5, r: 1.4, fill: "currentColor", stroke: "none" }]],
|
|
38
39
|
} satisfies Record<string, Shape[]>;
|
|
39
40
|
|
|
40
41
|
export type IconName = keyof typeof SHAPES;
|
package/src/admin/panel/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The server part is "@nano133/nanoterra/admin" (the guard and the handler): it must not load this one.
|
|
8
8
|
|
|
9
9
|
export { mountAdmin } from "./mount.js";
|
|
10
|
-
export type { MountedAdmin, MountOptions } from "./mount.js";
|
|
10
|
+
export type { AdminCard, MountedAdmin, MountOptions } from "./mount.js";
|
|
11
11
|
export { ApiError, createApi } from "./api.js";
|
|
12
12
|
export type { Api } from "./api.js";
|
|
13
13
|
export { gameCard } from "./gameCard.js";
|
package/src/admin/panel/mount.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AdminSummary } from "../store.js";
|
|
|
10
10
|
import type { GameState } from "../switches.js";
|
|
11
11
|
import { PASSKEY_PREVIEW_NOTE, previewHost } from "../../player-signin/host.js";
|
|
12
12
|
import { ApiError, createApi, type Api } from "./api.js";
|
|
13
|
+
import type { AdminCard } from "./card.js";
|
|
13
14
|
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
14
15
|
import { gameCard } from "./gameCard.js";
|
|
15
16
|
import { icon, type IconName } from "./icons.js";
|
|
@@ -43,10 +44,17 @@ export type MountOptions = {
|
|
|
43
44
|
/** The game offers its players a passkey (`@nano133/nanoterra/player-signin`): on a preview address (`*.workers.dev`) the banner says that passkeys turn
|
|
44
45
|
* on with the game's own domain. */
|
|
45
46
|
playerPasskeys?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Cards of the game's own for the Settings tab, after the Sign-ins card: for example the owner's passkeys (`ownerPasskeys()` of
|
|
49
|
+
* "@nano133/nanoterra/admin-signin/panel"). Each card is made once and kept for the life of the page; its `refresh()` runs each time the owner signs in.
|
|
50
|
+
*/
|
|
51
|
+
cards?: readonly AdminCard[];
|
|
46
52
|
/** The `fetch` to use (default: the page's). */
|
|
47
53
|
fetch?: typeof fetch;
|
|
48
54
|
};
|
|
49
55
|
|
|
56
|
+
export type { AdminCard };
|
|
57
|
+
|
|
50
58
|
export type MountedAdmin = {
|
|
51
59
|
/** Reads everything again and redraws. */
|
|
52
60
|
reload(): Promise<void>;
|
|
@@ -193,6 +201,8 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
193
201
|
const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
|
|
194
202
|
const settings = h("div", { class: "settings-grid" });
|
|
195
203
|
tabs.panels.settings.append(settings);
|
|
204
|
+
// The game's own cards read their data again at each sign-in (a new dashboard is built after every sign-in).
|
|
205
|
+
for (const c of options.cards ?? []) void Promise.resolve(c.refresh?.()).catch(() => undefined);
|
|
196
206
|
data.replaceChildren(top, banner, kpis, tabs.bar, ...Object.values(tabs.panels));
|
|
197
207
|
|
|
198
208
|
const kpi = (cls: string, mark: IconName, label: string, value: string, sub: string, open: () => void, alert = false) =>
|
|
@@ -245,7 +255,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
245
255
|
orders.update(s);
|
|
246
256
|
players.update(s);
|
|
247
257
|
// The Game card is the same element at every redraw: moving it keeps its state.
|
|
248
|
-
settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), about(s)].filter((p): p is HTMLElement => p !== null));
|
|
258
|
+
settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), ...(options.cards ?? []).map((c) => c.el), about(s)].filter((p): p is HTMLElement => p !== null));
|
|
249
259
|
},
|
|
250
260
|
};
|
|
251
261
|
}
|
|
@@ -1968,6 +1968,50 @@
|
|
|
1968
1968
|
flex-basis: 100%;
|
|
1969
1969
|
}
|
|
1970
1970
|
|
|
1971
|
+
/* ---- The owner's passkeys (a card from "@nano133/nanoterra/admin-signin/panel"): phone first, 16 px text, 48 px keys ---- */
|
|
1972
|
+
|
|
1973
|
+
.n133-admin .owner-passkeys .btn {
|
|
1974
|
+
min-height: 48px;
|
|
1975
|
+
font-size: 16px;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
.n133-admin .passkey-row {
|
|
1979
|
+
display: flex;
|
|
1980
|
+
flex-wrap: wrap;
|
|
1981
|
+
align-items: center;
|
|
1982
|
+
justify-content: space-between;
|
|
1983
|
+
gap: 8px;
|
|
1984
|
+
font-size: 16px;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
.n133-admin .passkey-what {
|
|
1988
|
+
display: inline-flex;
|
|
1989
|
+
flex-wrap: wrap;
|
|
1990
|
+
align-items: center;
|
|
1991
|
+
gap: 6px;
|
|
1992
|
+
min-width: 0;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
.n133-admin .passkey-add,
|
|
1996
|
+
.n133-admin .passkey-confirm {
|
|
1997
|
+
display: grid;
|
|
1998
|
+
gap: 8px;
|
|
1999
|
+
justify-items: start;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
.n133-admin .passkey-confirm {
|
|
2003
|
+
flex-basis: 100%;
|
|
2004
|
+
padding: 12px;
|
|
2005
|
+
border-radius: 12px;
|
|
2006
|
+
background: var(--alarm-bg);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
.n133-admin .passkey-keys {
|
|
2010
|
+
display: flex;
|
|
2011
|
+
flex-wrap: wrap;
|
|
2012
|
+
gap: 8px;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1971
2015
|
/* ---- Wider panels ---- */
|
|
1972
2016
|
|
|
1973
2017
|
@container n133 (min-width: 560px) {
|