kasy-cli 1.31.14 → 1.34.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.
Files changed (127) hide show
  1. package/bin/kasy.js +42 -0
  2. package/lib/commands/apple-web.js +222 -0
  3. package/lib/commands/configure.js +3 -91
  4. package/lib/commands/doctor.js +20 -0
  5. package/lib/commands/facebook.js +189 -0
  6. package/lib/commands/new.js +65 -3
  7. package/lib/scaffold/CHANGELOG.json +27 -0
  8. package/lib/scaffold/backends/api/patch/README.md +87 -2
  9. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
  10. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  11. package/lib/scaffold/backends/firebase/setup-from-scratch.js +186 -0
  12. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  13. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
  14. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
  15. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
  16. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
  17. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +22 -0
  18. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  19. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +3 -2
  20. package/lib/scaffold/generate.js +1 -1
  21. package/lib/scaffold/shared/generator-utils.js +34 -3
  22. package/lib/utils/apple-web.js +147 -0
  23. package/lib/utils/facebook.js +162 -0
  24. package/lib/utils/i18n/messages-en.js +64 -0
  25. package/lib/utils/i18n/messages-es.js +64 -0
  26. package/lib/utils/i18n/messages-pt.js +64 -0
  27. package/package.json +2 -2
  28. package/templates/firebase/AGENTS.md +87 -0
  29. package/templates/firebase/CLAUDE.md +16 -0
  30. package/templates/firebase/DESIGN_SYSTEM.md +234 -0
  31. package/templates/firebase/docs/auth-setup.en.md +7 -1
  32. package/templates/firebase/docs/auth-setup.es.md +7 -1
  33. package/templates/firebase/docs/auth-setup.pt.md +7 -1
  34. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
  35. package/templates/firebase/lib/components/components.dart +1 -0
  36. package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
  37. package/templates/firebase/lib/components/kasy_alert.dart +1 -1
  38. package/templates/firebase/lib/components/kasy_app_bar.dart +7 -4
  39. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
  40. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  41. package/templates/firebase/lib/components/kasy_chip.dart +1 -1
  42. package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
  43. package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
  44. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  45. package/templates/firebase/lib/components/kasy_sidebar.dart +2 -2
  46. package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
  47. package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
  48. package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
  49. package/templates/firebase/lib/components/kasy_toast.dart +39 -70
  50. package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
  51. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  52. package/templates/firebase/lib/core/config/features.dart +18 -0
  53. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
  54. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
  55. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +46 -124
  56. package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
  57. package/templates/firebase/lib/core/theme/shadows.dart +13 -0
  58. package/templates/firebase/lib/core/theme/texts.dart +32 -0
  59. package/templates/firebase/lib/core/theme/theme.dart +2 -0
  60. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
  61. package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
  62. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  63. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  66. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
  67. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
  68. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
  69. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
  70. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
  71. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +57 -29
  72. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +47 -25
  73. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
  74. package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
  75. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
  76. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -3
  77. package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
  78. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +54 -3
  79. package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
  80. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
  81. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  82. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  83. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  84. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
  85. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
  86. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +104 -156
  87. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
  88. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
  89. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  90. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
  91. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  92. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +3 -2
  93. package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
  94. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
  95. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +4 -4
  96. package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
  97. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
  98. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
  99. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
  100. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  101. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
  102. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
  103. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
  104. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
  105. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
  106. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
  107. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
  108. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
  109. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
  110. package/templates/firebase/lib/i18n/en.i18n.json +13 -4
  111. package/templates/firebase/lib/i18n/es.i18n.json +13 -4
  112. package/templates/firebase/lib/i18n/pt.i18n.json +13 -4
  113. package/templates/firebase/lib/router.dart +2 -0
  114. package/templates/firebase/pubspec.yaml +1 -2
  115. package/templates/firebase/tool/design_check.dart +152 -0
  116. package/templates/firebase/web/stripe_success.html +64 -26
  117. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  118. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  119. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  120. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  121. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  122. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  123. package/templates/firebase/assets/images/review.png +0 -0
  124. package/templates/firebase/assets/images/update.png +0 -0
  125. package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
  126. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
  127. package/templates/firebase/login-redesign-preview.png +0 -0
@@ -1,35 +1,39 @@
1
1
  # send-push-notification
2
2
 
