Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin/src/api/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getJson } from './http'

export interface MembershipConfigStatus {
apiKeyConfigured: boolean
applePrivateKeyConfigured?: boolean
supportedProviders: string[]
webhookSigningKeyConfigured: boolean
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ describe('MembershipConfigEditor', () => {
expect(harness.container.textContent).toContain(
'https://mx.example.com/api/v3/membership/webhook/dodo',
)
expect(harness.container.textContent).toContain(
'https://mx.example.com/api/v3/membership/webhook/apple',
)
for (const event of MEMBERSHIP_WEBHOOK_EVENTS) {
expect(harness.container.textContent).toContain(event)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
KeyRound,
PackageOpen,
PlugZap,
Smartphone,
Webhook,
} from 'lucide-react'
import { toast } from 'sonner'
Expand All @@ -26,7 +27,9 @@ import { TextInput } from '~/ui/primitives/text-field'

import {
buildMembershipWebhookUrl,
getAppleIapSetupChecks,
getMembershipSetupChecks,
getMembershipSetupProgress,
MEMBERSHIP_WEBHOOK_EVENTS,
type MembershipConfigValue,
} from '../../utils/membership'
Expand Down Expand Up @@ -135,23 +138,31 @@ export function MembershipConfigEditor(props: {
const environment = props.value.environment || 'live_mode'
const status = statusQuery.data ?? {
apiKeyConfigured: false,
applePrivateKeyConfigured: false,
supportedProviders: ['dodo'],
webhookSigningKeyConfigured: false,
}
const checks = getMembershipSetupChecks(props.value, status)
const completedCount = Object.values(checks).filter(Boolean).length
const totalCount = Object.keys(checks).length
const setupComplete = completedCount === totalCount
const appleChecks = getAppleIapSetupChecks(props.value, status)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let a complete Apple setup satisfy the enable gate

On a fresh Apple-only installation, appleChecks is calculated but never included in setupComplete; that value still reflects only the Dodo provider, API key, product, and webhook checks. Consequently the Enable toggle remains disabled unless Dodo is also configured, while resolveAppleIapAvailability requires the shared enabled flag, making the newly added Apple IAP flow impossible to enable through this editor without unrelated Dodo credentials.

Useful? React with 👍 / 👎.

const { completedCount, setupComplete, totalCount } =
getMembershipSetupProgress(checks, appleChecks)
const providerSupported = checks.provider
const apiKeyConfigured =
Boolean(props.value.apiKey?.trim()) || status.apiKeyConfigured
const webhookKeyConfigured =
Boolean(props.value.webhookSigningKey?.trim()) ||
status.webhookSigningKeyConfigured
const applePrivateKeyConfigured =
Boolean(props.value.applePrivateKey?.trim()) ||
Boolean(status.applePrivateKeyConfigured)
const webhookUrl = buildMembershipWebhookUrl(
API_URL || location.origin,
provider,
)
const appleWebhookUrl = buildMembershipWebhookUrl(
API_URL || location.origin,
'apple',
)
const rawProviderOptions = getStringOptions(props.fields, 'provider')
const providerOptions = rawProviderOptions.map((option) => ({
...option,
Expand All @@ -177,9 +188,9 @@ export function MembershipConfigEditor(props: {
value: MembershipConfigValue[TKey],
) => props.onChange({ ...props.value, [key]: value })

const copyWebhookUrl = async () => {
const copyWebhookUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(webhookUrl)
await navigator.clipboard.writeText(url)
toast.success(t('settings.membership.webhook.copySuccess'))
} catch {
toast.error(t('settings.membership.webhook.copyFailed'))
Expand Down Expand Up @@ -324,7 +335,7 @@ export function MembershipConfigEditor(props: {
</div>
</section>

<section className="grid gap-5 pt-8 lg:grid-cols-[220px_minmax(0,1fr)] lg:gap-10">
<section className="grid gap-5 border-b border-border py-8 lg:grid-cols-[220px_minmax(0,1fr)] lg:gap-10">
<div>
<GuideSectionHeading
description={t('settings.membership.webhook.description')}
Expand Down Expand Up @@ -356,7 +367,7 @@ export function MembershipConfigEditor(props: {
<Button
aria-label={t('settings.membership.webhook.copyAria')}
iconOnly
onClick={() => void copyWebhookUrl()}
onClick={() => void copyWebhookUrl(webhookUrl)}
type="button"
variant="secondary"
>
Expand Down Expand Up @@ -416,6 +427,98 @@ export function MembershipConfigEditor(props: {
</div>
</div>
</section>

<section className="grid gap-5 pt-8 lg:grid-cols-[220px_minmax(0,1fr)] lg:gap-10">
<div>
<GuideSectionHeading
description={t('settings.membership.apple.description')}
icon={Smartphone}
title={t('settings.membership.apple.title')}
/>
<p className="mt-3 text-xs text-fg-muted">
{Object.values(appleChecks).filter(Boolean).length}/
{Object.keys(appleChecks).length}
</p>
</div>
<div className="min-w-0 space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<TextInput
label={t('settings.membership.apple.bundleId.label')}
onChange={(value) => update('appleBundleId', value)}
placeholder={t('settings.membership.apple.bundleId.placeholder')}
value={props.value.appleBundleId ?? ''}
/>
<TextInput
label={t('settings.membership.apple.keyId.label')}
onChange={(value) => update('appleKeyId', value)}
placeholder={t('settings.membership.apple.keyId.placeholder')}
value={props.value.appleKeyId ?? ''}
/>
<TextInput
label={t('settings.membership.apple.issuerId.label')}
onChange={(value) => update('appleIssuerId', value)}
placeholder={t('settings.membership.apple.issuerId.placeholder')}
value={props.value.appleIssuerId ?? ''}
/>
<TextInput
label={t('settings.membership.apple.appAppleId.label')}
onChange={(value) => update('appleAppAppleId', value)}
placeholder={t(
'settings.membership.apple.appAppleId.placeholder',
)}
value={props.value.appleAppAppleId ?? ''}
/>
</div>
<p className="text-xs leading-5 text-fg-muted">
{t('settings.membership.apple.appAppleId.description')}
</p>
<SecretField
configured={applePrivateKeyConfigured}
description={`${t('settings.membership.apple.privateKey.description')} ${t('settings.membership.credential.keep')}`}
keepHint={t('settings.membership.credential.configured')}
label={t('settings.membership.apple.privateKey.label')}
onChange={(value) => update('applePrivateKey', value)}
placeholder={t('settings.membership.apple.privateKey.placeholder')}
value={props.value.applePrivateKey ?? ''}
/>
<div className="grid gap-5 md:grid-cols-2">
<TextInput
label={t('settings.membership.apple.monthly.label')}
onChange={(value) => update('appleMonthlyProductId', value)}
placeholder={t('settings.membership.apple.monthly.placeholder')}
value={props.value.appleMonthlyProductId ?? ''}
/>
<TextInput
label={t('settings.membership.apple.yearly.label')}
onChange={(value) => update('appleYearlyProductId', value)}
placeholder={t('settings.membership.apple.yearly.placeholder')}
value={props.value.appleYearlyProductId ?? ''}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-fg">
{t('settings.membership.webhook.endpointLabel')}
</p>
<div className="flex items-center gap-2 rounded-sm border border-border bg-surface-inset px-3 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-fg">
{appleWebhookUrl}
</code>
<Button
aria-label={t('settings.membership.webhook.copyAria')}
iconOnly
onClick={() => void copyWebhookUrl(appleWebhookUrl)}
type="button"
variant="secondary"
>
<Copy aria-hidden="true" className="size-3.5" />
</Button>
</div>
<p className="text-xs leading-5 text-fg-muted">
{t('settings.membership.apple.webhook.description')}
</p>
</div>
</div>
</section>
</div>
)
}
90 changes: 90 additions & 0 deletions apps/admin/src/features/settings/utils/membership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'

import {
buildMembershipWebhookUrl,
getAppleIapSetupChecks,
getMembershipSetupChecks,
getMembershipSetupProgress,
} from './membership'

describe('buildMembershipWebhookUrl', () => {
Expand All @@ -11,6 +13,12 @@ describe('buildMembershipWebhookUrl', () => {
buildMembershipWebhookUrl('https://mx.example.com/api/v3/', 'dodo'),
).toBe('https://mx.example.com/api/v3/membership/webhook/dodo')
})

it('builds the Apple webhook URL', () => {
expect(
buildMembershipWebhookUrl('https://mx.example.com/api/v3/', 'apple'),
).toBe('https://mx.example.com/api/v3/membership/webhook/apple')
})
})

describe('getMembershipSetupChecks', () => {
Expand Down Expand Up @@ -53,3 +61,85 @@ describe('getMembershipSetupChecks', () => {
expect(Object.values(checks).every(Boolean)).toBe(true)
})
})

describe('getAppleIapSetupChecks', () => {
it('is complete when all Apple fields or persisted private key are present', () => {
expect(
getAppleIapSetupChecks(
{
appleAppAppleId: '1234567890',
appleBundleId: 'dev.yohaku.app',
appleIssuerId: 'ISSUER',
appleKeyId: 'KEYID',
appleMonthlyProductId: 'monthly',
appleYearlyProductId: 'yearly',
},
{
apiKeyConfigured: false,
applePrivateKeyConfigured: true,
supportedProviders: ['dodo'],
webhookSigningKeyConfigured: false,
},
),
).toEqual({
appAppleId: true,
bundleId: true,
issuerId: true,
keyId: true,
monthlyProductId: true,
privateKey: true,
yearlyProductId: true,
})
})

it.each(['', '0', '-1', '1.5', 'not-a-number'])(
'rejects an invalid App Apple ID: %s',
(appleAppAppleId) => {
const checks = getAppleIapSetupChecks(
{
appleAppAppleId,
appleBundleId: 'dev.yohaku.app',
appleIssuerId: 'ISSUER',
appleKeyId: 'KEYID',
appleMonthlyProductId: 'monthly',
applePrivateKey: 'private-key',
appleYearlyProductId: 'yearly',
},
{
apiKeyConfigured: false,
supportedProviders: ['dodo'],
webhookSigningKeyConfigured: false,
},
)

expect(checks.appAppleId).toBe(false)
},
)

it('lets a complete Apple-only setup satisfy the enable gate', () => {
const status = {
apiKeyConfigured: false,
supportedProviders: ['dodo'],
webhookSigningKeyConfigured: false,
}
const membershipChecks = getMembershipSetupChecks({}, status)
const appleChecks = getAppleIapSetupChecks(
{
appleAppAppleId: '1234567890',
appleBundleId: 'dev.yohaku.app',
appleIssuerId: 'ISSUER',
appleKeyId: 'KEYID',
appleMonthlyProductId: 'monthly',
applePrivateKey: 'private-key',
appleYearlyProductId: 'yearly',
},
status,
)

expect(getMembershipSetupProgress(membershipChecks, appleChecks)).toEqual({
completedCount: 7,
setupComplete: true,
totalCount: 7,
})
})
})
56 changes: 56 additions & 0 deletions apps/admin/src/features/settings/utils/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ export const MEMBERSHIP_WEBHOOK_EVENTS = [

export interface MembershipConfigValue {
apiKey?: string
appleAppAppleId?: string
appleBundleId?: string
appleIssuerId?: string
appleKeyId?: string
appleMonthlyProductId?: string
applePrivateKey?: string
appleYearlyProductId?: string
enabled?: boolean
environment?: string
monthlyProductId?: string
Expand All @@ -19,10 +26,40 @@ export interface MembershipConfigValue {

export interface MembershipCredentialStatus {
apiKeyConfigured: boolean
applePrivateKeyConfigured?: boolean
supportedProviders: string[]
webhookSigningKeyConfigured: boolean
}

type SetupChecks = Record<string, boolean>

export function getMembershipSetupProgress(
membershipChecks: SetupChecks,
appleChecks: SetupChecks,
) {
const membershipValues = Object.values(membershipChecks)
const appleValues = Object.values(appleChecks)
const membershipCompletedCount = membershipValues.filter(Boolean).length
const appleCompletedCount = appleValues.filter(Boolean).length
const membershipComplete =
membershipCompletedCount === membershipValues.length
const appleComplete = appleCompletedCount === appleValues.length

if (!membershipComplete && appleComplete) {
return {
completedCount: appleCompletedCount,
setupComplete: true,
totalCount: appleValues.length,
Comment on lines +48 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Count Apple availability when gating premium content

The new Apple-only success branch allows an installation without Dodo credentials to enable membership, but EntitlementService.isMembershipPurchasable() still consults only resolveMembershipAvailability(), which returns false without a registered web provider and its credentials. In that Apple-only configuration, PostController.applyPaywall() skips the paywall and isPremiumLocked() returns false, exposing premium content to nonmembers even though Apple IAP is enabled; backend entitlement availability must also include resolveAppleIapAvailability().

Useful? React with 👍 / 👎.

}
}

return {
completedCount: membershipCompletedCount,
setupComplete: membershipComplete,
totalCount: membershipValues.length,
}
}

export function buildMembershipWebhookUrl(apiUrl: string, provider = 'dodo') {
return `${apiUrl.replace(/\/+$/, '')}/membership/webhook/${encodeURIComponent(provider)}`
}
Expand All @@ -47,3 +84,22 @@ export function getMembershipSetupChecks(
webhookSigningKey: hasWebhookSigningKey,
}
}

export function getAppleIapSetupChecks(
config: MembershipConfigValue,
status?: MembershipCredentialStatus,
) {
const appAppleId = Number(config.appleAppAppleId?.trim())
const hasPrivateKey =
Boolean(config.applePrivateKey?.trim()) ||
Boolean(status?.applePrivateKeyConfigured)
return {
appAppleId: Number.isSafeInteger(appAppleId) && appAppleId > 0,
bundleId: Boolean(config.appleBundleId?.trim()),
issuerId: Boolean(config.appleIssuerId?.trim()),
keyId: Boolean(config.appleKeyId?.trim()),
monthlyProductId: Boolean(config.appleMonthlyProductId?.trim()),
Comment on lines +96 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the App Apple ID in Apple setup validation

When the other Apple fields are populated but appleAppAppleId is blank, non-numeric, or non-positive, this helper reports every Apple check as complete even though the backend's resolveAppleIapAvailability explicitly rejects that configuration. The editor therefore displays a misleading 6/6 completion state while /plans advertises Apple IAP as disabled and confirmation is rejected; validate the App Apple ID here with the same positive-integer rule.

Useful? React with 👍 / 👎.

privateKey: hasPrivateKey,
yearlyProductId: Boolean(config.appleYearlyProductId?.trim()),
}
}
Loading