Publica versión definitiva de Seguimiento de Impuestos GLM

This commit is contained in:
2026-07-31 15:24:26 -04:00
parent 2fad73040e
commit 9b75955a5b
26 changed files with 318 additions and 44 deletions
+2 -28
View File
@@ -1,30 +1,4 @@
# Dependencies
node_modules/ node_modules/
# Build output
dist/
# Local environment
.env .env
.env.* .env.local
!.env.example .env.production
*.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editors and operating system
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
Thumbs.db
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1 -1
View File
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
+9
View File
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
+9
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
+44 -1
View File
@@ -10,6 +10,49 @@
/> />
<meta name="author" content="GomezLee Marketing" /> <meta name="author" content="GomezLee Marketing" />
<meta name="theme-color" content="#4F758B" /> <meta name="theme-color" content="#4F758B" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<script>
(() => {
const navigationKey = "glm-tax-oauth-navigation-v3";
const refreshKey = "__glm_auth_fresh";
const hasOAuthResult = () => {
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
return (
hash.has("access_token") ||
hash.has("refresh_token") ||
hash.has("error") ||
hash.has("error_description")
);
};
const forceFreshDocument = () => {
let pending = false;
try {
pending = Boolean(sessionStorage.getItem(navigationKey));
} catch {
pending = false;
}
if (!pending || !hasOAuthResult()) return;
const url = new URL(window.location.href);
if (url.searchParams.has(refreshKey)) return;
// Este parámetro se agrega DESPUÉS de volver de Supabase. Por eso no
// forma parte de redirect_to ni requiere una URL nueva en EasyPanel.
url.searchParams.set(refreshKey, String(Date.now()));
window.location.replace(url.toString());
};
forceFreshDocument();
window.addEventListener("pageshow", (event) => {
if (event.persisted) forceFreshDocument();
});
})();
</script>
<meta property="og:title" content="Seguimiento de Impuestos GLM" /> <meta property="og:title" content="Seguimiento de Impuestos GLM" />
<meta <meta
property="og:description" property="og:description"
@@ -21,7 +64,7 @@
<link rel="icon" type="image/png" sizes="192x192" href="/calendario-impuestos/favicon-192.png?v=glm-2026" /> <link rel="icon" type="image/png" sizes="192x192" href="/calendario-impuestos/favicon-192.png?v=glm-2026" />
<link rel="shortcut icon" href="/calendario-impuestos/favicon.ico?v=glm-2026" /> <link rel="shortcut icon" href="/calendario-impuestos/favicon.ico?v=glm-2026" />
<link rel="apple-touch-icon" sizes="180x180" href="/calendario-impuestos/apple-touch-icon.png?v=glm-2026" /> <link rel="apple-touch-icon" sizes="180x180" href="/calendario-impuestos/apple-touch-icon.png?v=glm-2026" />
<script type="module" crossorigin src="/calendario-impuestos/assets/index-3IoCMmqv.js"></script> <script type="module" crossorigin src="/calendario-impuestos/assets/index-BsCJFT61.js"></script>
<link rel="stylesheet" crossorigin href="/calendario-impuestos/assets/index-DrJGHNJG.css"> <link rel="stylesheet" crossorigin href="/calendario-impuestos/assets/index-DrJGHNJG.css">
</head> </head>
<body> <body>
+43
View File
@@ -10,6 +10,49 @@
/> />
<meta name="author" content="GomezLee Marketing" /> <meta name="author" content="GomezLee Marketing" />
<meta name="theme-color" content="#4F758B" /> <meta name="theme-color" content="#4F758B" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<script>
(() => {
const navigationKey = "glm-tax-oauth-navigation-v3";
const refreshKey = "__glm_auth_fresh";
const hasOAuthResult = () => {
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
return (
hash.has("access_token") ||
hash.has("refresh_token") ||
hash.has("error") ||
hash.has("error_description")
);
};
const forceFreshDocument = () => {
let pending = false;
try {
pending = Boolean(sessionStorage.getItem(navigationKey));
} catch {
pending = false;
}
if (!pending || !hasOAuthResult()) return;
const url = new URL(window.location.href);
if (url.searchParams.has(refreshKey)) return;
// Este parámetro se agrega DESPUÉS de volver de Supabase. Por eso no
// forma parte de redirect_to ni requiere una URL nueva en EasyPanel.
url.searchParams.set(refreshKey, String(Date.now()));
window.location.replace(url.toString());
};
forceFreshDocument();
window.addEventListener("pageshow", (event) => {
if (event.persisted) forceFreshDocument();
});
})();
</script>
<meta property="og:title" content="Seguimiento de Impuestos GLM" /> <meta property="og:title" content="Seguimiento de Impuestos GLM" />
<meta <meta
property="og:description" property="og:description"
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build && node scripts/create-legacy-asset-aliases.mjs",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
+49
View File
@@ -0,0 +1,49 @@
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
const assetsDir = new URL("../dist/assets/", import.meta.url);
const assetsPath = fileURLToPath(assetsDir);
if (!existsSync(assetsPath)) process.exit(0);
mkdirSync(assetsPath, { recursive: true });
const files = readdirSync(assetsPath);
const currentJs = files.find((name) => /^index-[A-Za-z0-9_-]+\.js$/.test(name));
const currentCss = files.find((name) => /^index-[A-Za-z0-9_-]+\.css$/.test(name));
const legacyJs = [
"index-3IoCMmqv.js",
"index-DjmlsRNV.js",
"index-7Fzi8kyG.js",
"index-OAuthRedirectExacto.js",
"index-oauthv3-ed1f63a671.js",
"index-C1g15Gzk.js",
"index-DvnNNw_U.js",
"index-B7i36ZCk.js",
"index-DcLwYapC.js",
"index-k1Oz2sTE.js",
"index-rurDav1q.js",
];
const legacyCss = [
"index-DrJGHNJG.css",
"index-CD8ZsNgz.css",
"index-_4eSo35_.css",
"index-CvdK_cH7.css",
"index-DPuohwR6.css",
"index-DGUCvIOo.css",
"index-C-5g0-Hp.css",
];
function copyAliases(sourceName, aliases) {
if (!sourceName) return;
const source = join(assetsPath, sourceName);
for (const alias of aliases) {
if (alias === basename(source)) continue;
copyFileSync(source, join(assetsPath, alias));
}
}
copyAliases(currentJs, legacyJs);
copyAliases(currentCss, legacyCss);
+4 -2
View File
@@ -1603,7 +1603,8 @@ function ContactManagerModal({
<div className="rounded-xl border border-glm-blue/15 bg-glm-blue-soft/35 p-4"> <div className="rounded-xl border border-glm-blue/15 bg-glm-blue-soft/35 p-4">
<p className="text-sm font-semibold text-glm-blue">Búsqueda automática</p> <p className="text-sm font-semibold text-glm-blue">Búsqueda automática</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground"> <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Escribe el nombre completo. La app consultará BambooHR y propondrá país, área, cargo y correo. Podrás modificar todo antes de guardar. Escribe el nombre completo. La app consultará BambooHR y propondrá país, área, cargo
y correo. Podrás modificar todo antes de guardar.
</p> </p>
</div> </div>
<Field label="Nombre completo"> <Field label="Nombre completo">
@@ -1814,7 +1815,8 @@ function ContactForm({
const invalid = const invalid =
!draft.name.trim() || !draft.name.trim() ||
!draft.countryId || !draft.countryId ||
((draft.emailEnabled || draft.googleChatEnabled || draft.calendarEnabled) && !draft.email.trim()); ((draft.emailEnabled || draft.googleChatEnabled || draft.calendarEnabled) &&
!draft.email.trim());
return ( return (
<form <form
+3 -3
View File
@@ -90,10 +90,10 @@ export default function AuthGate() {
} }
}; };
const handleLogin = () => { const handleLogin = async () => {
setActionPending(true); setActionPending(true);
try { try {
signInWithGoogle(); await signInWithGoogle();
} catch (error) { } catch (error) {
setActionPending(false); setActionPending(false);
setState({ status: "signed-out", error: getErrorMessage(error) }); setState({ status: "signed-out", error: getErrorMessage(error) });
@@ -192,7 +192,7 @@ export default function AuthGate() {
<button <button
type="button" type="button"
onClick={handleLogin} onClick={() => void handleLogin()}
disabled={actionPending} disabled={actionPending}
className="flex w-full items-center justify-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-sm font-semibold text-foreground shadow-sm transition hover:-translate-y-px hover:border-glm-blue/35 hover:shadow-md disabled:cursor-wait disabled:opacity-60" className="flex w-full items-center justify-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-sm font-semibold text-foreground shadow-sm transition hover:-translate-y-px hover:border-glm-blue/35 hover:shadow-md disabled:cursor-wait disabled:opacity-60"
> >
+60 -8
View File
@@ -31,6 +31,8 @@ const anonKey = String(import.meta.env.VITE_SUPABASE_ANON_KEY ?? "").trim();
const supabaseKey = publishableKey || anonKey; const supabaseKey = publishableKey || anonKey;
const SESSION_STORAGE_KEY = "glm-tax-auth-session-v1"; const SESSION_STORAGE_KEY = "glm-tax-auth-session-v1";
const OAUTH_NAVIGATION_KEY = "glm-tax-oauth-navigation-v3";
const OAUTH_FRESH_QUERY_KEY = "__glm_auth_fresh";
const EXPIRY_MARGIN_SECONDS = 60; const EXPIRY_MARGIN_SECONDS = 60;
export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseKey); export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseKey);
@@ -48,22 +50,30 @@ export function getSupabaseConfigurationMessage(): string | null {
} }
export function getOAuthRedirectUrl(): string { export function getOAuthRedirectUrl(): string {
// La URL debe coincidir exactamente con una de las permitidas en Supabase. // Se conserva exactamente la misma URL que ya está permitida en Supabase.
// No se agregan parámetros variables porque Auth descartaría redirect_to y // La actualización del documento se hace después del retorno, desde el
// terminaría enviando al SITE_URL de Supabase. // navegador, por lo que no requiere agregar una URL nueva en EasyPanel.
return new URL(import.meta.env.BASE_URL, window.location.origin).toString(); return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
} }
export function signInWithGoogle(): void { export async function signInWithGoogle(): Promise<void> {
assertConfigured(); assertConfigured();
const redirectUrl = getOAuthRedirectUrl();
markOAuthNavigation();
// Actualiza la entrada HTML en la caché HTTP antes de salir hacia Google.
// Así, el retorno de OAuth no reutiliza un index.html anterior del servidor.
await warmLatestApplicationEntry(redirectUrl);
const params = new URLSearchParams({ const params = new URLSearchParams({
provider: "google", provider: "google",
redirect_to: getOAuthRedirectUrl(), redirect_to: redirectUrl,
prompt: "select_account", prompt: "select_account",
}); });
window.location.assign(`${supabaseUrl}/auth/v1/authorize?${params.toString()}`); // replace evita que el navegador restaure la pantalla previa mediante BFCache.
window.location.replace(`${supabaseUrl}/auth/v1/authorize?${params.toString()}`);
} }
export async function restoreSession(): Promise<SupabaseAuthSession | null> { export async function restoreSession(): Promise<SupabaseAuthSession | null> {
@@ -113,6 +123,7 @@ export async function signOut(session?: SupabaseAuthSession | null): Promise<voi
// El cierre local debe completarse incluso si Supabase no está disponible. // El cierre local debe completarse incluso si Supabase no está disponible.
} finally { } finally {
clearStoredSession(); clearStoredSession();
clearOAuthNavigation();
} }
} }
@@ -121,6 +132,7 @@ async function consumeOAuthCallback(): Promise<SupabaseAuthSession | null> {
const oauthError = hash.get("error_description") ?? hash.get("error"); const oauthError = hash.get("error_description") ?? hash.get("error");
if (oauthError) { if (oauthError) {
clearOAuthNavigation();
cleanOAuthFragment(); cleanOAuthFragment();
throw new Error(oauthError.replace(/\+/g, " ")); throw new Error(oauthError.replace(/\+/g, " "));
} }
@@ -131,7 +143,8 @@ async function consumeOAuthCallback(): Promise<SupabaseAuthSession | null> {
const expiresAt = resolveExpiry(hash.get("expires_at"), hash.get("expires_in")); const expiresAt = resolveExpiry(hash.get("expires_at"), hash.get("expires_in"));
// Retira los tokens de la barra de direcciones antes de hacer más solicitudes. // Retira los tokens y el parámetro temporal de la barra de direcciones.
clearOAuthNavigation();
cleanOAuthFragment(); cleanOAuthFragment();
const user = await fetchUser(accessToken); const user = await fetchUser(accessToken);
@@ -184,6 +197,41 @@ async function fetchUser(accessToken: string): Promise<SupabaseAuthUser> {
return user as SupabaseAuthUser; return user as SupabaseAuthUser;
} }
async function warmLatestApplicationEntry(url: string): Promise<void> {
try {
await fetch(url, {
method: "GET",
cache: "reload",
credentials: "same-origin",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
} catch {
// El login debe continuar aunque la actualización preventiva no responda.
}
}
function markOAuthNavigation(): void {
try {
sessionStorage.setItem(
OAUTH_NAVIGATION_KEY,
JSON.stringify({ startedAt: Date.now(), redirectUrl: getOAuthRedirectUrl() }),
);
} catch {
// Algunos navegadores pueden bloquear sessionStorage; el OAuth sigue funcionando.
}
}
function clearOAuthNavigation(): void {
try {
sessionStorage.removeItem(OAUTH_NAVIGATION_KEY);
} catch {
// No impide cerrar o restaurar la sesión.
}
}
function publicHeaders(): HeadersInit { function publicHeaders(): HeadersInit {
return { return {
apikey: supabaseKey, apikey: supabaseKey,
@@ -233,10 +281,14 @@ function clearStoredSession(): void {
function cleanOAuthFragment(): void { function cleanOAuthFragment(): void {
const cleanUrl = new URL(window.location.href); const cleanUrl = new URL(window.location.href);
cleanUrl.hash = ""; cleanUrl.hash = "";
cleanUrl.searchParams.delete(OAUTH_FRESH_QUERY_KEY);
cleanUrl.searchParams.delete("oauth_return");
const search = cleanUrl.searchParams.toString();
window.history.replaceState( window.history.replaceState(
null, null,
document.title, document.title,
`${cleanUrl.pathname}${cleanUrl.search}`, `${cleanUrl.pathname}${search ? `?${search}` : ""}`,
); );
} }