mantenimento-app 2.2.8 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -34
- package/app.js +558 -102
- package/backend/server.js +346 -23
- package/frontend/public/app.js +558 -102
- package/frontend/public/autologin.html +40 -0
- package/frontend/public/index.html +29 -6
- package/frontend/public/styles.css +78 -5
- package/frontend/public/supabase-config.js +4 -11
- package/package.json +5 -1
- package/scripts/auth-url-check.mjs +166 -0
- package/scripts/create-url-login-token.mjs +52 -0
- package/scripts/manage-donor-users.mjs +229 -0
- package/scripts/sql/grant-donor.sql +22 -0
- package/scripts/sql/revoke-donor.sql +19 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="it">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>AutoLogin Redirect - Mantenimento App</title>
|
|
7
|
+
<meta name="robots" content="noindex,nofollow" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<noscript>JavaScript richiesto per il redirect di autologin.</noscript>
|
|
11
|
+
<script>
|
|
12
|
+
(function () {
|
|
13
|
+
try {
|
|
14
|
+
const current = new URL(window.location.href);
|
|
15
|
+
const query = new URLSearchParams(current.search);
|
|
16
|
+
|
|
17
|
+
if (!query.has('autologin')) {
|
|
18
|
+
query.set('autologin', '1');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if ((!query.has('authToken') || !String(query.get('authToken') || '').trim()) && current.hash) {
|
|
22
|
+
const hashParams = new URLSearchParams(String(current.hash || '').replace(/^#/, ''));
|
|
23
|
+
const hashToken = String(hashParams.get('authToken') || '').trim();
|
|
24
|
+
if (hashToken) {
|
|
25
|
+
query.set('authToken', hashToken);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const target = new URL('./', current);
|
|
30
|
+
target.search = query.toString();
|
|
31
|
+
target.hash = '';
|
|
32
|
+
|
|
33
|
+
window.location.replace(target.toString());
|
|
34
|
+
} catch (_) {
|
|
35
|
+
window.location.replace('./');
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
</script>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
@@ -102,8 +102,8 @@
|
|
|
102
102
|
<button class="top-actions-trigger" type="button" aria-expanded="false" aria-controls="topActionsMenu">Azioni rapide</button>
|
|
103
103
|
<div class="top-actions-menu" id="topActionsMenu" aria-label="Azioni applicazione">
|
|
104
104
|
<button class="btn-secondary" id="btnReset" type="button">Reset valori</button>
|
|
105
|
-
<button class="btn-secondary" id="btnExportJson" type="button">Esporta JSON
|
|
106
|
-
<button class="btn-secondary" id="btnImportJson" type="button">Carica JSON
|
|
105
|
+
<button class="btn-secondary" id="btnExportJson" type="button">Esporta JSON</button>
|
|
106
|
+
<button class="btn-secondary" id="btnImportJson" type="button">Carica JSON</button>
|
|
107
107
|
<button class="btn-secondary" id="btnPdf" type="button">Genera e scarica PDF</button>
|
|
108
108
|
<input id="fileJson" type="file" accept="application/json,.json" style="display:none" />
|
|
109
109
|
</div>
|
|
@@ -364,17 +364,23 @@
|
|
|
364
364
|
</label>
|
|
365
365
|
<input id="primaCasaMutuoImporto" type="number" min="0" step="50" value="0" />
|
|
366
366
|
</div>
|
|
367
|
-
<div class="field">
|
|
367
|
+
<div class="field" style="display: none;">
|
|
368
|
+
<label for="primaCasaValoreLocativo" class="label-row"><span id="lblPrimaCasaValoreLocativo">Casa (valore locativo) ({currency})</span>
|
|
369
|
+
<span class="hint" id="hintPrimaCasaValoreLocativo" title="Valore locativo mensile della casa assegnata, usato per valorizzare il beneficio economico implicito.">i</span>
|
|
370
|
+
</label>
|
|
371
|
+
<input id="primaCasaValoreLocativo" type="number" min="0" step="50" value="1000" />
|
|
372
|
+
</div>
|
|
373
|
+
<div class="field" style="display: none;">
|
|
368
374
|
<label for="primaCasaAssegnataA" class="label-row"><span id="lblPrimaCasaAssegnataA">Casa assegnata a</span>
|
|
369
375
|
<span class="hint" id="hintPrimaCasaAssegnataA" title="Seleziona il coniuge a cui e ceduta la prima casa.">i</span>
|
|
370
376
|
</label>
|
|
371
377
|
<select id="primaCasaAssegnataA">
|
|
372
378
|
<option value="">Nessuna cessione</option>
|
|
373
|
-
<option value="1">Coniuge 1</option>
|
|
379
|
+
<option value="1" selected>Coniuge 1</option>
|
|
374
380
|
<option value="2">Coniuge 2</option>
|
|
375
381
|
</select>
|
|
376
382
|
</div>
|
|
377
|
-
<div class="field">
|
|
383
|
+
<div class="field" style="display: none;">
|
|
378
384
|
<div class="mortgage-split-slider" id="primaCasaMutuoSliderWrap">
|
|
379
385
|
<div class="mortgage-split-side mortgage-split-side-left" id="primaCasaSplitLeft">
|
|
380
386
|
<div class="mortgage-split-name" id="primaCasaSplitLeftName">Coniuge 1</div>
|
|
@@ -596,11 +602,28 @@
|
|
|
596
602
|
<button class="coffee-float-btn" title="Offrimi un caffè!">☕</button>
|
|
597
603
|
</div>
|
|
598
604
|
|
|
605
|
+
<div id="exportModeModal" class="export-mode-modal is-hidden" aria-hidden="true">
|
|
606
|
+
<div class="export-mode-modal-backdrop" data-export-modal-close="1"></div>
|
|
607
|
+
<div class="export-mode-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="exportModeModalTitle">
|
|
608
|
+
<h3 id="exportModeModalTitle">Esportazione JSON</h3>
|
|
609
|
+
<p id="exportModeModalWarning">Per impostazione predefinita il file viene cifrato e puo essere importato solo dallo stesso utente KeyLock.</p>
|
|
610
|
+
<label class="export-mode-checkbox">
|
|
611
|
+
<input type="checkbox" id="chkExportPlainJson" />
|
|
612
|
+
<span id="lblExportPlainJson">Esporta JSON non cifrato (compatibile con altri utenti)</span>
|
|
613
|
+
</label>
|
|
614
|
+
<p id="exportModeModalRisk" class="export-mode-risk">Conferma esplicita richiesta: il file non cifrato puo essere letto da chiunque.</p>
|
|
615
|
+
<div class="export-mode-actions">
|
|
616
|
+
<button type="button" class="btn-secondary" id="btnCancelExportMode">Annulla</button>
|
|
617
|
+
<button type="button" class="btn-secondary" id="btnConfirmExportMode">Conferma export</button>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
|
|
599
622
|
<script src="supabase-config.js"></script>
|
|
600
623
|
<script src="supabase.min.js"></script>
|
|
601
624
|
<script src="fabric.min.js"></script>
|
|
602
625
|
<script src="html2pdf.bundle.min.js"></script>
|
|
603
|
-
<script src="app.js?v=2.
|
|
626
|
+
<script src="app.js?v=2.3.0"></script>
|
|
604
627
|
</body>
|
|
605
628
|
</html>
|
|
606
629
|
|
|
@@ -910,13 +910,14 @@
|
|
|
910
910
|
|
|
911
911
|
.mortgage-split-side {
|
|
912
912
|
border: 1px solid #c6ded8;
|
|
913
|
-
border-radius:
|
|
914
|
-
padding:
|
|
915
|
-
background:
|
|
913
|
+
border-radius: 12px;
|
|
914
|
+
padding: 8px;
|
|
915
|
+
background: linear-gradient(180deg, #ffffff, #f3faf8);
|
|
916
916
|
min-height: 58px;
|
|
917
917
|
display: grid;
|
|
918
918
|
align-content: center;
|
|
919
|
-
gap:
|
|
919
|
+
gap: 3px;
|
|
920
|
+
box-shadow: 0 1px 3px rgba(17, 74, 68, 0.08);
|
|
920
921
|
}
|
|
921
922
|
|
|
922
923
|
.mortgage-split-side-left {
|
|
@@ -945,6 +946,7 @@
|
|
|
945
946
|
.mortgage-split-range-wrap {
|
|
946
947
|
position: relative;
|
|
947
948
|
padding: 14px 0 6px;
|
|
949
|
+
--split-left: 50%;
|
|
948
950
|
}
|
|
949
951
|
|
|
950
952
|
.mortgage-split-range-wrap input[type="range"] {
|
|
@@ -953,9 +955,14 @@
|
|
|
953
955
|
width: 100%;
|
|
954
956
|
height: 8px;
|
|
955
957
|
border-radius: 999px;
|
|
956
|
-
background: linear-gradient(90deg,
|
|
958
|
+
background: linear-gradient(90deg,
|
|
959
|
+
#2b9d8e 0%,
|
|
960
|
+
#6cb9a9 var(--split-left),
|
|
961
|
+
#dfb264 var(--split-left),
|
|
962
|
+
#cb8a2d 100%);
|
|
957
963
|
outline: none;
|
|
958
964
|
margin: 0;
|
|
965
|
+
transition: background 0.16s ease;
|
|
959
966
|
}
|
|
960
967
|
|
|
961
968
|
.mortgage-split-range-wrap input[type="range"]::-webkit-slider-thumb {
|
|
@@ -3338,4 +3345,70 @@
|
|
|
3338
3345
|
border: 1px solid #cfcfcf;
|
|
3339
3346
|
background: #fff;
|
|
3340
3347
|
}
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
.export-mode-modal {
|
|
3351
|
+
position: fixed;
|
|
3352
|
+
inset: 0;
|
|
3353
|
+
z-index: 9999;
|
|
3354
|
+
display: flex;
|
|
3355
|
+
align-items: center;
|
|
3356
|
+
justify-content: center;
|
|
3357
|
+
padding: 16px;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
.export-mode-modal.is-hidden {
|
|
3361
|
+
display: none;
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
.export-mode-modal-backdrop {
|
|
3365
|
+
position: absolute;
|
|
3366
|
+
inset: 0;
|
|
3367
|
+
background: rgba(22, 42, 42, 0.48);
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
.export-mode-modal-dialog {
|
|
3371
|
+
position: relative;
|
|
3372
|
+
width: min(540px, 100%);
|
|
3373
|
+
border-radius: var(--radius);
|
|
3374
|
+
border: 1px solid var(--line);
|
|
3375
|
+
background: var(--panel);
|
|
3376
|
+
box-shadow: var(--shadow);
|
|
3377
|
+
padding: 18px;
|
|
3378
|
+
z-index: 1;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
.export-mode-modal-dialog h3 {
|
|
3382
|
+
margin: 0 0 10px;
|
|
3383
|
+
font-size: 1.05rem;
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
.export-mode-modal-dialog p {
|
|
3387
|
+
margin: 0 0 10px;
|
|
3388
|
+
color: var(--muted);
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
.export-mode-checkbox {
|
|
3392
|
+
display: flex;
|
|
3393
|
+
align-items: flex-start;
|
|
3394
|
+
gap: 8px;
|
|
3395
|
+
margin: 12px 0 8px;
|
|
3396
|
+
color: var(--ink);
|
|
3397
|
+
font-weight: 600;
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
.export-mode-checkbox input {
|
|
3401
|
+
margin-top: 2px;
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
.export-mode-risk {
|
|
3405
|
+
color: var(--warn) !important;
|
|
3406
|
+
font-size: 0.92rem;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
.export-mode-actions {
|
|
3410
|
+
margin-top: 14px;
|
|
3411
|
+
display: flex;
|
|
3412
|
+
justify-content: flex-end;
|
|
3413
|
+
gap: 8px;
|
|
3341
3414
|
}
|
|
@@ -3,17 +3,10 @@
|
|
|
3
3
|
window.KEYLOCK_SUPABASE_URL = "https://xyluwvzuogdsgzyganqp.supabase.co";
|
|
4
4
|
window.KEYLOCK_SUPABASE_ANON_KEY = "sb_publishable_f6_mJK6kgHKTjUMY7V9YJg_i6QCzG3g";
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// https://favagit.github.io/mantenimento-app/?env=prod
|
|
6
|
+
// Default backend used when no ?env / ?apiBase override is provided.
|
|
7
|
+
window.KEYLOCK_CALC_API_BASE = "https://mantenimento-app.onrender.com";
|
|
8
|
+
|
|
10
9
|
window.KEYLOCK_CALC_API_ENVS = {
|
|
11
10
|
dev: "",
|
|
12
|
-
prod: ""
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
// Optional frontend variants (used by ?frontend=dev on the published app).
|
|
16
|
-
// The dev URL can point to the feature branch static preview.
|
|
17
|
-
window.KEYLOCK_FRONTEND_VARIANT_ENVS = {
|
|
18
|
-
dev: "https://raw.githack.com/FaVaGit/mantenimento-app/feature/v2-scenario-lab/frontend/public/index.html"
|
|
11
|
+
prod: "https://mantenimento-app.onrender.com"
|
|
19
12
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mantenimento-app",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Frontend + backend architecture for the mantenimento calculator",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "backend/calculate-model.js",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"dev": "node scripts/dev-server.mjs",
|
|
15
15
|
"dev:watch": "node scripts/build-frontend.mjs --watch",
|
|
16
16
|
"build:frontend": "node scripts/build-frontend.mjs",
|
|
17
|
+
"auth:url-token": "node scripts/create-url-login-token.mjs",
|
|
18
|
+
"auth:url-check": "node scripts/auth-url-check.mjs",
|
|
19
|
+
"donor:grant": "node scripts/manage-donor-users.mjs --mode=grant",
|
|
20
|
+
"donor:revoke": "node scripts/manage-donor-users.mjs --mode=revoke",
|
|
17
21
|
"start": "npm run build:frontend && node backend/server.js"
|
|
18
22
|
},
|
|
19
23
|
"keywords": [
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* auth-url-check.mjs
|
|
4
|
+
*
|
|
5
|
+
* Validates the full autologin URL-login pipeline end-to-end:
|
|
6
|
+
* 1. Calls /api/auth/url-login/start (generates token on server)
|
|
7
|
+
* 2. Calls /api/auth/url-login/exchange (redeems token for session)
|
|
8
|
+
* 3. Reports ok / error with details
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/auth-url-check.mjs \
|
|
12
|
+
* --backendUrl=https://mantenimento-app.onrender.com \
|
|
13
|
+
* --bootstrapKey=<BOOTSTRAP_KEY> \
|
|
14
|
+
* [--sub=favagit] [--verbose]
|
|
15
|
+
*
|
|
16
|
+
* Env vars (used as fallback if CLI args are absent):
|
|
17
|
+
* AUTH_URL_LOGIN_BOOTSTRAP_KEY
|
|
18
|
+
* AUTH_URL_LOGIN_BACKEND_URL (defaults to http://localhost:3001)
|
|
19
|
+
* AUTH_URL_LOGIN_ALLOWED_USER (first in comma-separated list used as default sub)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'fs';
|
|
23
|
+
import { resolve, dirname } from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
|
|
26
|
+
// ── Load .env from project root (best-effort, no hard dep on dotenv) ──────────
|
|
27
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const envPath = resolve(__dir, '..', '.env');
|
|
29
|
+
try {
|
|
30
|
+
const envContent = readFileSync(envPath, 'utf8');
|
|
31
|
+
for (const line of envContent.split('\n')) {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
34
|
+
const eqIdx = trimmed.indexOf('=');
|
|
35
|
+
if (eqIdx < 1) continue;
|
|
36
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
37
|
+
const raw = trimmed.slice(eqIdx + 1).trim();
|
|
38
|
+
const val = raw.replace(/^['"]|['"]$/g, '');
|
|
39
|
+
if (!(key in process.env)) process.env[key] = val;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// .env not found — ok, rely on environment
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
46
|
+
const args = process.argv.slice(2);
|
|
47
|
+
const getArg = (name, fallback = '') => {
|
|
48
|
+
const prefix = `--${name}=`;
|
|
49
|
+
const found = args.find((a) => a.startsWith(prefix));
|
|
50
|
+
return found ? found.slice(prefix.length) : fallback;
|
|
51
|
+
};
|
|
52
|
+
const hasFlag = (name) => args.includes(`--${name}`);
|
|
53
|
+
|
|
54
|
+
const backendUrl = (
|
|
55
|
+
getArg('backendUrl',
|
|
56
|
+
process.env.AUTH_URL_LOGIN_BACKEND_URL || 'http://localhost:3001'
|
|
57
|
+
)
|
|
58
|
+
).replace(/\/$/, '');
|
|
59
|
+
|
|
60
|
+
const bootstrapKey = getArg(
|
|
61
|
+
'bootstrapKey',
|
|
62
|
+
process.env.AUTH_URL_LOGIN_BOOTSTRAP_KEY || ''
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const defaultSub = (process.env.AUTH_URL_LOGIN_ALLOWED_USER || 'favagit')
|
|
66
|
+
.split(',')[0].trim();
|
|
67
|
+
const sub = getArg('sub', defaultSub);
|
|
68
|
+
const verbose = hasFlag('verbose');
|
|
69
|
+
|
|
70
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
71
|
+
const ok = (msg) => console.log(` \x1b[32m✓\x1b[0m ${msg}`);
|
|
72
|
+
const err = (msg) => console.log(` \x1b[31m✗\x1b[0m ${msg}`);
|
|
73
|
+
const inf = (msg) => verbose && console.log(` \x1b[90m→ ${msg}\x1b[0m`);
|
|
74
|
+
|
|
75
|
+
// ── Pre-flight checks ─────────────────────────────────────────────────────────
|
|
76
|
+
console.log('\n\x1b[1mauth:url-check\x1b[0m — autologin pipeline validation');
|
|
77
|
+
console.log('─'.repeat(50));
|
|
78
|
+
console.log(` backend : ${backendUrl}`);
|
|
79
|
+
console.log(` subject : ${sub}`);
|
|
80
|
+
console.log('─'.repeat(50));
|
|
81
|
+
|
|
82
|
+
let exitCode = 0;
|
|
83
|
+
|
|
84
|
+
if (!bootstrapKey) {
|
|
85
|
+
err('AUTH_URL_LOGIN_BOOTSTRAP_KEY is not set (--bootstrapKey= or env var)');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Step 1: generate token via /start ─────────────────────────────────────────
|
|
90
|
+
let loginUrl = '';
|
|
91
|
+
let authToken = '';
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const startUrl = `${backendUrl}/api/auth/url-login/start?k=${encodeURIComponent(bootstrapKey)}&sub=${encodeURIComponent(sub)}&format=json`;
|
|
95
|
+
inf(`GET ${startUrl.replace(bootstrapKey, '<BOOTSTRAP_KEY>')}`);
|
|
96
|
+
|
|
97
|
+
const res = await fetch(startUrl);
|
|
98
|
+
const body = await res.json().catch(() => null);
|
|
99
|
+
|
|
100
|
+
if (!res.ok || !body?.ok) {
|
|
101
|
+
err(`/start returned ${res.status}: ${JSON.stringify(body)}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
loginUrl = body.url || '';
|
|
106
|
+
ok(`/api/auth/url-login/start → ${res.status} ok`);
|
|
107
|
+
inf(`returned url: ${loginUrl}`);
|
|
108
|
+
|
|
109
|
+
// Extract authToken from returned URL
|
|
110
|
+
const urlObj = new URL(loginUrl);
|
|
111
|
+
authToken = urlObj.searchParams.get('authToken') || '';
|
|
112
|
+
if (!authToken) {
|
|
113
|
+
// Try hash fragment
|
|
114
|
+
const hash = urlObj.hash.slice(1);
|
|
115
|
+
authToken = new URLSearchParams(hash).get('authToken') || '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!authToken) {
|
|
119
|
+
err('authToken not found in the URL returned by /start');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
ok(`authToken extracted (${authToken.length} chars)`);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
err(`/start request failed: ${e.message}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Step 2: exchange token via /exchange ─────────────────────────────────────
|
|
129
|
+
try {
|
|
130
|
+
const exchangeUrl = `${backendUrl}/api/auth/url-login/exchange`;
|
|
131
|
+
inf(`POST ${exchangeUrl} token length=${authToken.length}`);
|
|
132
|
+
|
|
133
|
+
const res = await fetch(exchangeUrl, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ token: authToken }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const body = await res.json().catch(() => null);
|
|
140
|
+
|
|
141
|
+
if (!res.ok || !body?.ok) {
|
|
142
|
+
err(`/exchange returned ${res.status}: ${JSON.stringify(body)}`);
|
|
143
|
+
exitCode = 1;
|
|
144
|
+
} else {
|
|
145
|
+
const email = body.session?.user?.email || body.user?.email || '(unknown)';
|
|
146
|
+
ok(`/api/auth/url-login/exchange → ${res.status} ok`);
|
|
147
|
+
ok(`Logged in as: ${email}`);
|
|
148
|
+
if (verbose && body.session?.access_token) {
|
|
149
|
+
inf(`access_token (first 32): ${body.session.access_token.slice(0, 32)}...`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
err(`/exchange request failed: ${e.message}`);
|
|
154
|
+
exitCode = 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Summary ───────────────────────────────────────────────────────────────────
|
|
158
|
+
console.log('─'.repeat(50));
|
|
159
|
+
if (exitCode === 0) {
|
|
160
|
+
console.log('\x1b[32m\x1b[1m PASSED\x1b[0m — autologin pipeline is functional');
|
|
161
|
+
console.log(`\n Share this link (valid for ~120 s):\n ${loginUrl}\n`);
|
|
162
|
+
} else {
|
|
163
|
+
console.log('\x1b[31m\x1b[1m FAILED\x1b[0m — see errors above');
|
|
164
|
+
}
|
|
165
|
+
console.log();
|
|
166
|
+
process.exit(exitCode);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const getArg = (name, fallback = '') => {
|
|
6
|
+
const prefix = `--${name}=`;
|
|
7
|
+
const found = args.find((arg) => arg.startsWith(prefix));
|
|
8
|
+
return found ? found.slice(prefix.length) : fallback;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const secret = String(getArg('secret', process.env.AUTH_URL_LOGIN_SECRET || '')).trim();
|
|
12
|
+
const subject = String(getArg('sub', process.env.AUTH_URL_LOGIN_ALLOWED_USER || 'favagit')).trim().toLowerCase();
|
|
13
|
+
const ttlSecRaw = Number(getArg('ttl', process.env.AUTH_URL_LOGIN_TTL_SEC || '120'));
|
|
14
|
+
const ttlSec = Number.isFinite(ttlSecRaw) ? Math.max(30, Math.min(180, Math.floor(ttlSecRaw))) : 120;
|
|
15
|
+
const baseUrl = String(getArg('baseUrl', 'https://favagit.github.io/mantenimento-app/')).trim();
|
|
16
|
+
|
|
17
|
+
if (!secret) {
|
|
18
|
+
console.error('Missing AUTH_URL_LOGIN_SECRET (env or --secret=...).');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
if (!subject) {
|
|
22
|
+
console.error('Missing subject (--sub=...).');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const toBase64Url = (value) => Buffer.from(value)
|
|
27
|
+
.toString('base64')
|
|
28
|
+
.replace(/\+/g, '-')
|
|
29
|
+
.replace(/\//g, '_')
|
|
30
|
+
.replace(/=+$/g, '');
|
|
31
|
+
|
|
32
|
+
const now = Math.floor(Date.now() / 1000);
|
|
33
|
+
const payload = {
|
|
34
|
+
sub: subject,
|
|
35
|
+
aud: 'url-login',
|
|
36
|
+
iat: now,
|
|
37
|
+
exp: now + ttlSec,
|
|
38
|
+
jti: typeof crypto.randomUUID === 'function'
|
|
39
|
+
? crypto.randomUUID()
|
|
40
|
+
: crypto.randomBytes(16).toString('hex')
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const payloadPart = toBase64Url(JSON.stringify(payload));
|
|
44
|
+
const signatureHex = crypto.createHmac('sha256', secret).update(payloadPart).digest('hex');
|
|
45
|
+
const signaturePart = toBase64Url(Buffer.from(signatureHex, 'hex'));
|
|
46
|
+
const token = `${payloadPart}.${signaturePart}`;
|
|
47
|
+
|
|
48
|
+
const joiner = baseUrl.includes('?') ? '&' : '?';
|
|
49
|
+
const url = `${baseUrl}${joiner}autologin=1&authToken=${encodeURIComponent(token)}`;
|
|
50
|
+
|
|
51
|
+
console.log('token:', token);
|
|
52
|
+
console.log('url :', url);
|