4 Commits

20 changed files with 1476 additions and 11 deletions

42
.gitignore vendored Normal file
View 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

84
Makefile Normal file
View File

@@ -0,0 +1,84 @@
# **************************************************************************
# * (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 test # run the unit tests
# make xpi # build, then package build/ as logsdu-<version>.xpi
# make clean # remove build/ and the package
#
# Dependencies are installed with pnpm, never npm:
# corepack pnpm install
#
# Permanent install (Firefox ESR, Developer Edition or Nightly):
# set xpinstall.signatures.required=false in about:config, then
# about:addons -> gear -> Install Add-on From File -> pick the .xpi
#
# Release Firefox refuses unsigned add-ons whatever that pref says. There the
# same .xpi has to go through addons.mozilla.org as an unlisted add-on first,
# which signs it automatically without publishing or reviewing it.
#
# Throwaway install for development: about:debugging -> This Firefox ->
# Load Temporary Add-on -> pick build/manifest.json (dropped on restart).
EXT_ID := logsdu
VERSION := $(shell node -p "require('./package.json').version")
XPI := $(EXT_ID)-$(VERSION).xpi
.PHONY: all build test xpi clean check-deps
all: build
# Unit tests for the pure logic: the input formatters and the decision that
# says whether a page load may press "Entrar". Run straight through Node's
# built-in runner and type stripping, so there is no test framework to install.
test:
node --test "src/**/*.test.ts"
# 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
# An .xpi is just a zip of the extension directory, with the manifest at the
# top level rather than inside a wrapper folder. The same file installs
# directly on ESR and uploads to AMO for signing.
xpi: build
rm -f $(XPI)
cd build && zip -qr ../$(XPI) .
@echo "Package: $(XPI)"
@echo "Install: about:addons -> gear -> Install Add-on From File"
@echo "Needs xpinstall.signatures.required=false on ESR/Developer/Nightly."
clean:
rm -rf build
rm -f $(EXT_ID)-*.xpi $(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; \
}

180
README.md Normal file
View File

