This commit is contained in:
Frank
2025-10-07 09:17:05 -04:00
parent cd3780b7f5
commit 6c99b833e4
8 changed files with 865 additions and 21 deletions

View File

@@ -56,25 +56,36 @@ const removeMember = action(async (form: FormData) => {
)
}, "member.remove")
const updateMemberRole = action(async (form: FormData) => {
const updateMember = action(async (form: FormData) => {
"use server"
console.log("!@#!@ Form data entries:")
for (const [key, value] of form.entries()) {
console.log(`!@#!@ ${key}:`, value)
}
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
if (!role) return { error: "Role is required" }
const limit = form.get("limit")?.toString()
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
console.log({ id, role, monthlyLimit, limit })
return json(
await withActor(
() =>
User.updateRole({ id, role })
User.update({ id, role, monthlyLimit })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listMembers.key },
)
}, "member.updateRole")
}, "member.update")
export function MemberCreateForm() {
const params = useParams()
@@ -155,7 +166,7 @@ export function MemberCreateForm() {
function MemberRow(props: { member: any; workspaceID: string; currentUserID: string | null }) {
const [editing, setEditing] = createSignal(false)
const submission = useSubmission(updateMemberRole)
const submission = useSubmission(updateMember)
const isCurrentUser = () => props.currentUserID === props.member.id
createEffect(() => {
@@ -164,6 +175,29 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
}
})
function getUsageDisplay() {
const currentUsage = (() => {
const dateLastUsed = props.member.timeMonthlyUsageUpdated
if (!dateLastUsed) return 0
const current = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
if (current !== lastUsed) return 0
return ((props.member.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()
const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
return `$${currentUsage} / ${limit}`
}
return (
<Show
when={editing()}
@@ -171,6 +205,7 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
<tr>
<td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
<td data-slot="member-role">{props.member.role}</td>
<td data-slot="member-usage">{getUsageDisplay()}</td>
<Show when={!props.member.timeSeen} fallback={<td data-slot="member-joined"></td>}>
<td data-slot="member-joined">invited</td>
</Show>
@@ -190,12 +225,21 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
}
>
<tr>
<td colspan="4">
<form action={updateMemberRole} method="post">
<td colspan="5">
<form action={updateMember} method="post">
<div data-slot="edit-member-email">{props.member.accountEmail ?? props.member.email}</div>
<input type="hidden" name="id" value={props.member.id} />
<input type="hidden" name="workspaceID" value={props.workspaceID} />
<Show when={!isCurrentUser()} fallback={<div data-slot="current-user-role">Role: {props.member.role}</div>}>
<Show
when={!isCurrentUser()}
fallback={
<>
<div data-slot="current-user-role">Role: {props.member.role}</div>
<input type="hidden" name="role" value={props.member.role} />
</>
}
>
<div data-slot="role-selector">
<label>
<input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
@@ -213,18 +257,32 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
</label>
</div>
</Show>
<div data-slot="limit-selector">
<label>
<strong>Monthly Limit</strong>
<input
type="number"
name="limit"
value={props.member.monthlyLimit ?? ""}
placeholder="No limit"
min="0"
/>
<p>Set a monthly spending limit for this user</p>
</label>
</div>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
<div data-slot="form-actions">
<button type="button" data-color="ghost" onClick={() => setEditing(false)}>
Cancel
</button>
<Show when={!isCurrentUser()}>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Saving..." : "Save"}
</button>
</Show>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Saving..." : "Save"}
</button>
</div>
</form>
</td>
@@ -258,6 +316,7 @@ export function MemberSection() {
<tr>
<th>Email</th>
<th>Role</th>
<th>Usage</th>
<th></th>
<th></th>
</tr>

View File

@@ -11,6 +11,7 @@ import { Billing } from "../../../../core/src/billing"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenModel } from "@opencode-ai/console-core/model.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
export async function handler(
input: APIEvent,
@@ -33,6 +34,7 @@ export async function handler(
class AuthError extends Error {}
class CreditsError extends Error {}
class MonthlyLimitError extends Error {}
class UserLimitError extends Error {}
class ModelError extends Error {}
type Model = z.infer<typeof ZenModel.ModelSchema>
@@ -181,6 +183,7 @@ export async function handler(
error instanceof AuthError ||
error instanceof CreditsError ||
error instanceof MonthlyLimitError ||
error instanceof UserLimitError ||
error instanceof ModelError
)
return new Response(
@@ -243,10 +246,15 @@ export async function handler(
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
userID: UserTable.id,
userMonthlyLimit: UserTable.monthlyLimit,
userMonthlyUsage: UserTable.monthlyUsage,
timeUserMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
})
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
.innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
@@ -269,6 +277,12 @@ export async function handler(
monthlyUsage: data.monthlyUsage,
timeMonthlyUsageUpdated: data.timeMonthlyUsageUpdated,
},
user: {
id: data.userID,
monthlyLimit: data.userMonthlyLimit,
monthlyUsage: data.userMonthlyUsage,
timeMonthlyUsageUpdated: data.timeUserMonthlyUsageUpdated,
},
isFree,
}
}
@@ -280,19 +294,34 @@ export async function handler(
const billing = authInfo.billing
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
const now = new Date()
const currentYear = now.getUTCFullYear()
const currentMonth = now.getUTCMonth()
if (
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const now = new Date()
const currentYear = now.getUTCFullYear()
const currentMonth = now.getUTCMonth()
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}.`,
)
}
if (
authInfo.user.monthlyLimit &&
authInfo.user.monthlyUsage &&
authInfo.user.timeMonthlyUsageUpdated &&
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100)
) {
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new UserLimitError(`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}.`)
}
}
@@ -386,6 +415,18 @@ export async function handler(
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
await tx
.update(UserTable)
.set({
monthlyUsage: sql`
CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
})
await Database.use((tx) =>