Compare commits

...

5 Commits

Author SHA1 Message Date
Jose
082e070aea fix: handle undefined chrome handling in api initialization 2026-03-18 13:57:29 +01:00
Jose
edf404542b bump: update version to 4.1 in manifest.json 2026-03-18 13:48:17 +01:00
Jose
3176af094e fix: background.js, remove: unnecessary permissions 2026-03-18 13:46:03 +01:00
Jose
e8a14e7e69 add: background.js for automatic code input 2026-03-18 12:33:21 +01:00
Jose
becc4f9445 bump: 4.0 - new react frontend 2026-03-15 17:27:16 +01:00
26 changed files with 4442 additions and 262 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="es" class="justify-content-center">
<head>
<meta charset="UTF-8">
<title>Autofill</title>
<link rel="stylesheet" href="./css/styles.css">
<link href="./css/bootstrap.min.css" rel="stylesheet">
<script src="./scripts/bootstrap.bundle.min.js"></script>
<script src="./scripts/bootstrap.min.js"></script>
</head>
<body class="bg-dark border-us p-4">
<div class="bg-dark container justify-content-center row text-white p-0 m-0">
<h4 class="display-6 mb-1 p-0 text-center">US 2FA Autofill</h4>
<small class="mb-4 p-0 text-center">Para obtener tu secret ve <a href="https://2fa.us.es/a2f/otp/bind-device" target="_blank">aquí</a><br>y dale a 'Mostrar' -> 'Mostrar parámetros de configuración'</small>
<input class="mb-2" id="2fa-input" type="text" name="secret" autocomplete="off" placeholder="Introduce tu secret SHA-1">
<p class="item2fa m-0 col-sm-7 mb-2 p-0 m-0 text-center display" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Copiar" id="codigo2FA">000000</p>
<div class="counter bg-us mt-2 mb-4" id="counter">Reinicio en: 30s</div>
<div class="counter bg-us text-white" id="reset">Restablecer secret</div>
</div>
<script src="./scripts/scripts.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,57 +0,0 @@
html {
width: 400px;
max-width: 400px;
}
.bg-us {
background-color: #be0f2e;
}
.border-us {
border: 3px solid #be0f2e;
}
.copy {
font-size: 36px;
text-align: center;
width: 36px;
height: 36px;
}
.counter {
text-align: center;
background-color: #be0f2e;
font-size: 24px;
color: #ffffff;
border-radius: px;
}
input {
width: 100%;
margin: 8px 0;
padding: 6px 12px 6px 12px;
display: inline-block;
border: 1px solid #be0f2e;
border-radius: 4px;
background-color: #111;
margin-top: 20px;
caret-color: white;
color: white;
box-sizing: border-box;
}
#codigo2FA {
font-size: 48px;
padding: 0%;
}
#codigo2FA:hover {
cursor: pointer;
text-decoration: underline;
font-weight: bold;
}
#reset:hover {
cursor: pointer;
background-color: #940f27;
}

29
eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

11
index.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>US 2FA Autofill</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "src/background.js", "src/content.js"]
}

View File

@@ -1,30 +0,0 @@
{
"manifest_version": 3,
"name": "US 2FA Autofill",
"description": "Rellena el código de 2FA en los sitios de la US",
"version": "3.0",
"icons": {
"16": "images/USAutofill_16.png",
"128": "images/USAutofill_128.png"
},
"action": {
"default_popup": "autofill.html",
"default_icon": "images/USAutofill_128.png"
},
"permissions": [
"activeTab",
"tabs",
"scripting",
"storage"
],
"content_scripts": [
{
"matches": [
"https://sso.us.es/*"
],
"js": [
"scripts/scripts.js"
]
}
]
}

3645
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "us-2fa-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"bootstrap": "^5.3.8",
"otpauth": "^9.5.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vite-plugin-clean": "^2.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 536 B