3
- Edge Function que envia push notifications via Firebase Cloud Messaging (FCM) quando uma notificação é inserida na tabela `notifications`.
3
+ Edge Function que envia push notifications via Firebase Cloud Messaging (FCM)
4
+ quando uma notificação é inserida na tabela `notifications`.
4
5
 
5
- ## Setup (1x por projeto)
6
+ ## Tudo isto é automático
6
7
 
7
- ### 1. Configurar secrets no Supabase
8
+ O `kasy new` e o `kasy deploy` já deixam o push funcionando no Supabase, sem passo
9
+ manual:
8
10
 
9
- ```bash
10
- supabase secrets set FIREBASE_PROJECT_ID=your-firebase-project-id
11
- supabase secrets set FIREBASE_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"..."}'
12
- ```
11
+ - **Secrets** `FIREBASE_PROJECT_ID` e `FIREBASE_SERVICE_ACCOUNT_JSON` são definidos
12
+ automaticamente (a chave de Service Account é gerada pela CLI).
13
+ - **A chamada automática** da função quando uma notificação é inserida vem de um
14
+ **trigger no banco** (`pg_net`), criado pela migration
15
+ `20240101000006_notification_webhook.sql`. **Não é** um Database Webhook do painel.
16
+ - O trigger só dispara quando `notify_user IS DISTINCT FROM false` (ex.: a notificação
17
+ de boas-vindas usa `notify_user = false` porque o usuário já está dentro do app).
18
+
19
+ > ⚠️ **Não crie um Database Webhook no painel** apontando para esta função. O trigger
20
+ > `pg_net` já faz isso; um webhook duplicado faria o push **disparar duas vezes**.
13
21
 
14
- O JSON do service account: Firebase Console → Project Settings → Service Accounts → **Generate new private key**.
22
+ ## Fallback manual (só se a automação falhar)
15
23
 
16
- ### 2. Deploy da Edge Function
24
+ Se por algum motivo os secrets não tiverem sido definidos, rode:
17
25
 
18
26
  ```bash
27
+ supabase secrets set FIREBASE_PROJECT_ID=your-firebase-project-id
28
+ supabase secrets set FIREBASE_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"..."}'
19
29
  supabase functions deploy send-push-notification --project-ref YOUR_PROJECT_REF
20
30
  ```
21
31
 
22
- ### 3. Configurar Database Webhook
23
-
24
- No Supabase Dashboard → **Database → Webhooks → Create webhook**:
25
-
26
- | Campo | Valor |
27
- |-------|-------|
28
- | Name | `on_notification_inserted` |
29
- | Table | `notifications` |
30
- | Events | `INSERT` |
31
- | Method | POST |
32
- | URL | `https://YOUR_PROJECT_REF.supabase.co/functions/v1/send-push-notification` |
33
- | Headers | `Authorization: Bearer YOUR_SUPABASE_ANON_KEY` |
32
+ O JSON do service account: Firebase Console → Project Settings → Service Accounts →
33
+ **Generate new private key**.
34
34
 
35
- Sem este webhook, a edge function não é chamada automaticamente.
35
+ Se preferir o trigger via painel em vez do `pg_net` (não recomendado, e nunca os dois
36
+ ao mesmo tempo), use Database → Webhooks → Create webhook na tabela `notifications`,
37
+ evento `INSERT`, POST para
38
+ `https://YOUR_PROJECT_REF.supabase.co/functions/v1/send-push-notification`,
39
+ header `Authorization: Bearer YOUR_SUPABASE_ANON_KEY`.
@@ -1,22 +1,19 @@
1
1
  /**
2
2
  * Supabase Edge Function: Send Push Notification via FCM
3
3
  *
4
- * Triggered by a Supabase Database Webhook when a row is inserted
5
- * into the `notifications` table. Reads device FCM tokens for the
6
- * target user and sends a push notification via Firebase Cloud Messaging
7
- * HTTP v1 API.
4
+ * Triggered automatically by a database trigger (pg_net) when a row is inserted
5
+ * into the `notifications` table with notify_user != false (migration
6
+ * 20240101000006_notification_webhook.sql) NOT by a Dashboard Database Webhook.
7
+ * Reads device FCM tokens for the target user and sends a push notification via
8
+ * Firebase Cloud Messaging HTTP v1 API.
8
9
  *
9
- * Secrets required (set via `supabase secrets set`):
10
+ * Secrets (set automatically by `kasy new` / `kasy deploy`):
10
11
  * - FIREBASE_PROJECT_ID: Your Firebase project ID (e.g. my-app-12345)
11
12
  * - FIREBASE_SERVICE_ACCOUNT_JSON: Full JSON of your Firebase service account key
12
13
  * (Firebase Console → Project Settings → Service Accounts → Generate new private key)
13
14
  *
14
- * Database Webhook setup (Supabase Dashboard Database → Webhooks):
15
- * - Table: notifications
16
- * - Event: INSERT
17
- * - Method: POST
18
- * - URL: https://<PROJECT_REF>.supabase.co/functions/v1/send-push-notification
19
- * - HTTP Headers: Authorization: Bearer <SUPABASE_ANON_KEY>
15
+ * Do NOT also create a Dashboard Database Webhook for this function the pg_net
16
+ * trigger already calls it; a second webhook would double-fire push. See README.md.
20
17
  *
21
18
  * @see https://firebase.google.com/docs/cloud-messaging/send-message
22
19
  */
