kasy-cli 1.31.0 → 1.31.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/lib/commands/new.js +10 -2
- package/lib/scaffold/backends/supabase/deploy.js +89 -69
- package/lib/scaffold/shared/fcm-service-account.js +77 -11
- package/lib/scaffold/shared/post-build.js +18 -12
- package/lib/utils/i18n/messages-en.js +2 -0
- package/lib/utils/i18n/messages-es.js +2 -0
- package/lib/utils/i18n/messages-pt.js +2 -0
- package/package.json +1 -1
- package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +14 -0
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +2 -12
- package/templates/firebase/lib/core/states/user_state_notifier.dart +4 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +4 -1
- package/templates/firebase/pubspec.yaml +1 -1
package/lib/commands/new.js
CHANGED
|
@@ -1908,9 +1908,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
|
|
|
1908
1908
|
fcmSpinner.stop(tr('new.fcm.generating'));
|
|
1909
1909
|
if (fcmResult.ok) {
|
|
1910
1910
|
fcmServiceAccountJson = fcmResult.json;
|
|
1911
|
+
if (fcmResult.policyAdjusted) ui.log.info(tr('new.fcm.policyLifted'));
|
|
1911
1912
|
printStepResult({ name: 'fcm-key', ok: true, detail: tr('new.fcm.ok') }, language);
|
|
1912
1913
|
} else {
|
|
1913
|
-
|
|
1914
|
+
const detail = (fcmResult.errorKind === 'orgPolicyNoPermission' || fcmResult.errorKind === 'orgPolicyBlocked')
|
|
1915
|
+
? tr('new.fcm.orgPolicyBlocked')
|
|
1916
|
+
: tr('new.fcm.failSupabase');
|
|
1917
|
+
printStepResult({ name: 'fcm-key', ok: false, detail }, language);
|
|
1914
1918
|
}
|
|
1915
1919
|
}
|
|
1916
1920
|
|
|
@@ -2002,13 +2006,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
|
|
|
2002
2006
|
await fs.appendFile(gitignorePath, `\n# FCM service account key (credentials — do not commit)\n${gitignoreEntry}\n`, 'utf8');
|
|
2003
2007
|
}
|
|
2004
2008
|
}
|
|
2009
|
+
if (fcmResult.policyAdjusted) ui.log.info(tr('new.fcm.policyLifted'));
|
|
2005
2010
|
printStepResult({ name: 'fcm-key-saved', ok: true }, language);
|
|
2006
2011
|
ui.log.message(tr('new.fcm.serverConfig'));
|
|
2007
2012
|
} catch (_) {
|
|
2008
2013
|
printStepResult({ name: 'fcm-key-saved', ok: false, detail: 'salvar arquivo de chave falhou' }, language);
|
|
2009
2014
|
}
|
|
2010
2015
|
} else {
|
|
2011
|
-
|
|
2016
|
+
const detail = (fcmResult.errorKind === 'orgPolicyNoPermission' || fcmResult.errorKind === 'orgPolicyBlocked')
|
|
2017
|
+
? tr('new.fcm.orgPolicyBlocked')
|
|
2018
|
+
: tr('new.fcm.failApi');
|
|
2019
|
+
printStepResult({ name: 'fcm-key', ok: false, detail }, language);
|
|
2012
2020
|
}
|
|
2013
2021
|
}
|
|
2014
2022
|
|
|
@@ -188,17 +188,18 @@ async function getProjectKeys(projectRef) {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
// The Supabase CLI stores its access token via go-keyring under the service
|
|
191
|
-
// "Supabase CLI"
|
|
192
|
-
//
|
|
191
|
+
// "Supabase CLI". The account/key has varied across CLI versions (e.g.
|
|
192
|
+
// "supabase", "access-token"), so we DON'T hardcode it — we read by service
|
|
193
|
+
// only. Each OS keeps this in a different vault. Used by the Management API
|
|
193
194
|
// calls below (auth providers), which the CLI itself has no command for.
|
|
194
195
|
const SUPABASE_KEYRING_SERVICE = 'Supabase CLI';
|
|
195
|
-
const SUPABASE_KEYRING_USER = 'access-token';
|
|
196
196
|
|
|
197
|
-
// Read
|
|
198
|
-
// wincred backend names the target "<service>:<user>"
|
|
199
|
-
//
|
|
200
|
-
// the
|
|
201
|
-
|
|
197
|
+
// Read the Supabase token out of the Windows Credential Manager. go-keyring's
|
|
198
|
+
// wincred backend names the target "<service>:<user>", but since the user part
|
|
199
|
+
// differs by CLI version we ENUMERATE every credential whose target starts with
|
|
200
|
+
// the service name and return the first non-empty blob as base64 — the caller
|
|
201
|
+
// picks the decoding. Robust to the account name we can't see from here.
|
|
202
|
+
async function readWindowsSupabaseTokenBase64() {
|
|
202
203
|
const ps = `
|
|
203
204
|
$ErrorActionPreference = 'Stop'
|
|
204
205
|
$sig = @"
|
|
@@ -206,7 +207,7 @@ using System;
|
|
|
206
207
|
using System.Runtime.InteropServices;
|
|
207
208
|
public class KasyCred {
|
|
208
209
|
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
209
|
-
public static extern bool
|
|
210
|
+
public static extern bool CredEnumerate(string filter, int flag, out int count, out IntPtr creds);
|
|
210
211
|
[DllImport("advapi32.dll")] public static extern void CredFree(IntPtr cred);
|
|
211
212
|
[StructLayout(LayoutKind.Sequential)] public struct CREDENTIAL {
|
|
212
213
|
public int Flags; public int Type; public IntPtr TargetName; public IntPtr Comment;
|
|
@@ -217,13 +218,18 @@ public class KasyCred {
|
|
|
217
218
|
}
|
|
218
219
|
"@
|
|
219
220
|
Add-Type $sig | Out-Null
|
|
220
|
-
$ptr = [IntPtr]::Zero
|
|
221
|
-
if ([KasyCred]::
|
|
222
|
-
$
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
221
|
+
$count = 0; $ptr = [IntPtr]::Zero
|
|
222
|
+
if ([KasyCred]::CredEnumerate('${SUPABASE_KEYRING_SERVICE}*', 0, [ref]$count, [ref]$ptr)) {
|
|
223
|
+
for ($i = 0; $i -lt $count; $i++) {
|
|
224
|
+
$credPtr = [System.Runtime.InteropServices.Marshal]::ReadIntPtr($ptr, $i * [IntPtr]::Size)
|
|
225
|
+
$c = [System.Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [type]([KasyCred+CREDENTIAL]))
|
|
226
|
+
if ($c.CredentialBlobSize -gt 0) {
|
|
227
|
+
$bytes = New-Object byte[] $c.CredentialBlobSize
|
|
228
|
+
[System.Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $bytes, 0, $c.CredentialBlobSize)
|
|
229
|
+
[Convert]::ToBase64String($bytes)
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
227
233
|
}
|
|
228
234
|
`;
|
|
229
235
|
const encoded = Buffer.from(ps, 'utf16le').toString('base64');
|
|
@@ -265,7 +271,7 @@ async function getSupabaseAccessToken() {
|
|
|
265
271
|
|
|
266
272
|
if (process.platform === 'win32') {
|
|
267
273
|
try {
|
|
268
|
-
const b64 = await
|
|
274
|
+
const b64 = await readWindowsSupabaseTokenBase64();
|
|
269
275
|
if (!b64) return null;
|
|
270
276
|
const buf = Buffer.from(b64, 'base64');
|
|
271
277
|
// UTF-16LE blobs have a NUL after most bytes; UTF-8 blobs don't.
|
|
@@ -277,15 +283,65 @@ async function getSupabaseAccessToken() {
|
|
|
277
283
|
}
|
|
278
284
|
}
|
|
279
285
|
|
|
280
|
-
// Linux (libsecret).
|
|
286
|
+
// Linux (libsecret). The account key has varied by CLI version, so try the
|
|
287
|
+
// ones we've seen before giving up.
|
|
288
|
+
for (const user of ['supabase', 'access-token']) {
|
|
289
|
+
try {
|
|
290
|
+
const { stdout } = await execAsync(
|
|
291
|
+
`secret-tool lookup service "${SUPABASE_KEYRING_SERVICE}" username "${user}"`,
|
|
292
|
+
);
|
|
293
|
+
const token = decodeKeyring(stdout);
|
|
294
|
+
if (token) return token;
|
|
295
|
+
} catch {
|
|
296
|
+
// try next
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* PATCH the Supabase Management API auth config with a JSON body.
|
|
304
|
+
*
|
|
305
|
+
* Uses Node's built-in fetch (Node 18+) instead of shelling out to curl. curl
|
|
306
|
+
* with a single-quoted JSON body is unreliable on Windows: cmd.exe does not strip
|
|
307
|
+
* single quotes and treats the inner double quotes specially, so the body arrives
|
|
308
|
+
* mangled and the response comes back as the leaked payload — which then fails to
|
|
309
|
+
* parse ("Unexpected token ''', \"'{external\"... is not valid JSON"). fetch sends
|
|
310
|
+
* the exact bytes with no shell quoting involved, identically on every OS.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} projectRef
|
|
313
|
+
* @param {string} token - Supabase access token
|
|
314
|
+
* @param {object} body - auth config fields to PATCH
|
|
315
|
+
* @returns {Promise<{ ok: boolean, data?: object, error?: string }>}
|
|
316
|
+
*/
|
|
317
|
+
async function patchAuthConfig(projectRef, token, body) {
|
|
318
|
+
let res;
|
|
281
319
|
try {
|
|
282
|
-
|
|
283
|
-
`
|
|
320
|
+
res = await fetch(
|
|
321
|
+
`https://api.supabase.com/v1/projects/${projectRef}/config/auth`,
|
|
322
|
+
{
|
|
323
|
+
method: 'PATCH',
|
|
324
|
+
headers: {
|
|
325
|
+
Authorization: `Bearer ${token}`,
|
|
326
|
+
'Content-Type': 'application/json',
|
|
327
|
+
},
|
|
328
|
+
body: JSON.stringify(body),
|
|
329
|
+
}
|
|
284
330
|
);
|
|
285
|
-
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return { ok: false, error: `Network error calling Management API: ${err.message}` };
|
|
333
|
+
}
|
|
334
|
+
const text = await res.text();
|
|
335
|
+
let data;
|
|
336
|
+
try {
|
|
337
|
+
data = JSON.parse(text);
|
|
286
338
|
} catch {
|
|
287
|
-
return
|
|
339
|
+
return { ok: false, error: `Management API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}` };
|
|
288
340
|
}
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
return { ok: false, error: data.message || `Management API error (HTTP ${res.status})`, data };
|
|
343
|
+
}
|
|
344
|
+
return { ok: true, data };
|
|
289
345
|
}
|
|
290
346
|
|
|
291
347
|
/**
|
|
@@ -302,27 +358,15 @@ async function enableGoogleSignIn(projectRef, webClientId, clientSecret) {
|
|
|
302
358
|
if (!webClientId || !clientSecret) return { ok: false, error: 'webClientId and clientSecret are required' };
|
|
303
359
|
const token = await getSupabaseAccessToken();
|
|
304
360
|
if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
|
|
305
|
-
const
|
|
361
|
+
const result = await patchAuthConfig(projectRef, token, {
|
|
306
362
|
external_google_enabled: true,
|
|
307
363
|
external_google_client_id: webClientId,
|
|
308
364
|
external_google_secret: clientSecret,
|
|
309
365
|
external_google_skip_nonce_check: true,
|
|
310
366
|
});
|
|
311
|
-
const result = await run(
|
|
312
|
-
`curl -s -X PATCH "https://api.supabase.com/v1/projects/${projectRef}/config/auth" ` +
|
|
313
|
-
`-H "Authorization: Bearer ${token}" ` +
|
|
314
|
-
`-H "Content-Type: application/json" ` +
|
|
315
|
-
`-d '${payload}'`,
|
|
316
|
-
process.cwd()
|
|
317
|
-
);
|
|
318
367
|
if (!result.ok) return { ok: false, error: result.error };
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (data.external_google_enabled === true) return { ok: true };
|
|
322
|
-
return { ok: false, error: data.message || JSON.stringify(data) };
|
|
323
|
-
} catch {
|
|
324
|
-
return { ok: false, error: 'Could not parse Management API response' };
|
|
325
|
-
}
|
|
368
|
+
if (result.data.external_google_enabled === true) return { ok: true };
|
|
369
|
+
return { ok: false, error: result.data.message || JSON.stringify(result.data) };
|
|
326
370
|
}
|
|
327
371
|
|
|
328
372
|
/**
|
|
@@ -346,28 +390,16 @@ async function enableAppleSignIn(projectRef, bundleId) {
|
|
|
346
390
|
if (!bundleId) return { ok: false, error: 'bundleId is required' };
|
|
347
391
|
const token = await getSupabaseAccessToken();
|
|
348
392
|
if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
|
|
349
|
-
const
|
|
393
|
+
const result = await patchAuthConfig(projectRef, token, {
|
|
350
394
|
external_apple_enabled: true,
|
|
351
395
|
external_apple_client_id: bundleId,
|
|
352
396
|
});
|
|
353
|
-
const result = await run(
|
|
354
|
-
`curl -s -X PATCH "https://api.supabase.com/v1/projects/${projectRef}/config/auth" ` +
|
|
355
|
-
`-H "Authorization: Bearer ${token}" ` +
|
|
356
|
-
`-H "Content-Type: application/json" ` +
|
|
357
|
-
`-d '${payload}'`,
|
|
358
|
-
process.cwd()
|
|
359
|
-
);
|
|
360
397
|
if (!result.ok) return { ok: false, error: result.error };
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (data.external_apple_enabled === true && allowedIds.includes(bundleId)) return { ok: true };
|
|
367
|
-
return { ok: false, error: data.message || JSON.stringify(data) };
|
|
368
|
-
} catch {
|
|
369
|
-
return { ok: false, error: 'Could not parse Management API response' };
|
|
370
|
-
}
|
|
398
|
+
const allowedIds = String(result.data.external_apple_client_id || '')
|
|
399
|
+
.split(',')
|
|
400
|
+
.map((s) => s.trim());
|
|
401
|
+
if (result.data.external_apple_enabled === true && allowedIds.includes(bundleId)) return { ok: true };
|
|
402
|
+
return { ok: false, error: result.data.message || JSON.stringify(result.data) };
|
|
371
403
|
}
|
|
372
404
|
|
|
373
405
|
/**
|
|
@@ -380,24 +412,12 @@ async function enableAppleSignIn(projectRef, bundleId) {
|
|
|
380
412
|
async function enableAnonymousSignIn(projectRef) {
|
|
381
413
|
const token = await getSupabaseAccessToken();
|
|
382
414
|
if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
|
|
383
|
-
const
|
|
415
|
+
const result = await patchAuthConfig(projectRef, token, {
|
|
384
416
|
external_anonymous_users_enabled: true,
|
|
385
417
|
mailer_autoconfirm: true,
|
|
386
418
|
});
|
|
387
|
-
const result = await run(
|
|
388
|
-
`curl -s -X PATCH "https://api.supabase.com/v1/projects/${projectRef}/config/auth" ` +
|
|
389
|
-
`-H "Authorization: Bearer ${token}" ` +
|
|
390
|
-
`-H "Content-Type: application/json" ` +
|
|
391
|
-
`-d '${payload}'`,
|
|
392
|
-
process.cwd()
|
|
393
|
-
);
|
|
394
419
|
if (!result.ok) return { ok: false, error: result.error };
|
|
395
|
-
|
|
396
|
-
const data = JSON.parse(result.stdout);
|
|
397
|
-
return { ok: data.external_anonymous_users_enabled === true };
|
|
398
|
-
} catch {
|
|
399
|
-
return { ok: false, error: 'Could not parse Management API response' };
|
|
400
|
-
}
|
|
420
|
+
return { ok: result.data.external_anonymous_users_enabled === true };
|
|
401
421
|
}
|
|
402
422
|
|
|
403
423
|
/**
|
|
@@ -75,13 +75,52 @@ async function grantFcmAdminRole(projectId, saEmail) {
|
|
|
75
75
|
return { ok: true };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Detects the one failure mode that NO amount of retrying can fix: the GCP org
|
|
80
|
+
* policy constraints/iam.disableServiceAccountKeyCreation. Google turns this on by
|
|
81
|
+
* default for organizations created since 2024 ("secure by default"), so a brand-new
|
|
82
|
+
* org blocks service-account key creation outright. gcloud reports it as
|
|
83
|
+
* FAILED_PRECONDITION: "Key creation is not allowed on this service account."
|
|
84
|
+
*/
|
|
85
|
+
function isKeyCreationBlockedByOrgPolicy(result) {
|
|
86
|
+
const blob = `${result.error || ''} ${result.stderr || ''} ${result.stdout || ''}`;
|
|
87
|
+
return /disableServiceAccountKeyCreation|Key creation is not allowed/i.test(blob);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Lifts the disableServiceAccountKeyCreation constraint for a SINGLE project (not the
|
|
92
|
+
* whole organization) so the Firebase Admin SDK key can be created. This is the
|
|
93
|
+
* standard, Google-documented way to allow SA keys where they are genuinely required
|
|
94
|
+
* — here, so Supabase Edge Functions / a REST server can authenticate to FCM HTTP v1.
|
|
95
|
+
*
|
|
96
|
+
* The override is scoped to the app's own project, leaving the org-wide policy intact.
|
|
97
|
+
* Requires the caller to have roles/orgpolicy.policyAdmin (org owners normally do).
|
|
98
|
+
*
|
|
99
|
+
* @param {string} projectId
|
|
100
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
101
|
+
*/
|
|
102
|
+
async function allowServiceAccountKeyCreation(projectId) {
|
|
103
|
+
const result = await run(
|
|
104
|
+
`gcloud resource-manager org-policies disable-enforce` +
|
|
105
|
+
` iam.disableServiceAccountKeyCreation --project=${projectId}`
|
|
106
|
+
);
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
return { ok: false, error: result.stderr || result.error };
|
|
109
|
+
}
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
/**
|
|
79
114
|
* Generates a new private key for the Firebase Admin SDK service account and returns
|
|
80
115
|
* the JSON content as a string. The temporary key file is deleted immediately after reading.
|
|
81
116
|
* Also ensures the service account has the roles/firebasecloudmessaging.admin IAM role.
|
|
82
117
|
*
|
|
118
|
+
* If the org blocks SA key creation (default on new GCP orgs), the constraint is lifted
|
|
119
|
+
* for this project only and key creation is retried — otherwise push would silently come
|
|
120
|
+
* out unconfigured on every fresh organization.
|
|
121
|
+
*
|
|
83
122
|
* @param {string} projectId - Firebase/GCP project ID
|
|
84
|
-
* @returns {{ ok: boolean, json?: string, saEmail?: string, error?: string }}
|
|
123
|
+
* @returns {{ ok: boolean, json?: string, saEmail?: string, policyAdjusted?: boolean, errorKind?: string, error?: string }}
|
|
85
124
|
*/
|
|
86
125
|
async function createFcmServiceAccountKey(projectId) {
|
|
87
126
|
if (!projectId || !projectId.trim()) {
|
|
@@ -97,7 +136,7 @@ async function createFcmServiceAccountKey(projectId) {
|
|
|
97
136
|
// the discover+grant with backoff before giving up.
|
|
98
137
|
let saEmail = '';
|
|
99
138
|
let lastErr = 'Firebase Admin SDK service account not found';
|
|
100
|
-
for (let attempt = 1; attempt <=
|
|
139
|
+
for (let attempt = 1; attempt <= 7; attempt++) {
|
|
101
140
|
const saResult = await findFirebaseAdminSdkSA(projectId.trim());
|
|
102
141
|
if (saResult.ok) {
|
|
103
142
|
const roleResult = await grantFcmAdminRole(projectId.trim(), saResult.email);
|
|
@@ -109,31 +148,58 @@ async function createFcmServiceAccountKey(projectId) {
|
|
|
109
148
|
} else {
|
|
110
149
|
lastErr = saResult.error;
|
|
111
150
|
}
|
|
112
|
-
if (attempt <
|
|
151
|
+
if (attempt < 7) await sleep(10000);
|
|
113
152
|
}
|
|
114
153
|
if (!saEmail) {
|
|
115
154
|
return { ok: false, error: lastErr };
|
|
116
155
|
}
|
|
117
156
|
|
|
118
157
|
const tmpPath = path.join(os.tmpdir(), `kasy-fcm-${Date.now()}.json`);
|
|
158
|
+
const createKey = () => run(
|
|
159
|
+
`gcloud iam service-accounts keys create "${tmpPath}"` +
|
|
160
|
+
` --iam-account="${saEmail}"` +
|
|
161
|
+
` --project=${projectId.trim()}`
|
|
162
|
+
);
|
|
119
163
|
|
|
120
164
|
// Right after granting the FCM admin role (and on a brand-new project), the
|
|
121
165
|
// IAM permission takes a moment to propagate — so the first key-create often
|
|
122
166
|
// fails with "permission still propagating". Back off and retry a few times
|
|
123
167
|
// before giving up, instead of leaving push half-configured.
|
|
124
168
|
let keyResult;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
` --project=${projectId.trim()}`
|
|
130
|
-
);
|
|
169
|
+
let policyAdjusted = false;
|
|
170
|
+
const maxAttempts = 6;
|
|
171
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
172
|
+
keyResult = await createKey();
|
|
131
173
|
if (keyResult.ok) break;
|
|
132
|
-
|
|
174
|
+
|
|
175
|
+
// The org blocks SA keys (default on new GCP orgs). This is NOT a propagation
|
|
176
|
+
// race that retrying alone can fix — the constraint must be lifted. Lift it for
|
|
177
|
+
// THIS project once, then keep retrying: an org policy change itself takes a
|
|
178
|
+
// minute or more to take effect. If we can't lift it (no policy-admin
|
|
179
|
+
// permission), report a specific, actionable error instead of "still propagating".
|
|
180
|
+
if (isKeyCreationBlockedByOrgPolicy(keyResult)) {
|
|
181
|
+
if (!policyAdjusted) {
|
|
182
|
+
const lift = await allowServiceAccountKeyCreation(projectId.trim());
|
|
183
|
+
if (!lift.ok) {
|
|
184
|
+
await fs.remove(tmpPath).catch(() => {});
|
|
185
|
+
return { ok: false, errorKind: 'orgPolicyNoPermission', error: lift.error };
|
|
186
|
+
}
|
|
187
|
+
policyAdjusted = true;
|
|
188
|
+
}
|
|
189
|
+
if (attempt < maxAttempts) await sleep(15000);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (attempt < maxAttempts) await sleep(7000);
|
|
133
193
|
}
|
|
134
194
|
|
|
135
195
|
if (!keyResult.ok) {
|
|
136
196
|
await fs.remove(tmpPath).catch(() => {});
|
|
197
|
+
// Only surface the "ask an org admin" message when we genuinely could NOT lift
|
|
198
|
+
// the policy. If we DID lift it but the change is still propagating, fall through
|
|
199
|
+
// to the generic "still propagating, run again" message instead of blaming perms.
|
|
200
|
+
if (!policyAdjusted && isKeyCreationBlockedByOrgPolicy(keyResult)) {
|
|
201
|
+
return { ok: false, errorKind: 'orgPolicyBlocked', error: keyResult.error };
|
|
202
|
+
}
|
|
137
203
|
return { ok: false, error: `Failed to generate key: ${keyResult.error}` };
|
|
138
204
|
}
|
|
139
205
|
|
|
@@ -141,7 +207,7 @@ async function createFcmServiceAccountKey(projectId) {
|
|
|
141
207
|
const jsonContent = await fs.readFile(tmpPath, 'utf8');
|
|
142
208
|
await fs.remove(tmpPath).catch(() => {});
|
|
143
209
|
JSON.parse(jsonContent); // validate
|
|
144
|
-
return { ok: true, json: jsonContent.trim(), saEmail };
|
|
210
|
+
return { ok: true, json: jsonContent.trim(), saEmail, policyAdjusted };
|
|
145
211
|
} catch (err) {
|
|
146
212
|
await fs.remove(tmpPath).catch(() => {});
|
|
147
213
|
return { ok: false, error: `Failed to read generated key: ${err.message}` };
|
|
@@ -38,7 +38,11 @@ async function run(cmd, cwd, timeout) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
async function pubGet(projectDir) {
|
|
41
|
-
|
|
41
|
+
// 15 min: the FIRST `flutter pub get` on a fresh machine downloads the whole
|
|
42
|
+
// dependency tree (this template pulls in firebase, supabase, revenuecat,
|
|
43
|
+
// stripe, sentry…), which timed out at the old 5-min cap on slower Windows
|
|
44
|
+
// connections. It's a ceiling, not a wait — a warm cache still returns fast.
|
|
45
|
+
return run('flutter pub get', projectDir, 900_000);
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
async function slangGenerate(projectDir) {
|
|
@@ -896,17 +900,19 @@ async function getGoogleClientSecretViaGcloud(firebaseProjectId) {
|
|
|
896
900
|
if (!tokenResult.ok || !tokenResult.stdout.trim()) return '';
|
|
897
901
|
const token = tokenResult.stdout.trim();
|
|
898
902
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
903
|
+
// Use fetch (Node 18+) instead of curl: no shell quoting, identical on every OS,
|
|
904
|
+
// and it does not depend on curl being on PATH (not guaranteed on Windows).
|
|
905
|
+
const res = await fetch(
|
|
906
|
+
`https://identitytoolkit.googleapis.com/admin/v2/projects/${firebaseProjectId}/defaultSupportedIdpConfigs/google.com`,
|
|
907
|
+
{
|
|
908
|
+
headers: {
|
|
909
|
+
Authorization: `Bearer ${token}`,
|
|
910
|
+
'x-goog-user-project': firebaseProjectId,
|
|
911
|
+
},
|
|
912
|
+
}
|
|
913
|
+
);
|
|
914
|
+
if (!res.ok) return '';
|
|
915
|
+
const data = await res.json();
|
|
910
916
|
return data.clientSecret || '';
|
|
911
917
|
} catch (_) {
|
|
912
918
|
return '';
|
|
@@ -746,6 +746,8 @@ module.exports = {
|
|
|
746
746
|
'new.fcm.ok': 'generated automatically',
|
|
747
747
|
'new.fcm.failSupabase': 'not generated (GCP permission still propagating); set FIREBASE_SERVICE_ACCOUNT_JSON in your Supabase secrets',
|
|
748
748
|
'new.fcm.failApi': 'not generated (GCP permission still propagating); run the command again in a few minutes',
|
|
749
|
+
'new.fcm.policyLifted': 'Enabled service account key creation for this project (required for push notifications)',
|
|
750
|
+
'new.fcm.orgPolicyBlocked': 'not generated: your Google Cloud organization blocks service account keys. Ask an organization admin to allow it, or generate the key in the Firebase Console (Project settings > Service accounts > Generate new private key)',
|
|
749
751
|
'new.sha1.registering': 'Registering SHA-1 for Google Sign-In (Android)…',
|
|
750
752
|
'new.sha1.failed': 'SHA-1 not added automatically: {error}',
|
|
751
753
|
'new.sha1.manual': 'Add it manually so Google Sign-In works on Android:',
|
|
@@ -746,6 +746,8 @@ module.exports = {
|
|
|
746
746
|
'new.fcm.ok': 'generada automáticamente',
|
|
747
747
|
'new.fcm.failSupabase': 'no generada (permiso de GCP aún propagando); define FIREBASE_SERVICE_ACCOUNT_JSON en los secrets de Supabase',
|
|
748
748
|
'new.fcm.failApi': 'no generada (permiso de GCP aún propagando); ejecuta el comando de nuevo en unos minutos',
|
|
749
|
+
'new.fcm.policyLifted': 'Habilitada la creación de claves de cuenta de servicio en este proyecto (necesario para las notificaciones push)',
|
|
750
|
+
'new.fcm.orgPolicyBlocked': 'no generada: tu organización en Google Cloud bloquea las claves de cuenta de servicio. Pide a un administrador de la organización que lo permita, o genera la clave en la Firebase Console (Configuración del proyecto > Cuentas de servicio > Generar nueva clave privada)',
|
|
749
751
|
'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
|
|
750
752
|
'new.sha1.failed': 'SHA-1 no añadido automáticamente: {error}',
|
|
751
753
|
'new.sha1.manual': 'Agregalo manualmente para que Google Sign-In funcione en Android:',
|
|
@@ -746,6 +746,8 @@ module.exports = {
|
|
|
746
746
|
'new.fcm.ok': 'gerada automaticamente',
|
|
747
747
|
'new.fcm.failSupabase': 'não gerada (permissão do GCP ainda propagando); defina FIREBASE_SERVICE_ACCOUNT_JSON nos secrets do Supabase',
|
|
748
748
|
'new.fcm.failApi': 'não gerada (permissão do GCP ainda propagando); rode o comando de novo em alguns minutos',
|
|
749
|
+
'new.fcm.policyLifted': 'Liberada a criação de chaves de service account neste projeto (necessário para as notificações push)',
|
|
750
|
+
'new.fcm.orgPolicyBlocked': 'não gerada: sua organização no Google Cloud bloqueia chaves de service account. Peça a um administrador da organização para liberar, ou gere a chave no Firebase Console (Configurações do projeto > Contas de serviço > Gerar nova chave privada)',
|
|
749
751
|
'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
|
|
750
752
|
'new.sha1.failed': 'SHA-1 não adicionado automaticamente: {error}',
|
|
751
753
|
'new.sha1.manual': 'Adicione manualmente para o Google Sign-In funcionar no Android:',
|
package/package.json
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import 'package:flutter/foundation.dart';
|
|
2
|
+
|
|
3
|
+
/// The bottom-bar tab the user last opened, held at top level so it outlives the
|
|
4
|
+
/// [BottomMenu] remount that happens whenever the responsive layout flips
|
|
5
|
+
/// small↔large (e.g. toggling the web device preview, which renders the app in a
|
|
6
|
+
/// phone-width frame). Persisting it lets the bottom bar restore the tab instead
|
|
7
|
+
/// of snapping back to the first one on remount or hard reload (F5).
|
|
8
|
+
///
|
|
9
|
+
/// It lives in its own dependency-free file so both [BottomMenu] and the logout
|
|
10
|
+
/// flow can touch it without an import cycle. Cleared on logout so a fresh login
|
|
11
|
+
/// always lands on the default tab. Null until the user opens a tab.
|
|
12
|
+
final ValueNotifier<String?> activeTabRouteNotifier = ValueNotifier<String?>(
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|
|
4
4
|
import 'package:flutter/services.dart';
|
|
5
5
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
6
6
|
import 'package:kasy_kit/components/kasy_sidebar.dart';
|
|
7
|
+
import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
|
|
7
8
|
import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
|
|
8
9
|
import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
|
|
9
10
|
import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
|
|
@@ -15,19 +16,8 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
|
15
16
|
import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
|
|
16
17
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
17
18
|
|
|
18
|
-
/// The bottom-bar tab the user last opened, held at top level so it outlives the
|
|
19
|
-
/// [BottomMenu] remount that happens whenever the responsive layout flips
|
|
20
|
-
/// small↔large. Toggling the web device preview does exactly that: the app
|
|
21
|
-
/// renders inside a phone-width frame when on and at full desktop width when
|
|
22
|
-
/// off, so each toggle rebuilds [bart.BartScaffold] from scratch (its index
|
|
23
|
-
/// notifier starts at 0). Persisting the tab here lets [BottomMenu] restore it
|
|
24
|
-
/// instead of snapping back to the first tab. Null until the user opens a tab.
|
|
25
|
-
final ValueNotifier<String?> activeTabRouteNotifier = ValueNotifier<String?>(
|
|
26
|
-
null,
|
|
27
|
-
);
|
|
28
|
-
|
|
29
19
|
/// Records the active tab so it survives the next remount. Wired to
|
|
30
|
-
/// [bart.BartScaffold.onRouteChanged].
|
|
20
|
+
/// [bart.BartScaffold.onRouteChanged]. See [activeTabRouteNotifier].
|
|
31
21
|
void _rememberActiveTab(bart.BartMenuRoute route) {
|
|
32
22
|
activeTabRouteNotifier.value = route.path;
|
|
33
23
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'dart:async';
|
|
2
2
|
|
|
3
3
|
import 'package:flutter/foundation.dart';
|
|
4
|
+
import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
|
|
4
5
|
import 'package:kasy_kit/core/config/features.dart';
|
|
5
6
|
import 'package:kasy_kit/core/data/models/entitlement.dart';
|
|
6
7
|
import 'package:kasy_kit/core/data/models/subscription.dart';
|
|
@@ -139,6 +140,9 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
|
|
|
139
140
|
// Biometric lock is a per-account preference, not a device-wide one.
|
|
140
141
|
// The next user signing in on this install should start without it set.
|
|
141
142
|
await ref.read(sharedPreferencesProvider).setBiometricEnabled(false);
|
|
143
|
+
// Forget the last bottom-bar tab so the next login lands on the default tab
|
|
144
|
+
// (Home) instead of wherever the previous account left off.
|
|
145
|
+
activeTabRouteNotifier.value = null;
|
|
142
146
|
state = const UserState(user: User.anonymous());
|
|
143
147
|
if (mode == AuthenticationMode.anonymous) {
|
|
144
148
|
await _loadAnonymousState();
|
|
@@ -13,7 +13,10 @@ import 'package:provider/provider.dart';
|
|
|
13
13
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
14
14
|
import 'package:universal_html/html.dart' as html;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Suffixed `_v2` because the default flipped from OFF to ON. Values saved under
|
|
17
|
+
// the old key were written while the default was OFF, so we ignore them and
|
|
18
|
+
// start fresh — every install now gets the new ON default until it's toggled.
|
|
19
|
+
const String webDevicePreviewEnabledPrefKey = 'web_device_preview_enabled_v2';
|
|
17
20
|
const String _platformPrefKey = 'web_device_preview_platform';
|
|
18
21
|
const String _iosIndexPrefKey = 'web_device_preview_ios_index';
|
|
19
22
|
const String _androidIndexPrefKey = 'web_device_preview_android_index';
|
|
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|
|
16
16
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
|
17
17
|
# In Windows, build-name is used as the major, minor, and patch parts
|
|
18
18
|
# of the product and file versions while build-number is used as the build suffix.
|
|
19
|
-
version: 1.0.0+
|
|
19
|
+
version: 1.0.0+35
|
|
20
20
|
|
|
21
21
|
environment:
|
|
22
22
|
sdk: ^3.11.0
|