build clean, v0.1 firefox only, pre-test
This commit is contained in:
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# **************************************************************************
|
||||
# * (C)opyright 2026 by Ruben Carlo Benante *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU General Public License as published by *
|
||||
# * the Free Software Foundation, either version 3 of the License, or *
|
||||
# * (at your option) any later version. *
|
||||
# * *
|
||||
# * This program is distributed in the hope that it will be useful, *
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
# * GNU General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU General Public License *
|
||||
# * along with this program. If not, see http://www.gnu.org/licenses/. *
|
||||
# * *
|
||||
# * Contact author at: *
|
||||
# * Ruben Carlo Benante *
|
||||
# * rcb@beco.cc *
|
||||
# **************************************************************************
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# Build output. The unpacked extension and the signing package are release
|
||||
# artifacts, not sources.
|
||||
build
|
||||
logsdu-*.zip
|
||||
*.xpi
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
66
Makefile
Normal file
66
Makefile
Normal file
@@ -0,0 +1,66 @@
|
||||
# **************************************************************************
|
||||
# * (C)opyright 2026 by Ruben Carlo Benante *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU General Public License as published by *
|
||||
# * the Free Software Foundation, either version 3 of the License, or *
|
||||
# * (at your option) any later version. *
|
||||
# * *
|
||||
# * This program is distributed in the hope that it will be useful, *
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
# * GNU General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU General Public License *
|
||||
# * along with this program. If not, see http://www.gnu.org/licenses/. *
|
||||
# * *
|
||||
# * Contact author at: *
|
||||
# * Ruben Carlo Benante *
|
||||
# * rcb@beco.cc *
|
||||
# **************************************************************************
|
||||
|
||||
# Makefile for logsdu - build the unpacked extension into build/.
|
||||
#
|
||||
# Usage:
|
||||
# make # typecheck and bundle into build/
|
||||
# make dist # build, then zip build/ into logsdu-<version>.zip
|
||||
# make clean # remove build/ and the zip
|
||||
#
|
||||
# Dependencies are installed with pnpm, never npm:
|
||||
# corepack pnpm install
|
||||
#
|
||||
# Load build/ in Firefox: about:debugging -> This Firefox ->
|
||||
# Load Temporary Add-on -> pick build/manifest.json
|
||||
|
||||
EXT_ID := logsdu
|
||||
VERSION := $(shell node -p "require('./package.json').version")
|
||||
DIST := $(EXT_ID)-$(VERSION).zip
|
||||
|
||||
.PHONY: all build dist clean check-deps
|
||||
|
||||
all: build
|
||||
|
||||
# Call the local toolchain directly, so this works regardless of how pnpm is
|
||||
# provided (corepack vs standalone). Run "corepack pnpm install" first.
|
||||
build: check-deps
|
||||
node_modules/.bin/tsc -noEmit -skipLibCheck
|
||||
node esbuild.config.mjs production
|
||||
|
||||
# A plain zip of build/ is exactly what AMO wants for signing.
|
||||
dist: build
|
||||
rm -f $(DIST)
|
||||
cd build && zip -qr ../$(DIST) .
|
||||
@echo "Package: $(DIST)"
|
||||
@echo "Upload it to addons.mozilla.org as an unlisted add-on to get a signed .xpi."
|
||||
|
||||
clean:
|
||||
rm -rf build
|
||||
rm -f $(EXT_ID)-*.zip
|
||||
|
||||
# Fail with a useful message rather than a confusing "tsc: not found".
|
||||
check-deps:
|
||||
@test -x node_modules/.bin/tsc || { \
|
||||
echo "ERROR: dependencies are not installed."; \
|
||||
echo "Run: corepack pnpm install (do NOT use npm install in this tree)"; \
|
||||
exit 1; \
|
||||
}
|
||||
7
icons/logsdu.svg
Normal file
7
icons/logsdu.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect width="64" height="64" rx="14" fill="#2f6f4e"/>
|
||||
<path d="M32 14a10 10 0 0 0-10 10v6h6v-6a4 4 0 0 1 8 0v6h6v-6a10 10 0 0 0-10-10z" fill="#e8f3ec"/>
|
||||
<rect x="18" y="30" width="28" height="22" rx="4" fill="#e8f3ec"/>
|
||||
<circle cx="32" cy="39" r="3.5" fill="#2f6f4e"/>
|
||||
<rect x="30.5" y="41" width="3" height="7" rx="1.5" fill="#2f6f4e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
285
pnpm-lock.yaml
generated
Normal file
285
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,285 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
esbuild:
|
||||
specifier: 0.25.5
|
||||
version: 0.25.5
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.5':
|
||||
resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.5':
|
||||
resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.5':
|
||||
resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.5':
|
||||
resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.5':
|
||||
resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.5':
|
||||
resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.5':
|
||||
resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.5':
|
||||
resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.5':
|
||||
resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.5':
|
||||
resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.5':
|
||||
resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.5':
|
||||
resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.5':
|
||||
resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.5':
|
||||
resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.5':
|
||||
resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.5':
|
||||
resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.5':
|
||||
resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.5':
|
||||
resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
esbuild@0.25.5:
|
||||
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
esbuild@0.25.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.5
|
||||
'@esbuild/android-arm': 0.25.5
|
||||
'@esbuild/android-arm64': 0.25.5
|
||||
'@esbuild/android-x64': 0.25.5
|
||||
'@esbuild/darwin-arm64': 0.25.5
|
||||
'@esbuild/darwin-x64': 0.25.5
|
||||
'@esbuild/freebsd-arm64': 0.25.5
|
||||
'@esbuild/freebsd-x64': 0.25.5
|
||||
'@esbuild/linux-arm': 0.25.5
|
||||
'@esbuild/linux-arm64': 0.25.5
|
||||
'@esbuild/linux-ia32': 0.25.5
|
||||
'@esbuild/linux-loong64': 0.25.5
|
||||
'@esbuild/linux-mips64el': 0.25.5
|
||||
'@esbuild/linux-ppc64': 0.25.5
|
||||
'@esbuild/linux-riscv64': 0.25.5
|
||||
'@esbuild/linux-s390x': 0.25.5
|
||||
'@esbuild/linux-x64': 0.25.5
|
||||
'@esbuild/netbsd-arm64': 0.25.5
|
||||
'@esbuild/netbsd-x64': 0.25.5
|
||||
'@esbuild/openbsd-arm64': 0.25.5
|
||||
'@esbuild/openbsd-x64': 0.25.5
|
||||
'@esbuild/sunos-x64': 0.25.5
|
||||
'@esbuild/win32-arm64': 0.25.5
|
||||
'@esbuild/win32-ia32': 0.25.5
|
||||
'@esbuild/win32-x64': 0.25.5
|
||||
|
||||
typescript@5.9.3: {}
|
||||
208
src/content.ts
Normal file
208
src/content.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// *************************************************************************
|
||||
// * (C)opyright 2026 by Ruben Carlo Benante *
|
||||
// * *
|
||||
// * This program is free software; you can redistribute it and/or modify *
|
||||
// * it under the terms of the GNU General Public License as published by *
|
||||
// * the Free Software Foundation, either version 3 of the License, or *
|
||||
// * (at your option) any later version. *
|
||||
// * *
|
||||
// * This program is distributed in the hope that it will be useful, *
|
||||
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
// * GNU General Public License for more details. *
|
||||
// * *
|
||||
// * You should have received a copy of the GNU General Public License *
|
||||
// * along with this program. If not, see http://www.gnu.org/licenses/. *
|
||||
// * *
|
||||
// * Contact author at: *
|
||||
// * Ruben Carlo Benante *
|
||||
// * rcb@beco.cc *
|
||||
// *************************************************************************
|
||||
|
||||
// The content script is declared against every URL, because the one address
|
||||
// it cares about is configured at runtime and deliberately absent from the
|
||||
// manifest. Everything below is therefore written to bail out as early and as
|
||||
// cheaply as possible: one storage read and an origin comparison, then return.
|
||||
|
||||
import {
|
||||
decideSubmit,
|
||||
isConfigured,
|
||||
loadConfig,
|
||||
loadState,
|
||||
matchesSite,
|
||||
saveState,
|
||||
} from './config';
|
||||
import type { Config } from './config';
|
||||
import { EVENT_RESULT, FORM, LOGOUT } from './portal';
|
||||
import type { FillRequest, FillResult } from './portal';
|
||||
|
||||
const FORM_WAIT_MS = 10000;
|
||||
const NOTICE_ID = 'logsdu-notice';
|
||||
|
||||
// User-facing strings follow the portal's language.
|
||||
const NOTICE_TEXT: Record<string, string> = {
|
||||
disabled: 'logsdu: campos preenchidos. O login automático está desligado.',
|
||||
'rate-limited':
|
||||
'logsdu: campos preenchidos, sem envio automático (já houve uma tentativa na última hora). Confira os dados e clique em Entrar.',
|
||||
logout: 'logsdu: campos preenchidos, sem envio automático depois do logout.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Hand the values to the page-world filler.
|
||||
*
|
||||
* The payload travels on the script tag's dataset rather than in a CustomEvent
|
||||
* detail: an object created in the isolated world is not reliably readable
|
||||
* from the page, and a string on the element is both simple and synchronous.
|
||||
* The injected script removes the element as its first act.
|
||||
*/
|
||||
function fill(request: FillRequest): Promise<FillResult> {
|
||||
return new Promise((resolve) => {
|
||||
const onResult = (event: Event): void => {
|
||||
window.removeEventListener(EVENT_RESULT, onResult);
|
||||
const detail = (event as CustomEvent<string>).detail;
|
||||
try {
|
||||
resolve(JSON.parse(detail) as FillResult);
|
||||
} catch {
|
||||
resolve({ filled: false, submitted: false, error: 'bad result payload' });
|
||||
}
|
||||
};
|
||||
window.addEventListener(EVENT_RESULT, onResult);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = chrome.runtime.getURL('injected.js');
|
||||
script.dataset.logsdu = JSON.stringify(request);
|
||||
(document.head ?? document.documentElement).appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve once the login form exists, or null if it never shows up. */
|
||||
function awaitForm(): Promise<HTMLFormElement | null> {
|
||||
const existing = document.querySelector<HTMLFormElement>(FORM);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const found = document.querySelector<HTMLFormElement>(FORM);
|
||||
if (found) {
|
||||
observer.disconnect();
|
||||
window.clearTimeout(timer);
|
||||
resolve(found);
|
||||
}
|
||||
});
|
||||
const timer = window.setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(null);
|
||||
}, FORM_WAIT_MS);
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** A small, self-removing note explaining why nothing was submitted. */
|
||||
function showNotice(text: string): void {
|
||||
if (document.getElementById(NOTICE_ID)) return;
|
||||
const notice = document.createElement('div');
|
||||
notice.id = NOTICE_ID;
|
||||
notice.textContent = text;
|
||||
notice.style.cssText = [
|
||||
'position:fixed',
|
||||
'z-index:2147483647',
|
||||
'left:50%',
|
||||
'transform:translateX(-50%)',
|
||||
'bottom:16px',
|
||||
'max-width:min(90vw,520px)',
|
||||
'padding:10px 14px',
|
||||
'border-radius:8px',
|
||||
'background:#222',
|
||||
'color:#fff',
|
||||
'font:14px/1.4 system-ui,sans-serif',
|
||||
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
|
||||
].join(';');
|
||||
notice.addEventListener('click', () => notice.remove());
|
||||
document.body?.appendChild(notice);
|
||||
window.setTimeout(() => notice.remove(), 12000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember that the user asked to be logged out.
|
||||
*
|
||||
* Without this the logout redirect lands on the login page and the extension
|
||||
* immediately logs them back in, which makes logging out impossible.
|
||||
*/
|
||||
function watchLogout(): void {
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (!target?.closest?.(LOGOUT)) return;
|
||||
void loadState().then((state) =>
|
||||
saveState({ ...state, logoutAt: Date.now() }),
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function autoLogin(config: Config): Promise<void> {
|
||||
const form = await awaitForm();
|
||||
if (!form) return;
|
||||
|
||||
const state = await loadState();
|
||||
const decision = decideSubmit(config, state, Date.now());
|
||||
|
||||
// Record the attempt before it happens, not after. A submit that navigates
|
||||
// away, crashes, or is interrupted still has to count against the hourly
|
||||
// limit, otherwise a failing login could retry on every page load.
|
||||
if (decision.submit) {
|
||||
await saveState({ ...state, lastSubmitAt: Date.now() });
|
||||
}
|
||||
|
||||
const result = await fill({
|
||||
ra: config.ra,
|
||||
dn: config.dn,
|
||||
cpf: config.cpf,
|
||||
submit: decision.submit,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.warn('logsdu:', result.error);
|
||||
return;
|
||||
}
|
||||
if (!decision.submit && result.filled) {
|
||||
showNotice(NOTICE_TEXT[decision.reason] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = await loadConfig();
|
||||
if (!isConfigured(config)) return;
|
||||
if (!matchesSite(config, location.href)) return;
|
||||
|
||||
watchLogout();
|
||||
|
||||
// The popup's "Preencher agora" button, for when the automatic submit is
|
||||
// off or rate limited.
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if ((message as { type?: string })?.type !== 'fill') return undefined;
|
||||
void loadConfig()
|
||||
.then((fresh) =>
|
||||
fill({
|
||||
ra: fresh.ra,
|
||||
dn: fresh.dn,
|
||||
cpf: fresh.cpf,
|
||||
submit: (message as { submit?: boolean }).submit === true,
|
||||
}),
|
||||
)
|
||||
.then(sendResponse);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Only chase the form when it is already here, or when the document is
|
||||
// still loading and could still produce one. On the pages behind the login
|
||||
// this returns immediately instead of holding an observer open for ten
|
||||
// seconds on every navigation.
|
||||
if (document.querySelector(FORM) || document.readyState !== 'complete') {
|
||||
await autoLogin(config);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -4,8 +4,8 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Fills and submits a three-field academic portal login.",
|
||||
"icons": {
|
||||
"48": "icons/logsdu-48.png",
|
||||
"96": "icons/logsdu-96.png"
|
||||
"48": "icons/logsdu.svg",
|
||||
"96": "icons/logsdu.svg"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
@@ -38,6 +38,7 @@
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "logsdu"
|
||||
"default_title": "logsdu",
|
||||
"default_icon": "icons/logsdu.svg"
|
||||
}
|
||||
}
|
||||
|
||||
59
src/options.html
Normal file
59
src/options.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!--
|
||||
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
|
||||
* Licensed under the GNU General Public License v3.0 or later.
|
||||
* See https://www.gnu.org/licenses/ and the LICENSE file.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>logsdu</title>
|
||||
<link rel="stylesheet" href="ui.css" />
|
||||
</head>
|
||||
<body class="page">
|
||||
<h1>logsdu</h1>
|
||||
<p class="lede">
|
||||
Guarde os dados de acesso uma vez. Ao abrir a página de login, o
|
||||
preenchimento e o envio acontecem sozinhos.
|
||||
</p>
|
||||
|
||||
<form id="form" autocomplete="off">
|
||||
<label for="url">Endereço do portal</label>
|
||||
<input id="url" type="text" inputmode="url" placeholder="https://portal.exemplo.br/" />
|
||||
<p class="hint">
|
||||
O endereço fica somente aqui, na memória local da extensão. Nada
|
||||
dele aparece no código instalado.
|
||||
</p>
|
||||
|
||||
<label for="ra">Matrícula / Código</label>
|
||||
<input id="ra" type="text" inputmode="numeric" placeholder="2000101010" />
|
||||
|
||||
<label for="dn">Data de nascimento</label>
|
||||
<input id="dn" type="text" inputmode="numeric" placeholder="01/01/2000" />
|
||||
|
||||
<label for="cpf">CPF</label>
|
||||
<input id="cpf" type="text" inputmode="numeric" placeholder="000.000.000-00" />
|
||||
|
||||
<label class="check">
|
||||
<input id="autoSubmit" type="checkbox" />
|
||||
<span>Entrar automaticamente (no máximo uma tentativa por hora)</span>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<button id="save" type="submit">Salvar</button>
|
||||
<button id="clear" type="button" class="ghost">Apagar dados</button>
|
||||
<span id="status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2>Como isso é guardado</h2>
|
||||
<p class="hint">
|
||||
Os quatro valores ficam na memória local desta extensão, neste perfil
|
||||
do navegador. Não são sincronizados nem enviados para lugar nenhum. A
|
||||
proteção é a mesma de uma senha guardada no navegador: quem tiver a
|
||||
sua sessão do sistema aberta consegue lê-los.
|
||||
</p>
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
src/options.ts
Normal file
140
src/options.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// *************************************************************************
|
||||
// * (C)opyright 2026 by Ruben Carlo Benante *
|
||||
// * *
|
||||
// * This program is free software; you can redistribute it and/or modify *
|
||||
// * it under the terms of the GNU General Public License as published by *
|
||||
// * the Free Software Foundation, either version 3 of the License, or *
|
||||
// * (at your option) any later version. *
|
||||
// * *
|
||||
// * This program is distributed in the hope that it will be useful, *
|
||||
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
// * GNU General Public License for more details. *
|
||||
// * *
|
||||
// * You should have received a copy of the GNU General Public License *
|
||||
// * along with this program. If not, see http://www.gnu.org/licenses/. *
|
||||
// * *
|
||||
// * Contact author at: *
|
||||
// * Ruben Carlo Benante *
|
||||
// * rcb@beco.cc *
|
||||
// *************************************************************************
|
||||
|
||||
import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config';
|
||||
import {
|
||||
formatCpf,
|
||||
formatDate,
|
||||
formatRa,
|
||||
isCompleteCpf,
|
||||
isCompleteDate,
|
||||
isCompleteRa,
|
||||
toOrigin,
|
||||
} from './format';
|
||||
|
||||
function el<T extends HTMLElement>(id: string): T {
|
||||
const found = document.getElementById(id);
|
||||
if (!found) throw new Error(`missing element #${id}`);
|
||||
return found as T;
|
||||
}
|
||||
|
||||
const fields = {
|
||||
url: el<HTMLInputElement>('url'),
|
||||
ra: el<HTMLInputElement>('ra'),
|
||||
dn: el<HTMLInputElement>('dn'),
|
||||
cpf: el<HTMLInputElement>('cpf'),
|
||||
};
|
||||
const autoSubmit = el<HTMLInputElement>('autoSubmit');
|
||||
const status = el<HTMLSpanElement>('status');
|
||||
const form = el<HTMLFormElement>('form');
|
||||
|
||||
function setStatus(message: string, isError = false): void {
|
||||
status.textContent = message;
|
||||
status.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformat as the user types, keeping the caret at the end.
|
||||
*
|
||||
* Anchoring the caret is only correct because these masks are append-only in
|
||||
* practice: you type or paste a number left to right. It avoids the caret
|
||||
* jumping to position zero after every keystroke.
|
||||
*/
|
||||
function liveFormat(input: HTMLInputElement, format: (v: string) => string): void {
|
||||
input.addEventListener('input', () => {
|
||||
const atEnd = input.selectionStart === input.value.length;
|
||||
const formatted = format(input.value);
|
||||
if (formatted === input.value) return;
|
||||
input.value = formatted;
|
||||
if (atEnd) input.setSelectionRange(formatted.length, formatted.length);
|
||||
});
|
||||
}
|
||||
|
||||
liveFormat(fields.ra, formatRa);
|
||||
liveFormat(fields.dn, formatDate);
|
||||
liveFormat(fields.cpf, formatCpf);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const config = await loadConfig();
|
||||
fields.url.value = config.url;
|
||||
fields.ra.value = config.ra;
|
||||
fields.dn.value = config.dn;
|
||||
fields.cpf.value = config.cpf;
|
||||
autoSubmit.checked = config.autoSubmit;
|
||||
}
|
||||
|
||||
/** Mark the offending inputs and return the first complaint, if any. */
|
||||
function validate(): string | null {
|
||||
for (const input of Object.values(fields)) input.classList.remove('invalid');
|
||||
|
||||
const origin = toOrigin(fields.url.value);
|
||||
if (origin === null) {
|
||||
fields.url.classList.add('invalid');
|
||||
return 'Endereço inválido.';
|
||||
}
|
||||
if (!isCompleteRa(fields.ra.value)) {
|
||||
fields.ra.classList.add('invalid');
|
||||
return 'Informe a matrícula.';
|
||||
}
|
||||
if (!isCompleteDate(fields.dn.value)) {
|
||||
fields.dn.classList.add('invalid');
|
||||
return 'Data de nascimento incompleta ou inexistente.';
|
||||
}
|
||||
if (!isCompleteCpf(fields.cpf.value)) {
|
||||
fields.cpf.classList.add('invalid');
|
||||
return 'CPF incompleto.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const complaint = validate();
|
||||
if (complaint) {
|
||||
setStatus(complaint, true);
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await saveConfig({
|
||||
url: fields.url.value.trim(),
|
||||
ra: fields.ra.value,
|
||||
dn: fields.dn.value,
|
||||
cpf: fields.cpf.value,
|
||||
autoSubmit: autoSubmit.checked,
|
||||
});
|
||||
// Saving is how you correct a typo, so it also clears the hourly limit
|
||||
// and the logout cooldown: the next visit is allowed to try again.
|
||||
await resetState();
|
||||
setStatus('Salvo.');
|
||||
})();
|
||||
});
|
||||
|
||||
el<HTMLButtonElement>('clear').addEventListener('click', () => {
|
||||
void (async () => {
|
||||
await saveConfig({ ...EMPTY_CONFIG });
|
||||
await resetState();
|
||||
await load();
|
||||
setStatus('Dados apagados.');
|
||||
})();
|
||||
});
|
||||
|
||||
void load();
|
||||
22
src/popup.html
Normal file
22
src/popup.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
|
||||
* Licensed under the GNU General Public License v3.0 or later.
|
||||
* See https://www.gnu.org/licenses/ and the LICENSE file.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>logsdu</title>
|
||||
<link rel="stylesheet" href="ui.css" />
|
||||
</head>
|
||||
<body class="popup">
|
||||
<p class="state" id="state">...</p>
|
||||
<p class="hint" id="detail"></p>
|
||||
<div class="row">
|
||||
<button id="fill" type="button" disabled>Preencher agora</button>
|
||||
<button id="options" type="button" class="ghost">Configurar</button>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
79
src/popup.ts
Normal file
79
src/popup.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// *************************************************************************
|
||||
// * (C)opyright 2026 by Ruben Carlo Benante *
|
||||
// * *
|
||||
// * This program is free software; you can redistribute it and/or modify *
|
||||
// * it under the terms of the GNU General Public License as published by *
|
||||
// * the Free Software Foundation, either version 3 of the License, or *
|
||||
// * (at your option) any later version. *
|
||||
// * *
|
||||
// * This program is distributed in the hope that it will be useful, *
|
||||
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
// * GNU General Public License for more details. *
|
||||
// * *
|
||||
// * You should have received a copy of the GNU General Public License *
|
||||
// * along with this program. If not, see http://www.gnu.org/licenses/. *
|
||||
// * *
|
||||
// * Contact author at: *
|
||||
// * Ruben Carlo Benante *
|
||||
// * rcb@beco.cc *
|
||||
// *************************************************************************
|
||||
|
||||
// The manual escape hatch: fill (and optionally submit) on demand, for when
|
||||
// the automatic submit is switched off, rate limited, or suppressed after a
|
||||
// logout.
|
||||
|
||||
import { isConfigured, loadConfig, matchesSite } from './config';
|
||||
|
||||
const state = document.getElementById('state') as HTMLParagraphElement;
|
||||
const detail = document.getElementById('detail') as HTMLParagraphElement;
|
||||
const fillButton = document.getElementById('fill') as HTMLButtonElement;
|
||||
const optionsButton = document.getElementById('options') as HTMLButtonElement;
|
||||
|
||||
optionsButton.addEventListener('click', () => {
|
||||
void chrome.runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
async function activeTabOnSite(): Promise<number | null> {
|
||||
const config = await loadConfig();
|
||||
if (!isConfigured(config)) return null;
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.id || !tab.url) return null;
|
||||
return matchesSite(config, tab.url) ? tab.id : null;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const config = await loadConfig();
|
||||
if (!isConfigured(config)) {
|
||||
state.textContent = 'Não configurado';
|
||||
detail.textContent = 'Informe o endereço do portal e os três dados de acesso.';
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = await activeTabOnSite();
|
||||
if (tabId === null) {
|
||||
state.textContent = 'Configurado';
|
||||
detail.textContent = 'Esta aba não é o portal configurado.';
|
||||
return;
|
||||
}
|
||||
|
||||
state.textContent = 'Pronto';
|
||||
detail.textContent = config.autoSubmit
|
||||
? 'Login automático ligado.'
|
||||
: 'Login automático desligado: preencha e clique em Entrar.';
|
||||
fillButton.disabled = false;
|
||||
}
|
||||
|
||||
fillButton.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
const tabId = await activeTabOnSite();
|
||||
if (tabId === null) return;
|
||||
fillButton.disabled = true;
|
||||
// Fill only. Pressing "Entrar" stays with the user here, which is the
|
||||
// point of a manual button.
|
||||
await chrome.tabs.sendMessage(tabId, { type: 'fill', submit: false });
|
||||
window.close();
|
||||
})();
|
||||
});
|
||||
|
||||
void refresh();
|
||||
149
src/ui.css
Normal file
149
src/ui.css
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
|
||||
* Licensed under the GNU General Public License v3.0 or later.
|
||||
* See https://www.gnu.org/licenses/ and the LICENSE file.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1b1b1b;
|
||||
--muted: #5c5c5c;
|
||||
--line: #d6d6d6;
|
||||
--accent: #2f6f4e;
|
||||
--field: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1e1e1e;
|
||||
--fg: #ededed;
|
||||
--muted: #a8a8a8;
|
||||
--line: #3d3d3d;
|
||||
--accent: #6cc294;
|
||||
--field: #2a2a2a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 15px/1.5 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.popup {
|
||||
width: 20rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 2rem 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 1rem 0 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--field);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type='text']:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input.invalid {
|
||||
border-color: #c0392b;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.check input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--line);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#status {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#status.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.popup p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.popup .state {
|
||||
font-weight: 600;
|
||||
}
|
||||
Reference in New Issue
Block a user