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
2 changes: 2 additions & 0 deletions src/lib/actions/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export enum Click {
PlatformCreateClick = 'click_platform_create',
OrganizationClickCreate = 'click_create_organization',
OrganizationClickUpgrade = 'click_organization_upgrade',
OrganizationProgramMembershipReconnect = 'click_organization_program_membership_reconnect',
OnboardingSetupDatabaseClick = 'click_onboarding_setup_database',
OnboardingApiReferencesClick = 'click_onboarding_api_references',
OnboardingTutorialsClick = 'click_onboarding_tutorials',
Expand Down Expand Up @@ -225,6 +226,7 @@ export enum Submit {
AccountRecoveryCodesCreate = 'submit_account_recovery_codes_create',
AccountRecoveryCodesUpdate = 'submit_account_recovery_codes_update',
AccountDeleteIdentity = 'submit_account_delete_identity',
AccountReconnectIdentity = 'submit_account_reconnect_identity',
AccountOAuth2ConsentApprove = 'submit_account_oauth2_consent_approve',
AccountOAuth2ConsentDeny = 'submit_account_oauth2_consent_deny',
AccountOAuth2DeviceVerify = 'submit_account_oauth2_device_verify',
Expand Down
19 changes: 19 additions & 0 deletions src/lib/helpers/github.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import { sdk } from '$lib/stores/sdk';
import { OAuthProvider } from '@appwrite.io/console';

/**
* Re-runs the GitHub OAuth flow for the account that is already signed in. The
* callback rebinds to the current user and overwrites the stored provider tokens,
* which is what refreshes an identity whose refresh token expired or was revoked.
*/
export function reconnectGithubIdentity(): void {
const returnUrl = window.location.origin + window.location.pathname;

sdk.forConsole.account.createOAuth2Session({
provider: OAuthProvider.Github,
success: returnUrl,
failure: returnUrl,
scopes: ['read:user', 'user:email']
});
}

export function getNestedRootDirectory(repository: string): string | null {
const match = repository.match(/\/tree\/[^/]+\/(.+)$/);
return match ? match[1] : null;
Expand Down
63 changes: 63 additions & 0 deletions src/lib/stores/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { user } from './user';

import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
import ProgramMembershipAlert from '$routes/(console)/organization-[organization]/programMembershipAlert.svelte';
import EnterpriseTrial from '$routes/(console)/organization-[organization]/enterpriseTrial.svelte';

export const roles = [
Expand Down Expand Up @@ -60,6 +61,22 @@ export const roles = [
export const teamStatusReadonly = 'readonly';
export const teamStatusUpgrading = 'upgrading';
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
export const programMembershipUnverified = 'program_membership_unverified';
export const programMembershipInvalid = 'program_membership_invalid';

/**
* Set once verification of a program membership starts failing because the linked
* GitHub identity can no longer be used. The API returns it, but the SDK's
* `Models.Organization` doesn't declare it yet.
*/
export function getProgramMembershipUnverifiedSince(
organization: Models.Organization
): string | null {
return (
(organization as unknown as { programMembershipUnverifiedSince?: string })
?.programMembershipUnverifiedSince ?? null
);
}

export const paymentMethods = derived(
page,
Expand Down Expand Up @@ -442,6 +459,47 @@ export function calculateTrialDay(org: Models.Organization) {
return days;
}

/**
* Keeps both program-membership banners in step with the organization.
*
* Exported because the layout gates checkForUsageLimit on the organization id, so an organization
* that is reverified or restricted without changing id would otherwise keep whichever banner it
* had — and a stale one outranks the alerts below it and renders nothing in their place.
*
* @returns whether the organization is restricted for a program membership reason
*/
export function syncProgramMembershipAlerts(organization: Models.Organization): boolean {
const restricted =
organization?.status === teamStatusReadonly &&
(organization?.remarks === programMembershipUnverified ||
organization?.remarks === programMembershipInvalid);

// Warning window: the GitHub link is dead but access isn't restricted yet.
const warning =
!!organization &&
organization?.status !== teamStatusReadonly &&
!!getProgramMembershipUnverifiedSince(organization);

// add() no-ops on a known id, so show has to be pushed on every call.
headerAlert.add({
id: 'programMembershipRestricted',
component: ProgramMembershipAlert,
show: restricted,
importance: 11
});
headerAlert.updateShow('programMembershipRestricted', restricted);

headerAlert.add({
id: 'programMembershipWarning',
component: ProgramMembershipAlert,
show: warning,
importance: 9
});
Comment on lines +488 to +497

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.

P1 Alert visibility remains stale

When the selected organization is reverified or transitions between warning and restricted states without changing its ID, the layout skips checkForUsageLimit, so these updateShow calls never receive the new state. The obsolete entry remains active in the priority store, suppressing lower-priority banners or continuing to show the warning instead of the access-restricted banner.

Knowledge Base Used: Console application shell

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/stores/billing.ts
Line: 493-502

Comment:
**Alert visibility remains stale**

When the selected organization is reverified or transitions between warning and restricted states without changing its ID, the layout skips `checkForUsageLimit`, so these `updateShow` calls never receive the new state. The obsolete entry remains active in the priority store, suppressing lower-priority banners or continuing to show the warning instead of the access-restricted banner.

**Knowledge Base Used:** [Console application shell](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/console/-/docs/console-application-shell.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

headerAlert.updateShow('programMembershipWarning', warning);

return restricted;
}

export async function checkForUsageLimit(organization: Models.Organization) {
if (
organization?.status === teamStatusReadonly &&
Expand All @@ -457,6 +515,11 @@ export async function checkForUsageLimit(organization: Models.Organization) {
return;
}

if (syncProgramMembershipAlerts(organization)) {
readOnly.set(true);
return;
}

if (!organization?.billingLimits && organization?.status !== teamStatusReadonly) {
readOnly.set(false);
return;
Expand Down
24 changes: 24 additions & 0 deletions src/routes/(console)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
checkForNewDevUpgradePro,
checkForUpgradingStatus,
checkForUsageLimit,
getProgramMembershipUnverifiedSince,
syncProgramMembershipAlerts,
checkPaymentAuthorizationRequired,
paymentExpired,
showUsageRatesModal
Expand Down Expand Up @@ -319,6 +321,28 @@

$: checkForUsageLimits($organization);

// checkForUsageLimits returns early on a repeat organization id, but status and remarks
// change without the id changing. Re-run the canonical check when they do: it owns both the
// banners and readOnly, and deciding readOnly here would clobber an invoice or budget hold.
let lastProgramMembershipState = null;
$: {
const org = $organization;
const state = org
? `${org.$id}:${org.status}:${org.remarks}:${getProgramMembershipUnverifiedSince(org) ?? ''}`
: null;

if (state !== lastProgramMembershipState) {
const seenBefore = lastProgramMembershipState !== null;
lastProgramMembershipState = state;

if (seenBefore && org) {
checkForUsageLimit(org);
} else {
syncProgramMembershipAlerts(org);
}
}
}

$: if ($requestedMigration) {
openMigrationWizard();
}
Expand Down
43 changes: 37 additions & 6 deletions src/routes/(console)/account/identities.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { oAuthProviders } from '$lib/stores/oauth-providers';
import { Card, Empty, Icon, Layout, Table } from '@appwrite.io/pink-svelte';
import { IconTrash } from '@appwrite.io/pink-icons-svelte';
import { Card, Empty, Icon, Layout, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { IconRefresh, IconTrash } from '@appwrite.io/pink-icons-svelte';
import { reconnectGithubIdentity } from '$lib/helpers/github';
import DualTimeView from '$lib/components/dualTimeView.svelte';

// Apps authorized through the OAuth2 server use an `oauth2:<appId>` provider
Expand All @@ -18,6 +19,20 @@
(identity) => !identity.provider?.startsWith('oauth2:')
);

function reconnectIdentity(provider: string) {
try {
// the flow redirects away, so track before handing off to GitHub.
trackEvent(Submit.AccountReconnectIdentity, { provider });
reconnectGithubIdentity();
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.AccountReconnectIdentity);
}
}

async function deleteIdentity(id: string) {
try {
await sdk.forConsole.account.deleteIdentity({ identityId: id });
Expand Down Expand Up @@ -58,7 +73,7 @@
{ id: 'email' },
{ id: 'createdAt' },
{ id: 'expiryDate' },
{ id: 'actions', width: 40 }
{ id: 'actions', width: 80 }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="provider" {root}>Provider</Table.Header.Cell>
Expand Down Expand Up @@ -96,9 +111,25 @@
{/if}
</Table.Cell>
<Table.Cell column="actions" {root}>
<Button text on:click={() => deleteIdentity(identity.$id)}>
<Icon icon={IconTrash} size="s" />
</Button>
<Layout.Stack direction="row" gap="xxs" alignItems="center">
{#if identity.provider === 'github'}
<Tooltip>
<Button
text
icon
ariaLabel="Reconnect GitHub"
on:click={() => reconnectIdentity(identity.provider)}>
<Icon icon={IconRefresh} size="s" />
</Button>
<span slot="tooltip">
Reconnect to refresh this identity’s access
</span>
</Tooltip>
{/if}
<Button text on:click={() => deleteIdentity(identity.$id)}>
<Icon icon={IconTrash} size="s" />
</Button>
</Layout.Stack>
</Table.Cell>
</Table.Row.Base>
{/each}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script lang="ts">
import { page } from '$app/state';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Button } from '$lib/elements/forms';
import { reconnectGithubIdentity } from '$lib/helpers/github';
import { HeaderAlert } from '$lib/layout';
import {
getChangePlanUrl,
getProgramMembershipUnverifiedSince,
hideBillingHeaderRoutes,
programMembershipInvalid,
programMembershipUnverified,
teamStatusReadonly
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';

const isRestricted = $derived($organization?.status === teamStatusReadonly);

// GitHub confirmed the account is no longer a student, so reconnecting can't help.
const isInvalid = $derived(isRestricted && $organization?.remarks === programMembershipInvalid);
const isUnverified = $derived(
isRestricted && $organization?.remarks === programMembershipUnverified
);
const isWarning = $derived(
!isRestricted && !!getProgramMembershipUnverifiedSince($organization)
);

const show = $derived(
!!$organization?.$id &&
!hideBillingHeaderRoutes.includes(page.url.pathname) &&
(isInvalid || isUnverified || isWarning)
);

function onReconnect() {
trackEvent(Click.OrganizationProgramMembershipReconnect, {
source: isWarning ? 'program_membership_warning' : 'program_membership_restricted'
});
reconnectGithubIdentity();
}
</script>

{#if show}
{#if isInvalid}
<HeaderAlert type="error" title="Access restricted">
GitHub is no longer confirming student status for <b>{$organization.name}</b>, so this
organization is no longer eligible for the Appwrite Education Program and its access to
resources has been restricted. Choose a plan to restore access.
<svelte:fragment slot="buttons">
<Button secondary fullWidthMobile href={getChangePlanUrl($organization.$id)}>
<span class="text">View plans</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{:else if isUnverified}
<HeaderAlert type="error" title="Access restricted">
We couldn’t verify the Appwrite Education Program membership for <b
>{$organization.name}</b>
because its GitHub connection expired or was disconnected, so access to resources has been
restricted. Reconnect GitHub to restore access.
<svelte:fragment slot="buttons">
<Button secondary fullWidthMobile on:click={onReconnect}>
<span class="text">Reconnect GitHub</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{:else}
<HeaderAlert type="warning" title="Reconnect GitHub to keep your education plan">
We can’t verify the Appwrite Education Program membership for <b
>{$organization.name}</b>
because its GitHub connection expired or was disconnected. Reconnect GitHub to keep this organization
on its current plan — if the connection isn’t restored, access to resources will be restricted.
<svelte:fragment slot="buttons">
<Button secondary fullWidthMobile on:click={onReconnect}>
<span class="text">Reconnect GitHub</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
{/if}