Skip to content

Commit 5d7f1bb

Browse files
committed
feat: show a billing admin section for each account a user can admin
1 parent 2206422 commit 5d7f1bb

File tree

2 files changed

+93
-79
lines changed

2 files changed

+93
-79
lines changed

src/app/plans/change/page.tsx

+84-79
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { usePlan } from "@/hooks"
4-
import { useW3, DID, Client, AccountDID } from "@w3ui/react"
4+
import { useW3, DID, Client, AccountDID, Account } from "@w3ui/react"
55
import { ArrowTopRightOnSquareIcon, CheckCircleIcon } from '@heroicons/react/24/outline'
66
import DefaultLoader from "@/components/Loader"
77
import { useState } from "react"
@@ -15,10 +15,11 @@ import * as Access from "@web3-storage/access/access"
1515
import * as DidMailto from "@web3-storage/did-mailto"
1616
import * as Ucanto from "@ucanto/core"
1717
import { Plan } from "@web3-storage/capabilities"
18-
import { H2 } from "@/components/Text"
19-
import { Capability, Delegation } from "@ucanto/interface"
18+
import { H1, H2 } from "@/components/Text"
19+
import { Ability, Capability, Delegation } from "@ucanto/interface"
2020

2121
interface PlanSectionProps {
22+
account: Account
2223
planID: DID
2324
planName: string
2425
flatFee: number
@@ -40,9 +41,8 @@ const planRanks: Record<string, number> = {
4041

4142
const buttonText = (currentPlan: string, newPlan: string) => (planRanks[currentPlan] > planRanks[newPlan]) ? 'Downgrade' : 'Upgrade'
4243

43-
function PlanSection ({ planID, planName, flatFee, flatFeeAllotment, perGbFee }: PlanSectionProps) {
44-
const [{ accounts }] = useW3()
45-
const { data: plan, setPlan, isLoading } = usePlan(accounts[0])
44+
function PlanSection ({ account, planID, planName, flatFee, flatFeeAllotment, perGbFee }: PlanSectionProps) {
45+
const { data: plan, setPlan, isLoading } = usePlan(account)
4646
const currentPlanID = plan?.product
4747
const isCurrentPlan = currentPlanID === planID
4848
const [isUpdatingPlan, setIsUpdatingPlan] = useState(false)
@@ -84,12 +84,13 @@ function PlanSection ({ planID, planName, flatFee, flatFeeAllotment, perGbFee }:
8484
<div>Additional at ${perGbFee}/GB per month</div>
8585
</div>
8686
</div>
87+
<div className='flex-grow'></div>
8788
{
8889
(isLoading || isUpdatingPlan || !currentPlanID) ? (
8990
<DefaultLoader className='h-6 w-6' />
9091
) : (
9192
isCurrentPlan ? (
92-
<div className='h-4'>
93+
<div className='h-7'>
9394
{(currentPlanID === planID) && (
9495
<h5 className='font-bold'>Current Plan</h5>
9596
)}
@@ -114,46 +115,62 @@ function getCapabilities (delegations: Delegation[]): Capability[] {
114115
}
115116
}
116117

117-
function findAccountsICanAdminister (client: Client): AccountDID[] {
118-
return Array.from(new Set(
119-
getCapabilities(client.proofs([{ can: Plan.createAdminSession.can, with: 'ucan:*' }]))
118+
function doesCapabilityGrantAbility (capability: Ability, ability: Ability) {
119+
if (capability === ability) {
120+
return true
121+
} else if (capability.endsWith('/*')) {
122+
return ability.startsWith(capability.slice(0, -1))
123+
} else {
124+
return false
125+
}
126+
}
127+
128+
function findAccountResourcesWithCapability (client: Client, ability: Ability): Set<AccountDID> {
129+
return new Set(
130+
getCapabilities(client.proofs([{ can: ability, with: 'ucan:*' }]))
120131
.filter(cap => {
121-
const hasCapability = cap.can === Plan.createAdminSession.can || cap.can === 'plan/*' || '*'
122132
const isAccount = cap.with.startsWith('did:mailto:')
123-
return hasCapability && isAccount
133+
return doesCapabilityGrantAbility(cap.can, ability) && isAccount
124134
})
125135
.map(cap => cap.with) as AccountDID[]
126-
))
136+
)
127137
}
128138

129139
interface DelegationPlanCreateAdminSessionInput {
130140
email: string
131141
}
132-
function DelegatePlanCreateAdminSessionForm ({ className = '' }: { className?: string }) {
133-
const [{ client, accounts }] = useW3()
142+
143+
function DelegatePlanCreateAdminSessionForm ({ className = '', account }: { className?: string, account: Account }) {
144+
const [{ client }] = useW3()
134145

135146
const { register, handleSubmit } = useForm<DelegationPlanCreateAdminSessionInput>()
136147
const onSubmit: SubmitHandler<DelegationPlanCreateAdminSessionInput> = async (data) => {
137-
const currentAccount = accounts[0]
138-
if (client && currentAccount) {
148+
if (client && account) {
139149
const email = data.email as `${string}@${string}`
150+
const capabilities = [
151+
{
152+
with: account.did(),
153+
can: Plan.createAdminSession.can
154+
},
155+
{
156+
with: account.did(),
157+
can: Plan.get.can
158+
},
159+
{
160+
with: account.did(),
161+
can: Plan.set.can
162+
}
163+
]
140164
await ucantoast(Access.delegate(client.agent, {
141165
delegations: [
142166
await delegate({
143167
issuer: client.agent.issuer,
144168
audience: Ucanto.DID.parse(DidMailto.fromEmail(email)),
145-
capabilities: [
146-
{
147-
with: currentAccount.did(),
148-
can: Plan.createAdminSession.can
149-
}
150-
],
169+
// @ts-expect-error not sure why TS doesn't like this but I'm pretty sure it's safe to ignore
170+
capabilities,
151171
// TODO default to 1 year for now, but let's add UI for this soon
152172
lifetimeInSeconds: 60 * 60 * 24 * 365,
153-
proofs: client.proofs([{
154-
with: currentAccount.did(),
155-
can: Plan.createAdminSession.can
156-
}])
173+
proofs: client.proofs(capabilities)
157174
})
158175

159176
]
@@ -164,22 +181,12 @@ function DelegatePlanCreateAdminSessionForm ({ className = '' }: { className?: s
164181
})
165182
}
166183
}
167-
//console.log("PROOFS", client?.proofs().map(p => JSON.parse(JSON.stringify(p))))
168-
169-
/*client?.proofs().reduce((m, delegation) => {
170-
const capabilities = delegation.capabilities.filter(cap => cap.can === '*' || cap.can === 'plan/*' || cap.can === 'plan/create-admin-session')
171-
if (capabilities.length > 0){
172-
console.log("CAPS", capabilities, JSON.parse(JSON.stringify(delegation)))
173-
}
174-
return m.concat()
175-
}, [])*/
176-
177184
return (
178185
<form onSubmit={handleSubmit(onSubmit)} className={`flex flex-col space-y-2 ${className}`}>
179186
<label className='w-full'>
180-
<H2>Delegate billing admin portal access</H2>
187+
<H2>Delegate access to {DidMailto.toEmail(account.did())}'s<br />billing admin portal</H2>
181188
<input className='text-black py-2 px-2 rounded block w-full border border-gray-800'
182-
placeholder='Email' type='email'
189+
placeholder='To Email' type='email'
183190
{...register('email')} />
184191
</label>
185192
<input className='w3ui-button' type='submit' value='Delegate' />
@@ -197,13 +204,13 @@ function CustomerPortalLink ({ did }: { did: AccountDID }) {
197204
<ArrowPathIcon className={`h-5 w-5 text-white ${generatingCustomerPortalLink ? 'animate-spin' : ''}`} />
198205
</button>
199206
<a className='w3ui-button' href={customerPortalLink} target="_blank" rel="noopener noreferrer">
200-
Open Customer Portal
207+
Open Billing Portal
201208
<ArrowTopRightOnSquareIcon className='relative inline h-5 w-4 ml-1 -mt-1' />
202209
</a>
203210
</div>
204211
) : (
205212
<button className='w3ui-button' onClick={() => generateCustomerPortalLink(did)} disabled={generatingCustomerPortalLink}>
206-
Access Customer Portal for {DidMailto.toEmail(did as DidMailto.DidMailto)}
213+
Generate Link
207214
{generatingCustomerPortalLink &&
208215
<ArrowPathIcon className='inline ml-2 h-5 w-5 text-white animate-spin' />
209216
}
@@ -227,7 +234,6 @@ function useCustomerPortalLink () {
227234
console.debug(`w3up account is ${account}, could not generate customer portal link`)
228235
} else {
229236
setGeneratingCustomerPortalLink(true)
230-
console.log("PROFOS", JSON.parse(JSON.stringify(client.proofs([{ can: Plan.createAdminSession.can, with: did }]))))
231237
const result = await account.plan.createAdminSession(did, location.href)
232238
setGeneratingCustomerPortalLink(false)
233239
if (result.ok) {
@@ -241,48 +247,47 @@ function useCustomerPortalLink () {
241247
return { customerPortalLink, generateCustomerPortalLink, generatingCustomerPortalLink }
242248
}
243249

250+
function AccountAdmin ({ account }: { account: Account }) {
251+
return (
252+
<div className='flex flex-col space-y-2'>
253+
<H1>{DidMailto.toEmail(account.did())}</H1>
254+
<div>
255+
<H2>Pick a Plan</H2>
256+
<div className='flex flex-row xl:space-x-1'>
257+
<PlanSection account={account} planID={PLANS['starter']} planName='Starter' flatFee={0} flatFeeAllotment={5} perGbFee={0.15} />
258+
<PlanSection account={account} planID={PLANS['lite']} planName='Lite' flatFee={10} flatFeeAllotment={100} perGbFee={0.05} />
259+
<PlanSection account={account} planID={PLANS['business']} planName='Business' flatFee={100} flatFeeAllotment={2000} perGbFee={0.03} />
260+
</div>
261+
</div>
262+
<div>
263+
<H2>Access Billing Admin Portal</H2>
264+
<CustomerPortalLink did={account.did()} />
265+
</div>
266+
<DelegatePlanCreateAdminSessionForm account={account} className='w-96' />
267+
</div>
268+
)
269+
}
270+
244271
function Plans () {
245272
const [{ client, accounts }] = useW3()
246273
const account = accounts[0]
247274

248-
const [claimingDelegations, setClaimingDelegations] = useState(false)
249-
async function claimDelegations () {
250-
try {
251-
setClaimingDelegations(true)
252-
await client?.capability.access.claim()
253-
await client?.capability.access.claim({ audience: account.did() })
254-
} finally {
255-
setClaimingDelegations(false)
256-
}
257-
}
258-
const billingAdminAccounts: AccountDID[] = client ? findAccountsICanAdminister(client) : []
259-
console.log(billingAdminAccounts)
275+
const billingAdminAccounts: Set<AccountDID> = client ? findAccountResourcesWithCapability(client, Plan.createAdminSession.can) : new Set()
276+
const planAdminAccounts: Set<AccountDID> = client ? findAccountResourcesWithCapability(client, Plan.set.can) : new Set()
277+
const adminableAccounts: AccountDID[] = Array.from(new Set<AccountDID>([...billingAdminAccounts, ...planAdminAccounts]))
278+
260279
return (
261-
<div className='py-8 flex flex-col items-center space-y-8'>
262-
<div className='flex flex-col items-center'>
263-
<h1 className='text-2xl font-mono mb-8 font-bold'>Billing</h1>
264-
<CustomerPortalLink did={account.did()} />
265-
<DelegatePlanCreateAdminSessionForm className='my-4' />
266-
<div>
267-
{/*<button className='w3ui-button' onClick={claimDelegations} disabled={claimingDelegations}>
268-
Check For Billing Admin Delegations
269-
{claimingDelegations &&
270-
<ArrowPathIcon className='inline ml-2 h-5 w-5 text-white animate-spin' />
271-
}
272-
</button>
273-
*/}
274-
{billingAdminAccounts.map(did => (<CustomerPortalLink did={did} key={did} />))}
275-
</div>
276-
</div>
277-
<div className='flex flex-col items-center'>
278-
<h1 className='text-2xl font-mono mb-8 font-bold'>Plans</h1>
279-
<p className='mb-4'>Pick the price plan that works for you.</p>
280-
<div className='flex flex-col space-y-2 xl:flex-row xl:space-y-0 xl:space-x-2'>
281-
<PlanSection planID={PLANS['starter']} planName='Starter' flatFee={0} flatFeeAllotment={5} perGbFee={0.15} />
282-
<PlanSection planID={PLANS['lite']} planName='Lite' flatFee={10} flatFeeAllotment={100} perGbFee={0.05} />
283-
<PlanSection planID={PLANS['business']} planName='Business' flatFee={100} flatFeeAllotment={2000} perGbFee={0.03} />
284-
</div>
285-
</div>
280+
<div className='py-8 flex flex-col space-y-12'>
281+
<h1 className='text-2xl font-mono font-bold'>Billing</h1>
282+
<AccountAdmin account={account} />
283+
{adminableAccounts.map(did => (client && (did !== account.did()) ? (
284+
<AccountAdmin key={did}
285+
account={new Account({
286+
id: did as DidMailto.DidMailto,
287+
agent: client.agent,
288+
proofs: []
289+
})} />
290+
) : null))}
286291
</div>
287292
)
288293
}

src/components/Text.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
export function H1 ({ children, className = '', as = 'h1', explain }: { children: React.ReactNode, className?: string, as?: 'h1' | 'h2' | 'h3', explain?: string }) {
2+
const As = as
3+
return (
4+
<As className={`text-sm tracking-wider uppercase font-bold my-2 text-black font-mono ${className}`}>
5+
{children}{explain ? <span className='opacity-50 normal-case'>: {explain}</span> : null}
6+
</As>
7+
)
8+
}
9+
110
export function H2 ({ children, className = '', as = 'h2', explain }: { children: React.ReactNode, className?: string, as?: 'h1' | 'h2' | 'h3', explain?: string }) {
211
const As = as
312
return (

0 commit comments

Comments
 (0)