Skip to content
Open
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
19 changes: 11 additions & 8 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions web/sdk/client/hooks/useTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ interface UseTokensReturn {
fetchTokenBalance: () => Promise<any>;
}

export const useTokens = (): UseTokensReturn => {
export interface UseTokensOptions {
// when false the balance is not fetched; callers that only need the
// balance in a rarely-opened surface (like a dialog) gate it on that
enabled?: boolean;
}

export const useTokens = (options: UseTokensOptions = {}): UseTokensReturn => {
const { billingAccount } = useFrontier();

const {
Expand All @@ -25,7 +31,7 @@ export const useTokens = (): UseTokensReturn => {
id: billingAccount?.id ?? ''
}),
{
enabled: !!billingAccount?.id,
enabled: !!billingAccount?.id && (options.enabled ?? true),
retry: false
}
);
Expand Down
23 changes: 23 additions & 0 deletions web/sdk/client/utils/invoice-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { create } from '@bufbuild/protobuf';
import { RQLFilterSchema } from '@raystack/proton/frontier';
import { INVOICE_STATES } from './constants';

// The one definition of "an invoice the customer still has to pay": open
// state with a non-zero amount. The server refuses an organization delete
// while any exist, and the billing page's payment-issue banner keys off the
// same set — both build their queries from these filters so the two can not
// drift apart.
export function openInvoiceFilters() {
return [
create(RQLFilterSchema, {
name: 'state',
operator: 'eq',
value: { case: 'stringValue', value: INVOICE_STATES.OPEN }
}),
create(RQLFilterSchema, {
name: 'amount',
operator: 'gt',
value: { case: 'numberValue', value: 0 }
})
];
}
15 changes: 2 additions & 13 deletions web/sdk/client/views/billing/components/payment-issue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,19 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import {
Subscription,
RQLRequestSchema,
RQLFilterSchema,
RQLSortSchema
} from '@raystack/proton/frontier';
import { create } from '@bufbuild/protobuf';
import { INVOICE_STATES, SUBSCRIPTION_STATES } from '../../../utils/constants';
import { openInvoiceFilters } from '../../../utils/invoice-queries';
import { DEFAULT_PAGE_SIZE } from '../../../utils/connect-pagination';
import { useOrganizationInvoices } from '../../../hooks/useOrganizationInvoices';
import styles from '../billing-view.module.css';

// Open invoices with a non-zero amount, newest first — the invoice that needs
// payment when a subscription is past due.
const OPEN_INVOICES_QUERY = create(RQLRequestSchema, {
filters: [
create(RQLFilterSchema, {
name: 'state',
operator: 'eq',
value: { case: 'stringValue', value: INVOICE_STATES.OPEN }
}),
create(RQLFilterSchema, {
name: 'amount',
operator: 'gt',
value: { case: 'numberValue', value: 0 }
})
],
filters: openInvoiceFilters(),
sort: [create(RQLSortSchema, { name: 'created_at', order: 'desc' })],
offset: 0,
limit: DEFAULT_PAGE_SIZE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '@raystack/apsara';
import { useFrontier } from '../../../contexts/FrontierContext';
import { useTerminology } from '../../../hooks/useTerminology';
import { useTokens } from '../../../hooks/useTokens';
import { handleConnectError } from '~/utils/error';

const deleteOrgSchema = yup
Expand All @@ -44,6 +45,9 @@ export const DeleteOrganizationDialog = ({
const orgLabel = t.organization({ case: 'capital' });
const orgLabelLower = t.organization({ case: 'lower' });
const [isAcknowledged, setIsAcknowledged] = useState(false);
// fetched only while the dialog is open; the confirm button waits for the
// answer so the forfeit warning cannot be skipped by a slow response
const { tokenBalance, isTokensLoading } = useTokens({ enabled: open });
Comment on lines +48 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

fd -HI -t f \
  'package\.json|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml' . \
  -E node_modules \
  -x sh -c 'printf "\n%s\n" "$1"; rg -n -C 2 "\"(`@tanstack/react-query`|react-query)\"" "$1" || true' sh {}

Repository: raystack/frontier

Length of output: 882


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

file="web/sdk/client/views/general/components/delete-organization-dialog.tsx"
printf '%s\n' '--- target file outline ---'
ast-grep outline "$file" --lang tsx || true
printf '%s\n' '--- target file ---'
cat -n "$file"
printf '%s\n' '--- useTokens definitions and usages ---'
rg -n -C 6 'useTokens|isTokensLoading|tokenBalance|isFetching' web/sdk/client web/sdk \
  -g '*.{ts,tsx}' -g '!node_modules' | head -n 500
printf '%s\n' '--- related tests ---'
rg -n -C 5 'delete-organization-dialog|DeleteOrganization|forfeit|token balance|tokenBalance' web/sdk/client \
  -g '*.{test,spec}.{ts,tsx}' -g '!node_modules' || true

Repository: raystack/frontier

Length of output: 41441


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- useTokens.ts ---'
cat -n web/sdk/client/hooks/useTokens.ts

printf '%s\n' '--- query option patterns ---'
rg -n -C 8 'useQuery\(' web/sdk/client/hooks web/sdk/client/views \
  -g '*.{ts,tsx}' -g '!node_modules' | head -n 800

printf '%s\n' '--- lockfile package versions ---'
rg -n -C 3 '`@tanstack/`(react-query|query-core)|connect-query' web/pnpm-lock.yaml \
  web/sdk/client/package.json web/sdk/package.json web/package.json

Repository: raystack/frontier

Length of output: 50373


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- locked query packages ---'
python3 - <<'PY'
from pathlib import Path
p = Path("web/pnpm-lock.yaml")
lines = p.read_text().splitlines()
for i, line in enumerate(lines):
    if any(x in line for x in ("`@tanstack/react-query`", "`@tanstack/query-core`", "`@connectrpc/connect-query`")):
        print("\n".join(lines[max(0, i-3):i+8]))
        print("---")
PY

printf '%s\n' '--- package declarations ---'
rg -n -C 2 '"(`@connectrpc/connect-query`|`@tanstack/react-query`)"' \
  web/package.json web/sdk/package.json web/sdk/client/package.json web/apps/admin/package.json

printf '%s\n' '--- TanStack state semantics from repository documentation/comments ---'
rg -n -C 3 'isLoading.*isFetching|isFetching.*isLoading|refetchOnMount|staleTime' \
  . -g '!node_modules' -g '!pnpm-lock.yaml' | head -n 300 || true

printf '%s\n' '--- standalone state probe ---'
node - <<'JS'
const cases = [
  { name: 'initial enabled request', hasData: false, isFetching: true },
  { name: 'cached data with background refetch', hasData: true, isFetching: true },
  { name: 'cached fresh data', hasData: true, isFetching: false },
];
for (const c of cases) {
  // TanStack Query v5 derives isLoading from pending && fetching.
  const status = c.hasData ? 'success' : 'pending';
  const isPending = status === 'pending';
  const isLoading = isPending && c.isFetching;
  console.log(JSON.stringify({ ...c, status, isPending, isLoading }));
}
JS

Repository: raystack/frontier

Length of output: 44836


Disable deletion during balance refetches.

isLoading is false when cached data exists during a background refetch. Expose isFetching from useTokens and include it in the delete-button guard. Add a regression test for reopening the dialog after the balance changes from zero to positive.


const { mutateAsync: deleteOrganization } = useMutation(
FrontierServiceQueries.deleteOrganization
Expand Down Expand Up @@ -83,6 +87,7 @@ export const DeleteOrganizationDialog = ({
} catch (error) {
handleConnectError(error, {
PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", type: 'error' }),
FailedPrecondition: (err) => toastManager.add({ title: `Cannot delete this ${orgLabelLower} yet`, description: err.message, type: 'error' }),
NotFound: (err) => toastManager.add({ title: 'Not found', description: err.message, type: 'error' }),
Default: (err) => toastManager.add({ title: 'Something went wrong', description: err.message, type: 'error' }),
});
Expand All @@ -102,6 +107,14 @@ export const DeleteOrganizationDialog = ({
This action can not be undone. This will permanently
delete all the projects and resources in {organization?.title}.
</Text>
{tokenBalance > 0 ? (
<Text size="small" variant="danger">
You have {tokenBalance.toString()} tokens remaining. Deleting
the {orgLabelLower} forfeits them. Please contact support
about these tokens: any amount that was purchased can be
transferred to your bank account.
</Text>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Field
label={`Please type name of the ${orgLabel} to confirm.`}
error={
Expand Down Expand Up @@ -146,7 +159,12 @@ export const DeleteOrganizationDialog = ({
variant="solid"
color="danger"
type="submit"
disabled={!deleteTitle || !isAcknowledged}
disabled={
!deleteTitle ||
!isAcknowledged ||
isSubmitting ||
isTokensLoading
}
data-test-id="frontier-sdk-delete-organization-btn"
loading={isSubmitting}
loaderText="Deleting..."
Expand Down
66 changes: 61 additions & 5 deletions web/sdk/client/views/general/general-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import { create } from '@bufbuild/protobuf';
import {
createConnectQueryKey,
useMutation,
useQuery,
useTransport
} from '@connectrpc/connect-query';
import { useQueryClient } from '@tanstack/react-query';
import {
FrontierServiceQueries,
UpdateOrganizationRequestSchema
UpdateOrganizationRequestSchema,
CheckOrganizationDeleteRequestSchema
} from '@raystack/proton/frontier';
import {
Button,
Expand Down Expand Up @@ -47,6 +49,19 @@ const generalSchema = yup

type FormData = yup.InferType<typeof generalSchema>;

// One short instruction per kind of delete blocker the server can report.
// An unknown kind falls back to the server's own message.
const BLOCKER_INSTRUCTIONS: Record<string, (count: number) => string> = {
ACTIVE_SUBSCRIPTION: () =>
'Please downgrade the subscription to the standard plan',
UNPAID_INVOICE: count =>
count > 1
? `Please pay the ${count} open invoices from the billing page`
: 'Please pay the open invoice from the billing page',
NEGATIVE_TOKEN_BALANCE: () =>
'Please contact support to settle the token balance'
};

export interface GeneralViewProps {
onDeleteSuccess?: () => void;
urlPrefix?: string;
Expand Down Expand Up @@ -97,6 +112,35 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = {

const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching;

// the server's own answer to "would a delete go through right now" — the
// same blockers a real delete would refuse with. While it loads the button
// stays disabled rather than briefly allowing a delete the server would
// refuse; a failed check fails open since the server refuses independently
const { data: deleteCheck, isLoading: isDeleteCheckLoading } = useQuery(
FrontierServiceQueries.checkOrganizationDelete,
create(CheckOrganizationDeleteRequestSchema, {
id: organization?.id ?? ''
}),
{
enabled: canDeleteWorkspace && !!organization?.id,
retry: false
}
);
const isDeleteBlocked = !!deleteCheck && !deleteCheck.canDelete;
const blockerLines = useMemo(() => {
const blockers = deleteCheck?.blockers ?? [];
const counts = new Map<string, number>();
for (const blocker of blockers) {
counts.set(blocker.type, (counts.get(blocker.type) ?? 0) + 1);
}
return [...counts.entries()].map(
([type, count]) =>
BLOCKER_INSTRUCTIONS[type]?.(count) ??
blockers.find(blocker => blocker.type === type)?.message ??
type
);
}, [deleteCheck]);

// Update organization form
const { mutateAsync: updateOrganization } = useMutation(
FrontierServiceQueries.updateOrganization,
Expand Down Expand Up @@ -280,22 +324,34 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = {
</Text>
<Tooltip>
<Tooltip.Trigger
disabled={canDeleteWorkspace}
disabled={canDeleteWorkspace && !isDeleteBlocked}
render={<span className={styles.fitContent} />}
>
<Button
variant="solid"
color="danger"
onClick={() => setShowDeleteDialog(true)}
disabled={!canDeleteWorkspace}
disabled={
!canDeleteWorkspace ||
isDeleteBlocked ||
isDeleteCheckLoading
}
data-test-id="frontier-sdk-delete-organization-btn"
>
Delete {orgLabelLower}
</Button>
</Tooltip.Trigger>
{!canDeleteWorkspace && (
{!canDeleteWorkspace ? (
<Tooltip.Content>{AuthTooltipMessage}</Tooltip.Content>
)}
) : isDeleteBlocked ? (
<Tooltip.Content>
<Flex direction="column" gap={2}>
{blockerLines.map(line => (
<span key={line}>{line}</span>
))}
</Flex>
</Tooltip.Content>
) : null}
</Tooltip>
</>
)}
Expand Down
2 changes: 1 addition & 1 deletion web/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"@connectrpc/connect-query": "2.1.1",
"@connectrpc/connect-web": "2.1.1",
"@hookform/resolvers": "^3.10.0",
"@raystack/proton": "0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839",
"@raystack/proton": "0.1.0-194685ed0280d282261bc16f708266465dd1deb1",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-router": "^1.168.3",
"axios": "^1.9.0",
Expand Down
Loading