mantenimento-app 2.2.9 → 2.3.2
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 +541 -85
- package/backend/server.js +346 -23
- package/frontend/public/app.js +541 -85
- package/frontend/public/autologin.html +40 -0
- package/frontend/public/index.html +29 -6
- package/frontend/public/styles.css +270 -51
- 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,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
|
+
})();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Grant donor/premium entitlement globally (server-side) to selected users.
|
|
2
|
+
-- Usage in Supabase SQL Editor:
|
|
3
|
+
-- 1) Edit the usernames/emails in the WHERE clause.
|
|
4
|
+
-- 2) Run the script.
|
|
5
|
+
|
|
6
|
+
update auth.users
|
|
7
|
+
set raw_user_meta_data =
|
|
8
|
+
coalesce(raw_user_meta_data, '{}'::jsonb)
|
|
9
|
+
|| jsonb_build_object(
|
|
10
|
+
'is_donor', true,
|
|
11
|
+
'role', 'donor'
|
|
12
|
+
)
|
|
13
|
+
where lower(split_part(email, '@', 1)) in ('favagit', 'fabio.vacchino');
|
|
14
|
+
|
|
15
|
+
-- Verification
|
|
16
|
+
select
|
|
17
|
+
id,
|
|
18
|
+
email,
|
|
19
|
+
raw_user_meta_data->>'is_donor' as is_donor,
|
|
20
|
+
raw_user_meta_data->>'role' as role
|
|
21
|
+
from auth.users
|
|
22
|
+
where lower(split_part(email, '@', 1)) in ('favagit', 'fabio.vacchino');
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Revoke donor/premium entitlement globally (server-side) from selected users.
|
|
2
|
+
-- Usage in Supabase SQL Editor:
|
|
3
|
+
-- 1) Edit the usernames/emails in the WHERE clause.
|
|
4
|
+
-- 2) Run the script.
|
|
5
|
+
|
|
6
|
+
update auth.users
|
|
7
|
+
set raw_user_meta_data =
|
|
8
|
+
(coalesce(raw_user_meta_data, '{}'::jsonb) - 'is_donor' - 'isDonor' - 'premium' - 'role')
|
|
9
|
+
where lower(split_part(email, '@', 1)) in ('favagit', 'fabio.vacchino');
|
|
10
|
+
|
|
11
|
+
-- Verification
|
|
12
|
+
select
|
|
13
|
+
id,
|
|
14
|
+
email,
|
|
15
|
+
raw_user_meta_data->>'is_donor' as is_donor,
|
|
16
|
+
raw_user_meta_data->>'role' as role,
|
|
17
|
+
raw_user_meta_data->>'premium' as premium
|
|
18
|
+
from auth.users
|
|
19
|
+
where lower(split_part(email, '@', 1)) in ('favagit', 'fabio.vacchino');
|