41
public/manifest.json Normal file
View File

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "US 2FA Autofill",
"version": "4.1",
"description": "Rellena automáticamente el código 2FA en la Universidad de Sevilla.",
"permissions": [
"storage"
],
"background": {
"service_worker": "scripts/background.js",
"type": "module",
"scripts": ["scripts/background.js"]
},
"action": {
"default_popup": "index.html"
},
"icons": {
"128": "images/USAutofill_128.png"
},
"content_scripts": [
{
"matches": ["*://sso.us.es/*"],
"js": ["scripts/content.js"],
"run_at": "document_idle"
}
],
"host_permissions": [
"https://sso.us.es/*"
],
"browser_specific_settings": {
"gecko": {
"id": "{552d76df-199a-4058-a8ef-77478cad362f}",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": [
"none"
]
}
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

160
src/App.jsx Normal file
View File

@@ -0,0 +1,160 @@
import { useState, useEffect } from "react";
import * as OTPAuth from 'otpauth';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { ThemeToggle } from "./components/ThemeToggle";
// eslint-disable-next-line no-undef
const api = typeof browser !== "undefined" ? browser : (typeof chrome !== "undefined" ? chrome : null);
const App = () => {
const [secret, setSecret] = useState('');
const [token, setToken] = useState('000000');
const [remaining, setRemaining] = useState(30);
useEffect(() => {
if (api?.storage?.local) {
api.storage.local.get(['shasecret'], (result) => {
if (result.shasecret) {
setSecret(result.shasecret);
}
});
}
}, []);
useEffect(() => {
if (!secret) return;
const generateToken = () => {
try {
let totp = new OTPAuth.TOTP({
issuer: 'US',
label: '2FA',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret.trim().toUpperCase()),
});
const newToken = totp.generate()
setToken(newToken);
if (api?.tabs?.query) {
api.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.url?.includes("sso.us.es")) {
api.tabs.sendMessage(tabs[0].id, {
action: "autofill",
code: newToken
}).catch(() => {});
}
});
} else {
console.debug("DevEnv");
}
} catch (err) {
console.error("Invalid secret:", err);
setToken("ERR!");
}
};
generateToken();
const timer = setInterval(() => {
const now = Date.now() / 1000;
const secondsInCycle = now % 30;
const remainingTime = 30 - secondsInCycle;
setRemaining(remainingTime);
if (remainingTime > 29.9) generateToken();
}, 41); // ~41.66ms = 24fps
return () => clearInterval(timer);
}, [secret]);
const handleSaveSecret = (e) => {
if (e.key === 'Enter') {
const val = e.target.value.trim();
if (api?.storage?.local) {
api.storage.local.set({ 'shasecret': val }, () => {
setSecret(val);
console.log("Secret stored");
});
}
e.target.value = '';
}
};
const resetSecret = () => {
if (api?.storage?.local) {
api.storage.local.remove(['shasecret'], () => {
setSecret('');
setToken('000000');
});
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(token);
};
return (
<div className="container-app">
<div className="top-actions">
{!secret ? (
<div className="tooltip-container left">
<FontAwesomeIcon icon={faCircleInfo} className="info-icon" />
<div className="tooltip-text">
Para obtener tu secret ve <a href="https://2fa.us.es/a2f/otp/bind-device" target="_blank">aquí</a>
&nbsp;y dale a 'Mostrar' -&gt; 'Mostrar parámetros de configuración'
</div>
</div>
) : (
<div />
)}
<ThemeToggle className="right" />
</div>
{!secret && (
<div className="header-wrapper">
<h3>US 2FA Autofill</h3>
</div>
)}
{!secret ? (
<div className="setup-view">
<span className="info-text">
Mete tu secret SHA-1 de la US y pulsa Enter
</span>
<input
type="text"
onKeyUp={handleSaveSecret}
placeholder="Introduce secret..."
/>
</div>
) : (
<div className="token-view">
<div
id="codigo2FA"
onClick={copyToClipboard}
style={{
"--progress-smooth": `${(remaining / 30) * 100}%`
}}
>
{token}
</div>
<button className="btn-reset" onClick={resetSecret}>
Cambiar Secret
</button>
</div>
)}
<footer className="app-footer">
<small>Dev'd with &lt;3 by <a className="text-decoration-none" target="_blank" href="https://jose.miarma.net"><strong>Gallardo7761</strong></a></small>
</footer>
</div>
);
}
export default App;