@@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => {
97
97
  }
98
98
  const uid = user.id;
99
99
 
100
- let body: { priceId?: string; successUrl?: string; cancelUrl?: string; locale?: string };
100
+ let body: { priceId?: string; successUrl?: string; cancelUrl?: string; locale?: string; allowPromoCodes?: boolean };
101
101
  try {
102
102
  body = await req.json();
103
103
  } catch {
@@ -110,6 +110,7 @@ Deno.serve(async (req: Request) => {
110
110
  const successUrl = body.successUrl ?? "";
111
111
  const cancelUrl = body.cancelUrl ?? successUrl;
112
112
  const locale = body.locale?.substring(0, 2).toLowerCase();
113
+ const allowPromoCodes = body.allowPromoCodes === true;
113
114
 
114
115
  try {
115
116
  const stripe = new Stripe(secretKey);
@@ -136,6 +137,7 @@ Deno.serve(async (req: Request) => {
136
137
  metadata: { supabaseUID: uid },
137
138
  ...(trialDays ? { trial_period_days: trialDays } : {}),
138
139
  },
140
+ ...(allowPromoCodes ? { allow_promotion_codes: true } : {}),
139
141
  });
140
142
  return Response.json({ url: session.url }, { headers: corsHeaders });
141
143
  } catch (err) {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Supabase Edge Function: Stripe — Create Customer Portal Session
3
3
  *
4
- * Creates a Stripe Customer Portal session (manage / cancel) for the
5
- * authenticated user and returns its URL. The user is identified by the
4
+ * Creates a Stripe Customer Portal session (manage / cancel / switch plan) for
5
+ * the authenticated user and returns its URL. The user is identified by the
6
6
  * verified JWT and the Stripe customer is looked up server-side.
7
7
  *
8
8
  * Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
@@ -13,7 +13,10 @@
13
13
  * - STRIPE_SECRET_KEY
14
14
  * - SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_SERVICE_ROLE_KEY (auto-provided)
15
15
  *
16
- * Body: { returnUrl?: string }
16
+ * Optional env var:
17
+ * - STRIPE_PRODUCT_ID: narrows plan-switching to a single product's prices.
18
+ *
19
+ * Body: { returnUrl?: string, planSwitching?: boolean }
17
20
  */
18
21
 
19
22
  import Stripe from "npm:stripe@18";
@@ -39,6 +42,52 @@ async function getUid(req: Request, supabaseUrl: string, anonKey: string): Promi
39
42
  return user.id;
40
43
  }
41
44
 
45
+ // Creates (once) a Customer Portal configuration with subscription_update
46
+ // (plan switching) enabled, then reuses it on every subsequent call.
47
+ // Uses the Stripe list API to find an existing config — no extra DB table needed.
48
+ // deno-lint-ignore no-explicit-any
49
+ async function getOrCreatePortalConfig(stripe: Stripe): Promise<string | undefined> {
50
+ // Reuse an existing active config with plan switching already enabled
51
+ const configs = await stripe.billingPortal.configurations.list({ active: true, limit: 20 });
52
+ // deno-lint-ignore no-explicit-any
53
+ const existing = configs.data.find((c: any) => c.features?.subscription_update?.enabled);
54
+ if (existing) return existing.id;
55
+
56
+ // Build the price list grouped by product so Stripe knows what to switch between
57
+ const productId = Deno.env.get("STRIPE_PRODUCT_ID") ?? "";
58
+ // deno-lint-ignore no-explicit-any
59
+ const priceListParams: any = { active: true, type: "recurring", limit: 100 };
60
+ if (productId) priceListParams.product = productId;
61
+ const { data: prices } = await stripe.prices.list(priceListParams);
62
+
63
+ if (prices.length === 0) return undefined;
64
+
65
+ const byProduct: Record<string, string[]> = {};
66
+ for (const p of prices) {
67
+ // deno-lint-ignore no-explicit-any
68
+ const pid = typeof p.product === "string" ? p.product : (p.product as any).id;
69
+ if (!byProduct[pid]) byProduct[pid] = [];
70
+ byProduct[pid].push(p.id);
71
+ }
72
+ const products = Object.entries(byProduct).map(([prod, priceIds]) => ({
73
+ product: prod,
74
+ prices: priceIds,
75
+ }));
76
+
77
+ const config = await stripe.billingPortal.configurations.create({
78
+ features: {
79
+ subscription_update: {
80
+ enabled: true,
81
+ default_allowed_updates: ["price"],
82
+ products,
83
+ },
84
+ subscription_cancel: { enabled: true, mode: "at_period_end" },
85
+ payment_method_update: { enabled: true },
86
+ },
87
+ });
88
+ return config.id;
89
+ }
90
+
42
91
  Deno.serve(async (req: Request) => {
43
92
  if (req.method === "OPTIONS") {
44
93
  return new Response(null, { status: 204, headers: corsHeaders });
@@ -61,9 +110,11 @@ Deno.serve(async (req: Request) => {
61
110
  }
62
111
 
63
112
  let returnUrl = "";
113
+ let planSwitching = false;
64
114
  try {
65
115
  const body = await req.json();
66
116
  returnUrl = (body?.returnUrl as string | undefined) ?? "";
117
+ planSwitching = body?.planSwitching === true;
67
118
  } catch {
68
119
  // body is optional
69
120
  }
@@ -84,9 +135,15 @@ Deno.serve(async (req: Request) => {
84
135
  }
85
136
 
86
137
  const stripe = new Stripe(secretKey);
138
+
139
+ // When plan switching is requested, resolve (or create) a portal configuration
140
+ // with subscription_update enabled — no manual Stripe dashboard setup needed.
141
+ const configId = planSwitching ? await getOrCreatePortalConfig(stripe) : undefined;
142
+
87
143
  const session = await stripe.billingPortal.sessions.create({
88
144
  customer: customerId,
89
145
  return_url: returnUrl,
146
+ ...(configId ? { configuration: configId } : {}),
90
147
  });
91
148
  return Response.json({ url: session.url }, { headers: corsHeaders });
92
149
  } catch (err) {
@@ -150,6 +150,13 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
150
150
 
151
151
  @override
152
152
  Future<Credentials> signinWithApple() async {
153
+ // Apple on web needs a paid Apple Service ID + secret (not configured here);
154
+ // getAppleIDCredential force-unwraps webAuthenticationOptions and crashes on
155
+ // web. The UI hides the Apple button on web; guard here too so a programmatic
156
+ // call fails with a clear error instead of a null-check crash.
157
+ if (kIsWeb) {
158
+ throw ApiError(code: 501, message: 'Apple sign-in on web is not supported.');
159
+ }
153
160
  final rawNonce = client.auth.generateRawNonce();
154
161
  final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
155
162
 
@@ -185,6 +192,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
185
192
 
186
193
  @override
187
194
  Future<Credentials> signinWithFacebook() async {
195
+ // Facebook on web for Supabase is not wired yet (roadmap); the button is hidden
196
+ // on web, so this is a defensive guard.
197
+ if (kIsWeb) {
198
+ throw ApiError(code: 501, message: 'Facebook sign-in on web is not supported on Supabase.');
199
+ }
188
200
  final loginResult = await FacebookAuth.instance.login(
189
201
  permissions: ['email', 'public_profile'],
190
202
  );
@@ -309,6 +321,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
309
321
 
310
322
  @override
311
323
  Future<Credentials> signupFromAnonymousWithApple() async {
324
+ // See signinWithApple: Apple on web is unsupported here; the UI hides the button
325
+ // on web, and this guard prevents a null-check crash on a programmatic call.
326
+ if (kIsWeb) {
327
+ throw ApiError(code: 501, message: 'Apple sign-in on web is not supported.');
328
+ }
312
329
  final rawNonce = client.auth.generateRawNonce();
313
330
  final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
314
331
 
@@ -427,6 +444,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
427
444
 
428
445
  @override
429
446
  Future<Credentials> signupFromAnonymousWithFacebook() async {
447
+ // Facebook on web for Supabase is not wired yet (roadmap); the button is hidden
448
+ // on web, so this is a defensive guard.
449
+ if (kIsWeb) {
450
+ throw ApiError(code: 501, message: 'Facebook sign-in on web is not supported on Supabase.');
451
+ }
430
452
  final loginResult = await FacebookAuth.instance.login(
431
453
  permissions: ['email', 'public_profile'],
432
454
  );
@@ -30,6 +30,7 @@ class StripeBackendApi {
30
30
  String? successUrl,
31
31
  String? cancelUrl,
32
32
  String? locale,
33
+ bool? allowPromoCodes,
33
34
  }) async {
34
35
  final res = await _client.functions.invoke(
35
36
  'stripe-create-checkout-session',
@@ -38,16 +39,25 @@ class StripeBackendApi {
38
39
  if (successUrl != null) 'successUrl': successUrl,
39
40
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
40
41
  if (locale != null) 'locale': locale,
42
+ if (allowPromoCodes != null) 'allowPromoCodes': allowPromoCodes,
41
43
  },
42
44
  );
43
45
  return (res.data as Map)['url'] as String;
44
46
  }
45
47
 
46
48
  /// Create a Customer Portal session (manage / cancel) and return its URL.
47
- Future<String> createPortalSession({String? returnUrl}) async {
49
+ /// Pass [planSwitching] = true to auto-configure the portal with
50
+ /// upgrade/downgrade support (no manual Stripe dashboard setup needed).
51
+ Future<String> createPortalSession({
52
+ String? returnUrl,
53
+ bool? planSwitching,
54
+ }) async {
48
55
  final res = await _client.functions.invoke(
49
56
  'stripe-create-portal-session',
50
- body: {if (returnUrl != null) 'returnUrl': returnUrl},
57
+ body: {
58
+ if (returnUrl != null) 'returnUrl': returnUrl,
59
+ if (planSwitching != null) 'planSwitching': planSwitching,
60
+ },
51
61
  );
52
62
  return (res.data as Map)['url'] as String;
53
63
  }
@@ -25,12 +25,13 @@ dependencies:
25
25
  dio: ^5.9.2
26
26
  facebook_app_events: ^0.24.0
27
27
  firebase_app_installations: ^0.4.0+7
28
- firebase_core: ^4.5.0
29
28
  # Web-only Google sign-in: on web, google_sign_in v7 can't do imperative auth, so we
30
- # get the Google ID token via Firebase's popup (zero manual config reuses the
29
+ # get the Google ID token via Firebase's popup (zero manual config, reuses the
31
30
  # Firebase web client + authorized domains) and hand it to Supabase signInWithIdToken.
32
31
  # Supabase stays the auth backend; mobile keeps the native google_sign_in flow.
32
+ # Kept before firebase_core so the deps stay alphabetical (sort_pub_dependencies).
33
33
  firebase_auth: ^6.1.4
34
+ firebase_core: ^4.5.0
34
35
  firebase_messaging: ^16.1.2
35
36
  firebase_remote_config: ^6.2.0
36
37
  flutter:
@@ -219,7 +219,7 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
219
219
  await writeVsCodeLaunch(targetDir, appName, backend, modules, answers, language);
220
220
  await writeEnvExample(targetDir, modules, answers, language);
221
221
  await writeEnvFileIfMissing(targetDir);
222
- await writeFeaturesConfig(targetDir, modules, answers, language);
222
+ await writeFeaturesConfig(targetDir, modules, answers, language, backend);
223
223
  await writeRouter(targetDir, modules, packageName, moduleAnswers.defaultPaywall || 'basic');
224
224
  await writeMakefile(targetDir, language, backend, modules, answers);
225
225
  await writeKitSetup(targetDir, {
@@ -340,6 +340,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
340
340
  lines.push(`import 'package:flutter_riverpod/flutter_riverpod.dart';`);
341
341
  lines.push(`import 'package:go_router/go_router.dart';`);
342
342
  lines.push(`import 'package:${pkg}/core/bottom_menu/bottom_menu.dart';`);
343
+ lines.push(`import 'package:${pkg}/core/chrome/chrome_visibility.dart';`);
343
344
  if (withAnalytics) {
344
345
  lines.push(`import 'package:${pkg}/core/data/api/analytics_api.dart';`);
345
346
  }
@@ -417,6 +418,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
417
418
  if (withAnalytics) {
418
419
  lines.push(` AnalyticsObserver(analyticsApi: MixpanelAnalyticsApi.instance()),`);
419
420
  }
421
+ lines.push(` KasyChromeVisibilityObserver(),`);
420
422
  lines.push(` ...?observers,`);
421
423
  lines.push(` ],`);
422
424
  lines.push(` routes: [`);
@@ -616,7 +618,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
616
618
  * @param {object} [answers={}] - moduleAnswers (rcTestKey, stripe keys, etc.)
617
619
  * @param {string} [language='en'] - User's CLI language (en, pt, es)
618
620
  */
619
- async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en') {
621
+ async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en', backend = 'firebase') {
620
622
  const withOnboarding = modules.includes('onboarding');
621
623
  const withAiChat = modules.includes('ai_chat');
622
624
  const withFeedback = modules.includes('feedback');
@@ -624,6 +626,15 @@ async function writeFeaturesConfig(projectDir, modules, answers = {}, language =
624
626
  const withStripe = modules.includes('stripe');
625
627
  const withLocalReminders = modules.includes('local_reminders');
626
628
  const withWeb = modules.includes('web');
629
+ // Apple sign-in on web needs a Service ID + signed secret that don't exist until
630
+ // the developer configures it (`kasy apple-web`). Until then, showing the button
631
+ // means a dead button on web, so it ships false on every backend and the command
632
+ // flips it to true once web Apple actually works. Native always shows it.
633
+ const withAppleWebSignin = false;
634
+ // Facebook sign-in on web works on the Firebase backend (signInWithPopup) after
635
+ // `kasy facebook`; on Supabase the web flow isn't wired yet (roadmap). Ships false
636
+ // on every backend (the command flips it to true on Firebase). Native always shows.
637
+ const withFacebookWebSignin = false;
627
638
 
628
639
  const f = getStrings(language).features;
629
640
  const content = `${f.comment1}
@@ -635,7 +646,19 @@ const bool withFeedback = ${withFeedback};
635
646
  const bool withRevenuecat = ${withRevenuecat};
636
647
  // Stripe web subscriptions module (independent from RevenueCat mobile IAP).
637
648
  const bool withStripe = ${withStripe};
649
+ // When true, Stripe Checkout shows a promo-code / coupon field.
650
+ const bool withStripePromoCodes = true;
651
+ // When true, the Stripe Customer Portal lets subscribers switch plans (upgrade / downgrade).
652
+ const bool withStripePlanSwitching = true;
638
653
  const bool withLocalReminders = ${withLocalReminders};
654
+ // Apple sign-in on web: ships false until configured with \`kasy apple-web\` (needs a
655
+ // paid Apple Service ID + signed secret). The command flips this to true once web
656
+ // Apple actually works, so the button never appears dead. Native always shows it.
657
+ const bool withAppleWebSignin = ${withAppleWebSignin};
658
+ // Facebook sign-in on web: ships false until configured with \`kasy facebook\` on the
659
+ // Firebase backend (signInWithPopup). On Supabase the web flow is roadmap, so it stays
660
+ // false there. Native (iOS/Android) always shows the Facebook button.
661
+ const bool withFacebookWebSignin = ${withFacebookWebSignin};
639
662
  ${f.comment3}
640
663
  ${f.comment4}
641
664
  ${f.comment5}
@@ -1347,10 +1370,18 @@ async function removeFacebookSigninFromAuthPages(projectDir) {
1347
1370
  for (const p of pages) {
1348
1371
  if (!(await fs.pathExists(p))) continue;
1349
1372
  let content = await fs.readFile(p, 'utf8');
1350
- // Remove import line
1373
+ // Legacy: remove the standalone FacebookSigninComponent + its import, if present.
1351
1374
  content = content.replace(/^import 'package:[^']+\/features\/authentication\/ui\/components\/facebook_signin\.dart';\n/m, '');
1352
- // Remove component line (any leading whitespace)
1353
1375
  content = content.replace(/[ \t]*const FacebookSigninComponent\(\),\n/g, '');
1376
+ // Current UI: the Facebook button is an inline _SocialSigninTile/_SocialSignupTile
1377
+ // in the social row (label: t.auth.signin.facebook ... signinWithFacebook()). Strip
1378
+ // the whole tile plus the SizedBox spacer that precedes it, anchored on the facebook
1379
+ // label so the Google/Apple tiles are never touched. Runs before dartFix/format, so
1380
+ // it matches the kit's raw formatting verbatim.
1381
+ content = content.replace(
1382
+ /\n[ \t]*const SizedBox\(width: KasySpacing\.sm\),\n[ \t]*Expanded\(\n[ \t]*child: _Social(?:Signin|Signup)Tile\(\n[ \t]*label: t\.auth\.signin\.facebook,[\s\S]*?\.signinWithFacebook\(\),\n[ \t]*\),\n[ \t]*\),/,
1383
+ '',
1384
+ );
1354
1385
  await fs.writeFile(p, content, 'utf8');
1355
1386
  }
1356
1387
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Apple "Sign in with Apple" WEB support helpers.
3
+ *
4
+ * The native iOS/macOS flow needs no secret — the CLI already enables it on both
5
+ * backends at project creation. The WEB (and Android, which we hide) flow uses
6
+ * Apple's OAuth, which requires a Service ID + a client secret that is a short-lived
7
+ * JWT signed with the developer's `.p8` private key.
8
+ *
9
+ * Apple offers NO API to create the Service ID or the `.p8` key — those stay manual
10
+ * on developer.apple.com (done once per Apple account). What we CAN automate is
11
+ * taking the four inputs the developer already created and pushing them to the
12
+ * backend:
13
+ * - Firebase: store Service ID + Team ID + Key ID + `.p8` in the Apple provider
14
+ * (Firebase re-signs the JWT itself, so it never expires).
15
+ * - Supabase: sign the JWT here and store it as external_apple_secret (Supabase
16
+ * cannot re-sign, so this expires every 6 months and must be regenerated).
17
+ *
18
+ * This module: (1) signs the Apple client-secret JWT with node:crypto (no extra
19
+ * dependency), and (2) caches the four inputs in ~/.kasy/apple-web.json so future
20
+ * projects (and the 6-month Supabase renewal) configure web Apple without re-asking.
21
+ */
22
+
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+ const crypto = require('node:crypto');
26
+ const fs = require('fs-extra');
27
+
28
+ // Apple caps the client-secret JWT lifetime at 6 months. We sign for 180 days to
29
+ // stay safely under the limit. Firebase ignores this (it re-signs); Supabase stores
30
+ // the JWT verbatim, so this is the value behind its "expires every 6 months" notice.
31
+ const APPLE_SECRET_MAX_SECONDS = 180 * 24 * 60 * 60;
32
+
33
+ const CONFIG_DIR = path.join(os.homedir(), '.kasy');
34
+ const APPLE_WEB_CONFIG_PATH = path.join(CONFIG_DIR, 'apple-web.json');
35
+
36
+ /** base64url-encode a string or Buffer (no padding, URL-safe alphabet). */
37
+ function base64url(input) {
38
+ return Buffer.from(input)
39
+ .toString('base64')
40
+ .replace(/=+$/g, '')
41
+ .replace(/\+/g, '-')
42
+ .replace(/\//g, '_');
43
+ }
44
+
45
+ /**
46
+ * Normalize a `.p8` private key that may arrive with literal "\n" sequences
47
+ * (common when pasted from a one-line env var) into real newlines so
48
+ * crypto.createPrivateKey can parse the PEM.
49
+ */
50
+ function normalizePrivateKey(privateKey) {
51
+ const key = String(privateKey || '').trim();
52
+ if (key.includes('-----BEGIN') && !key.includes('\n') && key.includes('\\n')) {
53
+ return key.replace(/\\n/g, '\n');
54
+ }
55
+ return key;
56
+ }
57
+
58
+ /**
59
+ * Sign the Apple "Sign in with Apple" client secret (an ES256 JWT).
60
+ *
61
+ * @param {object} opts
62
+ * @param {string} opts.serviceId - Apple Service ID (the OAuth client_id), e.g. com.acme.app.signin
63
+ * @param {string} opts.teamId - Apple Developer Team ID
64
+ * @param {string} opts.keyId - Key ID of the .p8
65
+ * @param {string} opts.privateKey - PEM contents of the .p8 (PKCS#8 EC P-256)
66
+ * @param {number} [opts.expiresInSeconds] - lifetime; clamped to Apple's 6-month max
67
+ * @returns {{ token: string, issuedAt: number, expiresAt: number }}
68
+ */
69
+ function signAppleClientSecret({ serviceId, teamId, keyId, privateKey, expiresInSeconds = APPLE_SECRET_MAX_SECONDS } = {}) {
70
+ if (!serviceId) throw new Error('serviceId (Apple Service ID) is required');
71
+ if (!teamId) throw new Error('teamId (Apple Team ID) is required');
72
+ if (!keyId) throw new Error('keyId is required');
73
+ if (!privateKey) throw new Error('privateKey (.p8 contents) is required');
74
+
75
+ const issuedAt = Math.floor(Date.now() / 1000);
76
+ const expiresAt = issuedAt + Math.min(expiresInSeconds, APPLE_SECRET_MAX_SECONDS);
77
+
78
+ const header = { alg: 'ES256', kid: keyId };
79
+ const payload = {
80
+ iss: teamId,
81
+ iat: issuedAt,
82
+ exp: expiresAt,
83
+ aud: 'https://appleid.apple.com',
84
+ sub: serviceId,
85
+ };
86
+
87
+ const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
88
+
89
+ let keyObject;
90
+ try {
91
+ keyObject = crypto.createPrivateKey(normalizePrivateKey(privateKey));
92
+ } catch (err) {
93
+ throw new Error(`Invalid Apple .p8 private key: ${err.message}`);
94
+ }
95
+
96
+ // ES256 JWTs need the raw r||s signature (IEEE P1363), not DER.
97
+ const signature = crypto.sign('sha256', Buffer.from(signingInput), {
98
+ key: keyObject,
99
+ dsaEncoding: 'ieee-p1363',
100
+ });
101
+
102
+ return { token: `${signingInput}.${base64url(signature)}`, issuedAt, expiresAt };
103
+ }
104
+
105
+ /**
106
+ * Load cached Apple web credentials from ~/.kasy/apple-web.json.
107
+ * Returns null if absent or incomplete.
108
+ */
109
+ async function loadAppleWebCreds() {
110
+ try {
111
+ if (!(await fs.pathExists(APPLE_WEB_CONFIG_PATH))) return null;
112
+ const data = await fs.readJson(APPLE_WEB_CONFIG_PATH);
113
+ if (!data || !data.serviceId || !data.teamId || !data.keyId || !data.privateKey) return null;
114
+ return data;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Persist Apple web credentials to ~/.kasy/apple-web.json with 0600 perms so the
122
+ * 6-month Supabase renewal and future projects can reuse them without re-asking.
123
+ */
124
+ async function saveAppleWebCreds({ serviceId, teamId, keyId, privateKey }) {
125
+ await fs.ensureDir(CONFIG_DIR);
126
+ await fs.writeJson(
127
+ APPLE_WEB_CONFIG_PATH,
128
+ { serviceId, teamId, keyId, privateKey: normalizePrivateKey(privateKey) },
129
+ { spaces: 2 },
130
+ );
131
+ // Best effort: restrict to the owner (no-op / unsupported on some Windows setups).
132
+ try {
133
+ await fs.chmod(APPLE_WEB_CONFIG_PATH, 0o600);
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+
139
+ module.exports = {
140
+ APPLE_SECRET_MAX_SECONDS,
141
+ APPLE_WEB_CONFIG_PATH,
142
+ base64url,
143
+ normalizePrivateKey,
144
+ signAppleClientSecret,
145
+ loadAppleWebCreds,
146
+ saveAppleWebCreds,
147
+ };