mantenimento-app 2.2.9 → 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.
@@ -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 cifrato</button>
106
- <button class="btn-secondary" id="btnImportJson" type="button">Carica JSON cifrato</button>
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&egrave;!">&#9749;</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.2.9"></script>
626
+ <script src="app.js?v=2.3.0"></script>
604
627
  </body>
605
628
  </html>
606
629
 
@@ -3345,4 +3345,70 @@
3345
3345
  border: 1px solid #cfcfcf;
3346
3346
  background: #fff;
3347
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;
3348
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
- // Optional named backends for the published frontend.
7
- // Example usage:
8
- // https://favagit.github.io/mantenimento-app/?env=dev
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.2.9",
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);
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ function loadDotEnvFromWorkspaceRoot() {
6
+ try {
7
+ const root = process.cwd();
8
+ const envPath = path.join(root, '.env');
9
+ if (!fs.existsSync(envPath)) return;
10
+
11
+ const content = fs.readFileSync(envPath, 'utf8');
12
+ const lines = String(content || '').split(/\r?\n/);
13
+ for (const line of lines) {
14
+ const trimmed = String(line || '').trim();
15
+ if (!trimmed || trimmed.startsWith('#')) continue;
16
+ const idx = trimmed.indexOf('=');
17
+ if (idx <= 0) continue;
18
+ const key = trimmed.slice(0, idx).trim();
19
+ const value = trimmed.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '');
20
+ if (!key || process.env[key] != null) continue;
21
+ process.env[key] = value;
22
+ }
23
+ } catch (_) {
24
+ // best effort only
25
+ }
26
+ }
27
+
28
+ loadDotEnvFromWorkspaceRoot();
29
+
30
+ const args = process.argv.slice(2);
31
+ const getArg = (name, fallback = '') => {
32
+ const prefix = `--${name}=`;
33
+ const found = args.find((arg) => arg.startsWith(prefix));
34
+ return found ? found.slice(prefix.length) : fallback;
35
+ };
36
+ const hasFlag = (name) => args.includes(`--${name}`);
37
+
38
+ const mode = String(getArg('mode', '') || '').trim().toLowerCase();
39
+ const usernamesRaw = String(getArg('users', '') || '').trim();
40
+ const emailsRaw = String(getArg('emails', '') || '').trim();
41
+ const dryRun = hasFlag('dry-run');
42
+
43
+ const supabaseUrl = String(process.env.DONOR_ADMIN_SUPABASE_URL || process.env.AUTH_URL_LOGIN_SUPABASE_URL || '').trim().replace(/\/+$/, '');
44
+ const serviceRoleKey = String(process.env.DONOR_ADMIN_SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || '').trim();
45
+
46
+ function fail(message) {
47
+ console.error(message);
48
+ process.exit(1);
49
+ }
50
+
51
+ function parseCsv(value) {
52
+ return String(value || '')
53
+ .split(',')
54
+ .map((v) => v.trim().toLowerCase())
55
+ .filter(Boolean);
56
+ }
57
+
58
+ function usage() {
59
+ console.log([
60
+ 'Usage:',
61
+ ' node scripts/manage-donor-users.mjs --mode=grant --users=favagit,fabio.vacchino',
62
+ ' node scripts/manage-donor-users.mjs --mode=revoke --users=favagit',
63
+ ' node scripts/manage-donor-users.mjs --mode=grant --emails=user1@example.com,user2@example.com',
64
+ '',
65
+ 'Options:',
66
+ ' --mode=grant|revoke Required',
67
+ ' --users=a,b,c Comma-separated username local parts (email before @)',
68
+ ' --emails=a@x.com,b@y.it Comma-separated full emails',
69
+ ' --dry-run Print actions without writing changes',
70
+ '',
71
+ 'Environment variables:',
72
+ ' DONOR_ADMIN_SUPABASE_URL',
73
+ ' DONOR_ADMIN_SUPABASE_SERVICE_ROLE_KEY'
74
+ ].join('\n'));
75
+ }
76
+
77
+ if (!mode || !['grant', 'revoke'].includes(mode)) {
78
+ usage();
79
+ fail('\nMissing or invalid --mode. Use grant or revoke.');
80
+ }
81
+
82
+ const targetUsers = new Set(parseCsv(usernamesRaw));
83
+ const targetEmails = new Set(parseCsv(emailsRaw));
84
+ if (!targetUsers.size && !targetEmails.size) {
85
+ usage();
86
+ fail('\nSpecify at least one target via --users or --emails.');
87
+ }
88
+
89
+ if (!supabaseUrl) {
90
+ fail('Missing DONOR_ADMIN_SUPABASE_URL (or AUTH_URL_LOGIN_SUPABASE_URL).');
91
+ }
92
+ if (!serviceRoleKey) {
93
+ fail('Missing DONOR_ADMIN_SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_SERVICE_ROLE_KEY).');
94
+ }
95
+
96
+ const headers = {
97
+ apikey: serviceRoleKey,
98
+ Authorization: `Bearer ${serviceRoleKey}`,
99
+ 'Content-Type': 'application/json'
100
+ };
101
+
102
+ async function fetchJson(url, options = {}) {
103
+ const res = await fetch(url, options);
104
+ const body = await res.json().catch(() => ({}));
105
+ return { ok: res.ok, status: res.status, body };
106
+ }
107
+
108
+ async function listAllUsers() {
109
+ const out = [];
110
+ let page = 1;
111
+ while (true) {
112
+ const url = `${supabaseUrl}/auth/v1/admin/users?page=${page}&per_page=1000`;
113
+ const response = await fetchJson(url, { headers });
114
+ if (!response.ok) {
115
+ const err = String(response.body?.msg || response.body?.error || `HTTP ${response.status}`);
116
+ fail(`Cannot list users: ${err}`);
117
+ }
118
+
119
+ const users = Array.isArray(response.body?.users) ? response.body.users : [];
120
+ out.push(...users);
121
+ if (!users.length || users.length < 1000) break;
122
+ page += 1;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ function emailLocalPart(email) {
128
+ return String(email || '').trim().toLowerCase().split('@')[0] || '';
129
+ }
130
+
131
+ function shouldTargetUser(user) {
132
+ const email = String(user?.email || '').trim().toLowerCase();
133
+ const local = emailLocalPart(email);
134
+ if (targetEmails.has(email)) return true;
135
+ if (targetUsers.has(local)) return true;
136
+ return false;
137
+ }
138
+
139
+ function truthy(value) {
140
+ if (value === true || value === 1) return true;
141
+ const normalized = String(value || '').trim().toLowerCase();
142
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
143
+ }
144
+
145
+ function patchUserMetadata(existingMeta, selectedMode) {
146
+ const next = { ...(existingMeta && typeof existingMeta === 'object' ? existingMeta : {}) };
147
+ if (selectedMode === 'grant') {
148
+ next.is_donor = true;
149
+ next.role = 'donor';
150
+ return next;
151
+ }
152
+
153
+ delete next.is_donor;
154
+ delete next.isDonor;
155
+ delete next.premium;
156
+ if (String(next.role || '').trim().toLowerCase() === 'donor') {
157
+ delete next.role;
158
+ }
159
+ return next;
160
+ }
161
+
162
+ function isAlreadyAligned(existingMeta, selectedMode) {
163
+ const meta = existingMeta && typeof existingMeta === 'object' ? existingMeta : {};
164
+ if (selectedMode === 'grant') {
165
+ return truthy(meta.is_donor) && String(meta.role || '').trim().toLowerCase() === 'donor';
166
+ }
167
+
168
+ return !truthy(meta.is_donor)
169
+ && !truthy(meta.isDonor)
170
+ && !truthy(meta.premium)
171
+ && String(meta.role || '').trim().toLowerCase() !== 'donor';
172
+ }
173
+
174
+ async function updateUserMetadata(userId, nextMeta) {
175
+ const url = `${supabaseUrl}/auth/v1/admin/users/${encodeURIComponent(userId)}`;
176
+ const response = await fetchJson(url, {
177
+ method: 'PUT',
178
+ headers,
179
+ body: JSON.stringify({ user_metadata: nextMeta })
180
+ });
181
+
182
+ if (!response.ok) {
183
+ const err = String(response.body?.msg || response.body?.error || `HTTP ${response.status}`);
184
+ throw new Error(err);
185
+ }
186
+ }
187
+
188
+ (async () => {
189
+ const allUsers = await listAllUsers();
190
+ const targets = allUsers.filter(shouldTargetUser);
191
+
192
+ if (!targets.length) {
193
+ console.log('No matching users found.');
194
+ process.exit(0);
195
+ }
196
+
197
+ let changed = 0;
198
+ let skipped = 0;
199
+
200
+ for (const user of targets) {
201
+ const email = String(user?.email || '').trim().toLowerCase();
202
+ const userId = String(user?.id || '').trim();
203
+ const currentMeta = user?.user_metadata && typeof user.user_metadata === 'object' ? user.user_metadata : {};
204
+
205
+ if (!userId || !email) {
206
+ skipped += 1;
207
+ continue;
208
+ }
209
+
210
+ if (isAlreadyAligned(currentMeta, mode)) {
211
+ console.log(`[skip] ${email} already aligned for mode=${mode}`);
212
+ skipped += 1;
213
+ continue;
214
+ }
215
+
216
+ const nextMeta = patchUserMetadata(currentMeta, mode);
217
+ if (dryRun) {
218
+ console.log(`[dry-run] would ${mode} donor for ${email}`);
219
+ changed += 1;
220
+ continue;
221
+ }
222
+
223
+ await updateUserMetadata(userId, nextMeta);
224
+ console.log(`[ok] ${mode} donor for ${email}`);
225
+ changed += 1;
226
+ }
227
+
228
+ console.log(`Done. targets=${targets.length} changed=${changed} skipped=${skipped} dryRun=${dryRun}`);
229
+ })();