@@ -0,0 +1,180 @@
# logsdu
A Firefox extension that logs you into an academic portal whose sign-in form is
three fields -- registration number, birth date and national ID -- rather than
the usual user and password.
That shape defeats password managers. The form is marked `autocomplete="off"`,
none of the three inputs is a `password` field, and Bitwarden, Firefox and
Chrome all decline to remember it. Since the values never change and the
institution does not let you pick a different login method, the only option
left is copying three values by hand, every time.
logsdu stores them once and fills them in. By default it also presses "Entrar",
so the normal case is zero clicks.
## Install
Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below).
Node 22+ ships `corepack`, so pnpm does not have to be installed globally.
```
corepack pnpm install # first time, or after a dependency change
make xpi # typecheck, bundle, and package logsdu-<version>.xpi
```
Then install it permanently. Firefox will not load an unsigned add-on unless
you tell it to, and only the ESR, Developer Edition and Nightly builds accept
being told:
1. Open `about:config`, accept the warning
2. Set `xpinstall.signatures.required` to **false**
3. Open `about:addons`, click the **gear** icon, choose **Install Add-on From
File**, and pick `logsdu-<version>.xpi`
It survives restarts. On **release** Firefox that pref is ignored -- see
"Release Firefox" below.
Finally, open the extension's options page, fill in the four values, and save.
## The four values
| Field | Example | Notes |
| --- | --- | --- |
| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored |
| Registration number | `2000101010` | Digits only |
| Birth date | `01/01/2000` | Reformatted as you type |
| National ID | `000.000.000-00` | Reformatted as you type |
Paste raw digits if you like -- the options page inserts the separators, because
the portal's input masks expect the values in exactly that shape.
**The portal address is configuration, not code.** No institution is named
anywhere in the extension: not in the manifest, not in the source, not in the
build output. Be clear about what that does and does not buy you. It stops
someone who reads the extension from learning which portal it is for. It does
not hide anything from someone who can read the extension's storage -- and that
is the same access that would expose your national ID and birth date anyway.
## How it behaves
- **Fills and submits** on the login page, with no interaction.
- **At most one automatic submit per hour.** After an attempt, the extension
drops back to filling only, so a wrong value cannot resubmit itself on every
page load and lock you out. Correct the values and save; saving clears the
timer, so the next visit tries again immediately.
- **Logging out keeps you logged out.** Clicking the portal's logout control
suppresses the automatic submit for five minutes -- otherwise the logout
redirect lands on the login page and you would be signed straight back in.
- When it fills without submitting, a small note at the bottom of the page says
why. Click it to dismiss.
- The toolbar popup has a **Preencher agora** button that fills without
submitting, for when you want to check the values before sending them.
- Automatic submission can be turned off entirely in the options.
## How the values are stored
In `storage.local`: private to this browser profile, never synced, never sent
anywhere. That is the same protection a browser-saved password gets, and it has
the same limit -- anyone with your unlocked account can read it. If that is not
good enough for your threat model, this extension is the wrong tool.
## Release Firefox
`xpinstall.signatures.required` only works on ESR, Developer Edition and
Nightly. Release Firefox ignores it and refuses unsigned add-ons outright, so
there the same `.xpi` has to be signed first: upload it to
[addons.mozilla.org](https://addons.mozilla.org) as an **unlisted** add-on.
Signing is automated -- nothing is published publicly or reviewed by hand -- and
you install the signed file it hands back.
The `browser_specific_settings.gecko.id` in the manifest is what gives the
add-on a stable identity across both routes, so settings survive an upgrade
from one to the other.
## Chrome
Not yet. The source deliberately avoids anything Firefox-specific: it uses the
`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build
should be a manifest question rather than a rewrite. It has not been tried, so
do not assume it works.
## Development
```
corepack pnpm install
make test # unit tests
make # typecheck and bundle into build/
corepack pnpm run dev # rebuild on change
make clean
```
While iterating, reinstalling an `.xpi` for every edit is tedious. Load the
unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load
Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after
each rebuild. That copy disappears on restart, which is the point -- it is for
development, not for daily use.
`corepack pnpm run dev` watches and rebuilds, static files included, but
Firefox still needs the Reload click to pick anything up.
### Layout
| Path | Role |
| --- | --- |
| `src/manifest.json` | MV3 manifest. Names no site |
| `src/content.ts` | Isolated world. Decides whether to act, then delegates |
| `src/injected.ts` | Page world. Does the actual filling and clicking |
| `src/portal.ts` | Every selector the extension knows about the form |
| `src/config.ts` | Stored values, the rate limit and the logout cooldown |
| `src/format.ts` | Input normalisers for the three masked fields |
| `src/options.*`, `src/popup.*` | The two bits of UI |
### Why two scripts instead of one
The portal drives its inputs with Inputmask, which replaces each element's
`value` property with its own accessor. A content script assigning
`input.value` from the isolated world writes through Xrays to the *native*
setter and skips that accessor: the field looks right on screen, but the mask's
buffer is unchanged, and the page's submit handler reads the stale buffer back
out through jQuery. So the filling happens inside the page, through the page's
own jQuery and Inputmask.
Submitting is a real click on the button, never `form.submit()`. The portal
intercepts the submit event, cancels it, and posts by AJAX with a CSRF token
taken from a meta tag. `form.submit()` would bypass that handler and lose the
token; a click reproduces exactly what a person pressing "Entrar" does.
### Why the content script matches every URL
The address is configured at runtime, so it cannot also be a manifest match
pattern -- putting it there is exactly what would name the institution in the
shipped code. The script therefore loads everywhere and stops immediately
unless the page's origin equals the configured one. Origins are compared whole,
so `portal.example.br.evil.tld` does not match.
The honest cost: the extension holds read access to every page you visit. The
alternative -- registering the content script at runtime with
`scripting.registerContentScripts` and an optional host permission -- avoids
that at the price of a background script and a permission prompt.
### Why pnpm, not npm
**Do not run `npm install` or `npm ci` in this repo.** This project is developed
on **ZFS**, and npm's installer renames many directories in parallel while
hoisting and deduping, which ZFS intermittently fails with:
```
npm error code ENOTEMPTY
npm error syscall rename
```
pnpm hard-links packages from a global content-addressable store and does not
perform that rename dance, so it is unaffected. Only `npm install` / `npm ci`
are the problem -- running *scripts* through npm is fine, since that just spawns
tsc and esbuild. The lockfile is `pnpm-lock.yaml`; there is no
`package-lock.json` and none should be created.
## Licence
GPL-3.0-or-later. See `LICENSE`.

View File

@@ -1 +1 @@
logsdu v0.1
0.1.0

7
icons/logsdu.svg Normal file
View 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

View File

@@ -8,7 +8,9 @@
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "node --test \"src/**/*.test.ts\"",
"test:watch": "node --test --watch \"src/**/*.test.ts\""
},
"license": "GPL-3.0-or-later",
"devDependencies": {

285
pnpm-lock.yaml generated Normal file
View 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: {}

110
src/config.test.ts Normal file
View File

@@ -0,0 +1,110 @@
// *************************************************************************
// * (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 assert from 'node:assert/strict';
import { test } from 'node:test';
import {
LOGOUT_COOLDOWN_MS,
SUBMIT_INTERVAL_MS,
decideSubmit,
isConfigured,
matchesSite,
} from './config.ts';
import type { Config, State } from './config.ts';
const CONFIG: Config = {
url: 'https://portal.example.br/',
ra: '2000101010',
dn: '01/02/1999',
cpf: '123.456.789-01',
autoSubmit: true,
};
const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 };
const NOW = 1_000_000_000_000;
test('isConfigured requires all four values', () => {
assert.equal(isConfigured(CONFIG), true);
assert.equal(isConfigured({ ...CONFIG, url: '' }), false);
assert.equal(isConfigured({ ...CONFIG, url: 'not a url' }), false);
assert.equal(isConfigured({ ...CONFIG, ra: '' }), false);
assert.equal(isConfigured({ ...CONFIG, dn: '' }), false);
assert.equal(isConfigured({ ...CONFIG, cpf: '' }), false);
});
test('matchesSite compares origins, not prefixes', () => {
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true);
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true);
});
test('matchesSite refuses lookalike hosts and scheme downgrades', () => {
// The content script runs on every page, so this guard is the only thing
// standing between the stored CPF and an attacker-chosen page.
assert.equal(matchesSite(CONFIG, 'https://portal.example.br.evil.tld/'), false);
assert.equal(matchesSite(CONFIG, 'https://evil.tld/portal.example.br'), false);
assert.equal(matchesSite(CONFIG, 'http://portal.example.br/'), false);
assert.equal(matchesSite(CONFIG, 'https://sub.portal.example.br/'), false);
assert.equal(matchesSite(CONFIG, 'about:blank'), false);
});
test('a fresh state submits', () => {
assert.deepEqual(decideSubmit(CONFIG, FRESH, NOW), { submit: true });
});
test('the toggle wins over everything else', () => {
assert.deepEqual(decideSubmit({ ...CONFIG, autoSubmit: false }, FRESH, NOW), {
submit: false,
reason: 'disabled',
});
});
test('one submit per hour, then autofill only', () => {
const justTried: State = { ...FRESH, lastSubmitAt: NOW - 1000 };
assert.deepEqual(decideSubmit(CONFIG, justTried, NOW), {
submit: false,
reason: 'rate-limited',
});
const anHourAgo: State = { ...FRESH, lastSubmitAt: NOW - SUBMIT_INTERVAL_MS - 1 };
assert.deepEqual(decideSubmit(CONFIG, anHourAgo, NOW), { submit: true });
});
test('a recent logout suppresses the submit, and expires', () => {
const justLoggedOut: State = { ...FRESH, logoutAt: NOW - 1000 };
assert.deepEqual(decideSubmit(CONFIG, justLoggedOut, NOW), {
submit: false,
reason: 'logout',
});
const staleLogout: State = { ...FRESH, logoutAt: NOW - LOGOUT_COOLDOWN_MS - 1 };
assert.deepEqual(decideSubmit(CONFIG, staleLogout, NOW), { submit: true });
});
test('logout is reported ahead of the rate limit', () => {
// Both apply after a login followed by a logout. The logout is the more
// useful explanation to show the user.
const both: State = { lastSubmitAt: NOW - 1000, logoutAt: NOW - 1000 };
assert.deepEqual(decideSubmit(CONFIG, both, NOW), {
submit: false,
reason: 'logout',
});
});

View File

@@ -19,7 +19,7 @@
// * rcb@beco.cc *
// *************************************************************************
import { toOrigin } from './format';
import { toOrigin } from './format.ts';
const CONFIG_KEY = 'config';
const STATE_KEY = 'state';

208
src/content.ts Normal file
View 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.ts';
import type { Config } from './config.ts';
import { EVENT_RESULT, FORM, LOGOUT } from './portal.ts';
import type { FillRequest, FillResult } from './portal.ts';
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();

94
src/format.test.ts Normal file
View File

@@ -0,0 +1,94 @@
// *************************************************************************
// * (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 assert from 'node:assert/strict';
import { test } from 'node:test';
import {
formatCpf,
formatDate,
formatRa,
isCompleteCpf,
isCompleteDate,
toOrigin,
} from './format.ts';
test('formatDate builds dd/mm/yyyy progressively', () => {
assert.equal(formatDate('0'), '0');
assert.equal(formatDate('01'), '01');
assert.equal(formatDate('0102'), '01/02');
assert.equal(formatDate('01021999'), '01/02/1999');
});
test('formatDate is idempotent and tolerates a pasted separator', () => {
assert.equal(formatDate('01/02/1999'), '01/02/1999');
assert.equal(formatDate(formatDate('01021999')), '01/02/1999');
assert.equal(formatDate('1-2-1999'), '12/19/99');
});
test('formatCpf builds 000.000.000-00 progressively', () => {
assert.equal(formatCpf('123'), '123');
assert.equal(formatCpf('123456'), '123.456');
assert.equal(formatCpf('12345678901'), '123.456.789-01');
assert.equal(formatCpf('123.456.789-01'), '123.456.789-01');
});
test('formatters drop overflow instead of growing without bound', () => {
assert.equal(formatCpf('123456789012345'), '123.456.789-01');
assert.equal(formatRa('20001010109999'), '2000101010');
});
test('isCompleteDate rejects impossible calendar dates', () => {
assert.equal(isCompleteDate('01/02/1999'), true);
assert.equal(isCompleteDate('29/02/2000'), true, 'leap year');
assert.equal(isCompleteDate('29/02/1999'), false, 'not a leap year');
assert.equal(isCompleteDate('31/04/1999'), false, 'April has 30 days');
assert.equal(isCompleteDate('00/01/1999'), false);
assert.equal(isCompleteDate('01/13/1999'), false);
assert.equal(isCompleteDate('1/2/1999'), false, 'must be zero padded');
});
test('isCompleteCpf counts digits, not characters', () => {
assert.equal(isCompleteCpf('123.456.789-01'), true);
assert.equal(isCompleteCpf('12345678901'), true);
assert.equal(isCompleteCpf('123.456.789-0'), false);
});
test('toOrigin assumes https and strips path, query and fragment', () => {
assert.equal(toOrigin('https://portal.example.br/'), 'https://portal.example.br');
assert.equal(toOrigin('portal.example.br'), 'https://portal.example.br');
assert.equal(
toOrigin('https://portal.example.br/login?a=1#x'),
'https://portal.example.br',
);
assert.equal(toOrigin(' portal.example.br '), 'https://portal.example.br');
});
test('toOrigin keeps the port, which is part of the origin', () => {
assert.equal(toOrigin('http://localhost:8080/x'), 'http://localhost:8080');
});
test('toOrigin rejects what cannot be a site', () => {
assert.equal(toOrigin(''), null);
assert.equal(toOrigin(' '), null);
assert.equal(toOrigin('javascript:alert(1)'), null);
assert.equal(toOrigin('file:///etc/passwd'), null);
});

View File

@@ -84,7 +84,7 @@ export function isCompleteCpf(value: string): boolean {
/**
* Origin of a user-typed site address, or null if it cannot be parsed.
* Accepts input without a scheme ("saladigital.example.com") by assuming
* Accepts input without a scheme ("portal.example.br") by assuming
* https, which is what someone pasting an address from the URL bar expects.
*/
export function toOrigin(value: string): string | null {

View File

@@ -37,8 +37,8 @@
// before that handler exists would trigger a plain browser form POST without
// the CSRF header, which fails.
import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal';
import type { FillRequest, FillResult } from './portal';
import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts';
import type { FillRequest, FillResult } from './portal.ts';
const POLL_INTERVAL_MS = 100;
const POLL_TIMEOUT_MS = 15000;

View File

@@ -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
View 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
View 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.ts';
import {
formatCpf,
formatDate,
formatRa,
isCompleteCpf,
isCompleteDate,
isCompleteRa,
toOrigin,
} from './format.ts';
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
View 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
View 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.ts';
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
View 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;
}

View File

@@ -8,12 +8,15 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"lib": ["ES2021", "DOM"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}