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.
@@ -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
- printStepResult({ name: 'fcm-key', ok: false, detail: tr('new.fcm.failSupabase') }, language);
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
- printStepResult({ name: 'fcm-key', ok: false, detail: tr('new.fcm.failApi') }, language);
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" with the key/user "access-token". Each OS keeps that in a
192
- // different vault, so reading it back is per-OS. Used by the Management API
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 a generic credential out of the Windows Credential Manager. go-keyring's
198
- // wincred backend names the target "<service>:<user>" and stores the secret as a
199
- // raw blob (UTF-8 or UTF-16LE depending on version), so we return the base64 of
200
- // the blob and let the caller pick the right decoding.
201
- async function readWindowsCredentialBase64(target) {
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 CredRead(string target, int type, int flags, out IntPtr cred);
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]::CredRead('${target}', 1, 0, [ref]$ptr)) {
222
- $c = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type]([KasyCred+CREDENTIAL]))
223
- $bytes = New-Object byte[] $c.CredentialBlobSize
224
- [System.Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $bytes, 0, $c.CredentialBlobSize)
225
- [KasyCred]::CredFree($ptr)
226
- [Convert]::ToBase64String($bytes)
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 readWindowsCredentialBase64(`${SUPABASE_KEYRING_SERVICE}:${SUPABASE_KEYRING_USER}`);
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
- const { stdout } = await execAsync(
283
- `secret-tool lookup service "${SUPABASE_KEYRING_SERVICE}" username "${SUPABASE_KEYRING_USER}"`,
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
- return decodeKeyring(stdout);
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 null;
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 payload = JSON.stringify({
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
- try {
320
- const data = JSON.parse(result.stdout);
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 payload = JSON.stringify({
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
- try {
362
- const data = JSON.parse(result.stdout);
363
- const allowedIds = String(data.external_apple_client_id || '')
364
- .split(',')
365
- .map((s) => s.trim());
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 payload = JSON.stringify({
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
- try {
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 <= 5; 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 < 5) await sleep(8000);
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
- for (let attempt = 1; attempt <= 4; attempt++) {
126
- keyResult = await run(
127
- `gcloud iam service-accounts keys create "${tmpPath}"` +
128
- ` --iam-account="${saEmail}"` +
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
- if (attempt < 4) await sleep(7000);
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
- return run('flutter pub get', projectDir, 300_000); // 5 min
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
- const curlCmd = [
900
- 'curl -sf',
901
- `-H "Authorization: Bearer ${token}"`,
902
- `-H "x-goog-user-project: ${firebaseProjectId}"`,
903
- `"https://identitytoolkit.googleapis.com/admin/v2/projects/${firebaseProjectId}/defaultSupportedIdpConfigs/google.com"`,
904
- ].join(' ');
905
-
906
- const result = await run(curlCmd, process.cwd());
907
- if (!result.ok || !result.stdout.trim()) return '';
908
-
909
- const data = JSON.parse(result.stdout.trim());
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.0",
3
+ "version": "1.31.2",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -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
- const String webDevicePreviewEnabledPrefKey = 'web_device_preview_enabled';
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+34
19
+ version: 1.0.0+35
20
20
 
21
21
  environment:
22
22
  sdk: ^3.11.0