23
src/background.js Normal file
View File

@@ -0,0 +1,23 @@
import * as OTPAuth from 'otpauth';
// eslint-disable-next-line no-undef
const api = typeof browser !== "undefined" ? browser : chrome;
api.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request.action === "GENERATE_TOKEN") {
api.storage.local.get(['shasecret'], (data) => {
if (data.shasecret) {
const totp = new OTPAuth.TOTP({
issuer: 'US',
label: '2FA',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(data.shasecret.trim().toUpperCase()),
});
sendResponse({ token: totp.generate() });
}
});
return true;
}
});

View File

@@ -0,0 +1,20 @@
import { useTheme } from "@/hooks/useTheme";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import '@/css/ThemeToggle.css';
const ThemeToggle = ({className}) => {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className={`theme-btn m-0 p-0 ` + className}
title={theme === "light" ? "flashbang!" : "go to sleep"}
>
<FontAwesomeIcon icon={theme === "light" ? faMoon : faSun} />
</button>
);
};
export {ThemeToggle};

66
src/content.js Normal file
View File

@@ -0,0 +1,66 @@
(async function() {
// eslint-disable-next-line no-undef
const api = typeof browser !== "undefined" ? browser : chrome;
const fillAndSubmit = (code) => {
const input = document.getElementById("input2factor");
const button = document.getElementById("notification_2factor_button_ok");
const errorMsg = document.querySelector(".ui-state-error");
const isErrorVisible = errorMsg && errorMsg.style.display !== "none";
if (!input || input.value.length > 0 || isErrorVisible) {
return;
}
console.log("Autofill");
input.focus();
input.value = code;
['input', 'change', 'keyup', 'keydown'].forEach(evt => {
input.dispatchEvent(new Event(evt, { bubbles: true }));
});
setTimeout(() => {
if (button) {
console.log("Clicking 'Aceptar'");
const clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true
});
button.dispatchEvent(clickEvent);
}
}, 400);
};
const tryAutofill = async () => {
const data = await api.storage.local.get(['shasecret']);
if (data.shasecret) {
api.runtime.sendMessage({ action: "GENERATE_TOKEN" }, response => {
if (response?.token) {
fillAndSubmit(response.token);
}
});
}
};
const observer = new MutationObserver(() => {
if (document.getElementById("input2factor")) {
tryAutofill();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
api.runtime.onMessage.addListener((request) => {
if (request.action === "autofill") {
fillAndSubmit(request.code);
}
});
tryAutofill();
})();

View File

@@ -0,0 +1,31 @@
import { createContext, useEffect, useState } from "react";
export const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
return (
localStorage.getItem("theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
);
});
useEffect(() => {
const root = document.documentElement;
document.body.classList.remove("light", "dark");
document.body.classList.add(theme);
root.classList.remove("light", "dark");
root.classList.add(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};

17
src/css/ThemeToggle.css Normal file
View File

@@ -0,0 +1,17 @@
.theme-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.2rem;
cursor: pointer;
padding: 5px;
transition: color 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover {
color: var(--primary);
transform: rotate(15deg); /* Un detallito con arte */
}

277
src/css/index.css Normal file
View File

@@ -0,0 +1,277 @@
/* VARIABLES */
:root {
--primary: #be0f2e;
--primary-hover: #940f27;
--transition-speed: 0.3s;
}
:root.light {
--bg-body: #f8f9fa;
--card-bg: #ffffff;
--text-main: #212529;
--text-muted: #6c757d;
--input-bg: #ffffff;
}
:root.dark {
--bg-body: #121212;
--card-bg: #1e1e1e;
--text-main: #e9ecef;
--text-muted: #adb5bd;
--input-bg: #2d2d2d;
}
/* BASE */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
width: 360px;
margin: 0px;
padding: 0px;
}
body {
color: var(--text-main);
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
transition: all var(--transition-speed) ease;
display: flex;
justify-content: center;
padding: 0px;
margin: 0px;
}
/* LAYOUT */
.container-app {
width: 360px;
position: relative;
background: var(--card-bg);
padding: 30px 25px 15px 25px;
border: 1px solid rgba(190, 15, 46, 0.2);
}
.top-actions {
position: absolute;
top: 15px;
left: 0;
right: 0;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
pointer-events: none;
}
.top-actions > * {
pointer-events: auto;
}
.header-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
}
/* TIPOGRAFÍA */
h3, .header-wrapper h3 {
font-weight: 700;
text-align: center;
margin: 0;
margin-bottom: 10px;
color: var(--primary);
line-height: 1;
}
.token-header {
text-align: center;
font-size: 1.1rem;
color: var(--text-muted);
margin-bottom: -10px;
font-weight: 500;
}
.info-text {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
line-height: 1.4;
margin-bottom: 5px;
}
.info-text a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.info-text a:hover {
text-decoration: underline;
}
.muted {
color: var(--text-muted);
}
/* COMPONENTES */
input {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 8px;
background: var(--input-bg);
color: var(--text-main);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
}
:root.dark input {
border-color: #444;
}
input:focus {
border-color: var(--primary);
}
#codigo2FA {
font-family: 'Courier New', Courier, monospace;
font-size: 3.5rem;
font-weight: 800;
text-align: center;
padding: 10px 0;
margin: 5px 0;
letter-spacing: 5px;
cursor: pointer;
user-select: none;
display: inline-block;
width: 100%;
background: linear-gradient(
to right,
var(--primary) calc(var(--progress-smooth) * 0.7 + 15%),
var(--text-muted) calc(var(--progress-smooth) * 0.7 + 15%)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
transition: all 0.1s linear;
}
#codigo2FA:hover {
transform: scale(1.05);
transition: 0.2s;
filter: brightness(1.2);
}
.btn-reset {
background: transparent;
color: var(--text-muted);
border: none;
width: 100%;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.btn-reset::before {
content: '[';
}
.btn-reset::after {
content: ']';
}
.btn-reset:hover {
color: var(--primary);
}
/* TOOLTIPS */
.tooltip-container {
position: relative;
display: flex;
align-items: center;
}
.info-icon {
color: var(--text-muted);
font-size: 1.1rem;
transition: color 0.2s;
}
.tooltip-text {
visibility: hidden;
width: 280px;
background-color: var(--card-bg);
color: var(--text-main);
text-align: center;
border-radius: 8px;
padding: 12px;
position: absolute;
z-index: 9999;
top: 140%;
right: -10px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
border: 1.5px solid var(--primary);
font-size: 0.85rem;
line-height: 1.4;
pointer-events: auto;
word-wrap: break-word;
}
.tooltip-text::after {
content: "";
position: absolute;
bottom: 100%;
right: 15px;
border-width: 7px;
border-style: solid;
border-color: transparent transparent var(--primary) transparent;
}
.top-actions .left .tooltip-text {
left: -10px;
right: auto;
}
.top-actions .left .tooltip-text::after {
left: 15px;
right: auto;
}
.tooltip-container:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
/* FOOTER */
.app-footer {
margin-top: 20px;
text-align: center;
opacity: 0.6;
transition: opacity 0.3s;
}
.app-footer:hover {
opacity: 1;
}
.app-footer small {
font-size: 0.8rem;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.app-footer strong {
color: var(--primary);
}

10
src/hooks/useTheme.js Normal file
View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { ThemeContext } from "../context/ThemeContext";
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme debe usarse dentro de un <ThemeProvider>");
}
return context;
};

15
src/main.jsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/css/index.css'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import App from '@/App.jsx'
import { ThemeProvider } from '@/context/ThemeContext'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)

31
vite.config.js Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
content: resolve(__dirname, 'src/content.js'),
background: resolve(__dirname, 'src/background.js'),
},
output: {
format: 'es',
entryFileNames: (chunkInfo) => {
if (chunkInfo.name === 'content' || chunkInfo.name === 'background') {
return 'scripts/[name].js';
}
return 'assets/[name]-[hash].js';
},
manualChunks: undefined,
},
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
});