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.
- package/bin/kasy.js +42 -0
- package/lib/commands/apple-web.js +222 -0
- package/lib/commands/configure.js +3 -91
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/facebook.js +189 -0
- package/lib/commands/new.js +65 -3
- package/lib/scaffold/CHANGELOG.json +27 -0
- package/lib/scaffold/backends/api/patch/README.md +87 -2
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +186 -0
- package/lib/scaffold/backends/supabase/deploy.js +92 -0
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +22 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +3 -2
- package/lib/scaffold/generate.js +1 -1
- package/lib/scaffold/shared/generator-utils.js +34 -3
- package/lib/utils/apple-web.js +147 -0
- package/lib/utils/facebook.js +162 -0
- package/lib/utils/i18n/messages-en.js +64 -0
- package/lib/utils/i18n/messages-es.js +64 -0
- package/lib/utils/i18n/messages-pt.js +64 -0
- package/package.json +2 -2
- package/templates/firebase/AGENTS.md +87 -0
- package/templates/firebase/CLAUDE.md +16 -0
- package/templates/firebase/DESIGN_SYSTEM.md +234 -0
- package/templates/firebase/docs/auth-setup.en.md +7 -1
- package/templates/firebase/docs/auth-setup.es.md +7 -1
- package/templates/firebase/docs/auth-setup.pt.md +7 -1
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
- package/templates/firebase/lib/components/components.dart +1 -0
- package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
- package/templates/firebase/lib/components/kasy_alert.dart +1 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +7 -4
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/components/kasy_button.dart +8 -8
- package/templates/firebase/lib/components/kasy_chip.dart +1 -1
- package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
- package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
- package/templates/firebase/lib/components/kasy_screen.dart +114 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +2 -2
- package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
- package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
- package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
- package/templates/firebase/lib/components/kasy_toast.dart +39 -70
- package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
- package/templates/firebase/lib/core/config/features.dart +18 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
- package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +46 -124
- package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
- package/templates/firebase/lib/core/theme/shadows.dart +13 -0
- package/templates/firebase/lib/core/theme/texts.dart +32 -0
- package/templates/firebase/lib/core/theme/theme.dart +2 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
- package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
- package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
- package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
- package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
- package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
- package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +57 -29
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +47 -25
- package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -3
- package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +54 -3
- package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +104 -156
- package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +3 -2
- package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +4 -4
- package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
- package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
- package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
- package/templates/firebase/lib/i18n/en.i18n.json +13 -4
- package/templates/firebase/lib/i18n/es.i18n.json +13 -4
- package/templates/firebase/lib/i18n/pt.i18n.json +13 -4
- package/templates/firebase/lib/router.dart +2 -0
- package/templates/firebase/pubspec.yaml +1 -2
- package/templates/firebase/tool/design_check.dart +152 -0
- package/templates/firebase/web/stripe_success.html +64 -26
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
- package/templates/firebase/assets/images/review.png +0 -0
- package/templates/firebase/assets/images/update.png +0 -0
- package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
- package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
- 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)
|
|
3
|
+
Edge Function que envia push notifications via Firebase Cloud Messaging (FCM)
|
|
4
|
+
quando uma notificação é inserida na tabela `notifications`.
|
|
4
5
|
|
|
5
|
-
##
|
|
6
|
+
## Tudo isto é automático
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
O `kasy new` e o `kasy deploy` já deixam o push funcionando no Supabase, sem passo
|
|
9
|
+
manual:
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
22
|
+
## Fallback manual (só se a automação falhar)
|
|
15
23
|
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
* into the `notifications` table
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
15
|
-
*
|
|
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
|
*/
|
package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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) {
|
package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart
CHANGED
|
@@ -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
|
);
|
package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
|
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:
|
package/lib/scaffold/generate.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
+
};
|