diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 88e9fbaa..80c489c1 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -24,7 +24,7 @@ jobs: continue-on-error: true - name: Typecheck - run: npx tsc --noEmit + run: npx prisma generate && npx tsc --noEmit build-macos: runs-on: macos-latest diff --git a/components/dashboard/account/SubscriptionSettings.module.css b/components/dashboard/account/SubscriptionSettings.module.css index 05722580..058d4329 100644 --- a/components/dashboard/account/SubscriptionSettings.module.css +++ b/components/dashboard/account/SubscriptionSettings.module.css @@ -170,6 +170,16 @@ margin: 0; } +.errorMessage { + font-size: 0.82rem; + color: var(--error); + padding: 8px 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--error) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--error) 20%, transparent); + margin: 0; +} + .upgradeBtn { display: flex; align-items: center; diff --git a/components/dashboard/account/SubscriptionSettings.tsx b/components/dashboard/account/SubscriptionSettings.tsx index 31dfc42a..5e5c0a86 100644 --- a/components/dashboard/account/SubscriptionSettings.tsx +++ b/components/dashboard/account/SubscriptionSettings.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { ArrowRight, Check, Lock } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; -import { cancelStripeSubscription, createStripeCheckout, submitApplePurchase } from "@src/lib/utils/requests"; +import { cancelStripeSubscription, createStripeCheckout, getAppleSubscriptionOwner, submitApplePurchase } from "@src/lib/utils/requests"; import { useUser } from "@src/lib/utils/hooks"; import styles from "./SubscriptionSettings.module.css"; @@ -56,13 +56,22 @@ const SubscriptionSettings = () => { return () => { cancelled = true; }; }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps + const showOwnerError = async (jwsRepresentation: string) => { + const ownerEmail = await getAppleSubscriptionOwner(jwsRepresentation); + if (ownerEmail) { + setError(t("subscription.alreadyBoundTo", { email: ownerEmail })); + } else { + setError(t("subscription.alreadyBoundUnknown")); + } + }; + const handleUpgrade = async () => { setError(null); setUpgradeLoading(true); if (isTauri()) { try { - const { purchase, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); + const { purchase, restorePurchases, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); const result = await purchase(APPLE_PRODUCT_ID, "subs", { appAccountToken: user?.id, }); @@ -76,9 +85,25 @@ const SubscriptionSettings = () => { if (ok) { await mutate(); } else { - setError(t("subscription.purchaseError")); + await showOwnerError(result.jwsRepresentation); } } catch (err) { + // StoreKit may throw when the Apple ID already has an active subscription. + // Restore purchases to find the existing JWS and identify the linked account. + try { + const { restorePurchases, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); + const { purchases } = await restorePurchases("subs"); + const existing = purchases.find( + (p) => p.productId === APPLE_PRODUCT_ID + && p.purchaseState === PurchaseState.PURCHASED + && p.jwsRepresentation, + ); + if (existing?.jwsRepresentation) { + await showOwnerError(existing.jwsRepresentation); + return; + } + } catch { /* ignore */ } + console.error("[SubscriptionSettings] Apple IAP failed:", err); setError(t("subscription.purchaseError")); } finally { @@ -194,7 +219,7 @@ const SubscriptionSettings = () => { )} - {error &&
{error}
} + {error &&{error}
} ); }; diff --git a/components/projects/CreateProjectPage.tsx b/components/projects/CreateProjectPage.tsx index edab4e63..90946933 100644 --- a/components/projects/CreateProjectPage.tsx +++ b/components/projects/CreateProjectPage.tsx @@ -62,7 +62,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { if (selectedFile) { body.poster = await cropImageBase64(selectedFile, 686, 1016); } - const res = await createProject(user.id, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (res.ok && json.data) { projectId = json.data.id; @@ -104,7 +104,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { body.poster = await cropImageBase64(selectedFile, 686, 1016); } - const res = await createProject(user.id, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (!res.ok || !json.data) { setFormInfo({ content: json.message!, isError: true }); diff --git a/messages/de.json b/messages/de.json index dce8e7f5..a0c0217d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -148,7 +148,9 @@ "purchasing": "Kauf wird durchgeführt...", "purchaseError": "Kauf fehlgeschlagen. Bitte erneut versuchen.", "cancelApple": "Apple-Abonnements werden über den App Store verwaltet.", - "manageApple": "App Store öffnen" + "manageApple": "App Store öffnen", + "alreadyBoundTo": "Dieses Abonnement ist bereits mit {email} verknüpft.", + "alreadyBoundUnknown": "Dieses Abonnement ist bereits mit einem anderen Konto verknüpft." } }, "projects": { @@ -465,4 +467,4 @@ "monthsAgo": "Vor {months, plural, one {# Monat} other {# Monaten}}", "moreThanYearAgo": "Vor über einem Jahr" } -} +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 39d3bb9e..4bcca531 100644 --- a/messages/en.json +++ b/messages/en.json @@ -147,7 +147,9 @@ "purchasing": "Purchasing...", "purchaseError": "Purchase failed. Please try again.", "cancelApple": "Apple subscriptions are managed through the App Store.", - "manageApple": "Open App Store" + "manageApple": "Open App Store", + "alreadyBoundTo": "This subscription is already linked to {email}.", + "alreadyBoundUnknown": "This subscription is already linked to another account." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months, plural, one {# month ago} other {# months ago}}", "moreThanYearAgo": "More than 1 year ago" } -} +} \ No newline at end of file diff --git a/messages/es.json b/messages/es.json index ab41adb9..f62a42d6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -147,7 +147,9 @@ "purchasing": "Comprando...", "purchaseError": "La compra falló. Inténtalo de nuevo.", "cancelApple": "Las suscripciones de Apple se gestionan a través del App Store.", - "manageApple": "Abrir App Store" + "manageApple": "Abrir App Store", + "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", + "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", "moreThanYearAgo": "Hace más de 1 año" } -} +} \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json index dc30105a..f2b1eb62 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -148,7 +148,9 @@ "purchasing": "Achat en cours...", "purchaseError": "L'achat a échoué. Veuillez réessayer.", "cancelApple": "Les abonnements Apple sont gérés via l'App Store.", - "manageApple": "Ouvrir l'App Store" + "manageApple": "Ouvrir l'App Store", + "alreadyBoundTo": "Cet abonnement est déjà associé à {email}.", + "alreadyBoundUnknown": "Cet abonnement est déjà associé à un autre compte." } }, "projects": { @@ -465,4 +467,4 @@ "monthsAgo": "Il y a {months, plural, one {# mois} other {# mois}}", "moreThanYearAgo": "Il y a plus d'un an" } -} +} \ No newline at end of file diff --git a/messages/ja.json b/messages/ja.json index 84f66eee..f6428a42 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -147,7 +147,9 @@ "purchasing": "購入中...", "purchaseError": "購入に失敗しました。もう一度お試しください。", "cancelApple": "Appleのサブスクリプションはアプリストアで管理されます。", - "manageApple": "App Storeを開く" + "manageApple": "App Storeを開く", + "alreadyBoundTo": "このサブスクリプションは既に {email} に関連付けられています。", + "alreadyBoundUnknown": "このサブスクリプションは既に別のアカウントに関連付けられています。" } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months}ヶ月前", "moreThanYearAgo": "1年以上前" } -} +} \ No newline at end of file diff --git a/messages/ko.json b/messages/ko.json index c9eb3baf..d031da89 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -147,7 +147,9 @@ "purchasing": "구매 중...", "purchaseError": "구매에 실패했습니다. 다시 시도해 주세요.", "cancelApple": "Apple 구독은 App Store에서 관리됩니다.", - "manageApple": "App Store 열기" + "manageApple": "App Store 열기", + "alreadyBoundTo": "이 구독은 이미 {email}에 연결되어 있습니다.", + "alreadyBoundUnknown": "이 구독은 이미 다른 계정에 연결되어 있습니다." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months}달 전", "moreThanYearAgo": "1년 이상 전" } -} +} \ No newline at end of file diff --git a/messages/pl.json b/messages/pl.json index 0b3aeb35..b982fd08 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -147,7 +147,9 @@ "purchasing": "Zakup w toku...", "purchaseError": "Zakup nie powiódł się. Spróbuj ponownie.", "cancelApple": "Subskrypcje Apple są zarządzane przez App Store.", - "manageApple": "Otwórz App Store" + "manageApple": "Otwórz App Store", + "alreadyBoundTo": "Ta subskrypcja jest już powiązana z {email}.", + "alreadyBoundUnknown": "Ta subskrypcja jest już powiązana z innym kontem." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months, plural, one {# miesiąc} few {# miesiące} many {# miesięcy} other {# miesięcy}} temu", "moreThanYearAgo": "Ponad rok temu" } -} +} \ No newline at end of file diff --git a/messages/zh.json b/messages/zh.json index fc85ce77..14655f9c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -147,7 +147,9 @@ "purchasing": "购买中...", "purchaseError": "购买失败,请重试。", "cancelApple": "Apple 订阅通过 App Store 管理。", - "manageApple": "打开 App Store" + "manageApple": "打开 App Store", + "alreadyBoundTo": "此订阅已与 {email} 关联。", + "alreadyBoundUnknown": "此订阅已与其他账户关联。" } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months} 个月前", "moreThanYearAgo": "1 年前" } -} +} \ No newline at end of file diff --git a/src/app/api/apple/subscription-owner/route.ts b/src/app/api/apple/subscription-owner/route.ts new file mode 100644 index 00000000..46e43cef --- /dev/null +++ b/src/app/api/apple/subscription-owner/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { decodeJwt } from "jose"; +import * as UserService from "@src/server/service/user-service"; + +const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; +const APPLE_BUNDLE_IDS = ["app.scriptio", "app.scriptio.staging"]; + +interface AppleTransactionPayload { + bundleId: string; + productId: string; + appAccountToken?: string; +} + +function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + return `${local[0]}***@${domain}`; +} + +export async function POST(req: NextRequest) { + const body = (await req.json().catch(() => ({}))) as Record