From 9eca606579af940d9ec01373d55c67d491484027 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Aug 2026 10:15:03 +0545 Subject: [PATCH 1/5] feat(billing): ask for a GitHub reconnect when membership cannot be verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GitHub refresh token dies at six months, and revoking the OAuth app kills it outright. Either way Appwrite can no longer confirm Education Program membership, and until now the console said nothing — the check simply failed and the organization drifted on a plan nobody could verify. checkForUsageLimit gains two branches. While the organization is still active and programMembershipUnverifiedSince is set, a warning banner asks for a reconnect without touching readOnly, so the budget and limit paths below it run unchanged. Once the organization is restricted the banner becomes an error, and program_membership_invalid gets its own copy: GitHub has confirmed the account is no longer a student, so reconnecting cannot help and the action points at plans instead. Reconnecting needs no backend work — the OAuth callback binds to whoever is logged in and overwrites the stored tokens — so both entry points reuse one helper, keeping the scopes that mint the refresh token from drifting apart. Account settings gains the same action per GitHub identity, which previously offered only delete. --- src/lib/actions/analytics.ts | 2 + src/lib/helpers/github.ts | 19 +++++ src/lib/stores/billing.ts | 46 +++++++++++ .../(console)/account/identities.svelte | 43 ++++++++-- .../programMembershipAlert.svelte | 79 +++++++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/routes/(console)/organization-[organization]/programMembershipAlert.svelte diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index ebd6e8abf0..5ee56dcdee 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -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', @@ -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', diff --git a/src/lib/helpers/github.ts b/src/lib/helpers/github.ts index b173d6d221..36d625c938 100644 --- a/src/lib/helpers/github.ts +++ b/src/lib/helpers/github.ts @@ -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; diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts index f413955175..209ef15f9d 100644 --- a/src/lib/stores/billing.ts +++ b/src/lib/stores/billing.ts @@ -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 = [ @@ -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, @@ -457,6 +474,35 @@ export async function checkForUsageLimit(organization: Models.Organization) { return; } + if ( + organization?.status === teamStatusReadonly && + (organization?.remarks === programMembershipUnverified || + organization?.remarks === programMembershipInvalid) + ) { + headerAlert.add({ + id: 'programMembershipRestricted', + component: ProgramMembershipAlert, + show: true, + importance: 11 + }); + readOnly.set(true); + return; + } + + // Warning window: the GitHub link is dead but access isn't restricted yet, + // so this only surfaces the alert and lets the usage checks below run. + if ( + organization?.status !== teamStatusReadonly && + getProgramMembershipUnverifiedSince(organization) + ) { + headerAlert.add({ + id: 'programMembershipWarning', + component: ProgramMembershipAlert, + show: true, + importance: 9 + }); + } + if (!organization?.billingLimits && organization?.status !== teamStatusReadonly) { readOnly.set(false); return; diff --git a/src/routes/(console)/account/identities.svelte b/src/routes/(console)/account/identities.svelte index 3e7b71747d..7b38ba8b13 100644 --- a/src/routes/(console)/account/identities.svelte +++ b/src/routes/(console)/account/identities.svelte @@ -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:` provider @@ -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 }); @@ -58,7 +73,7 @@ { id: 'email' }, { id: 'createdAt' }, { id: 'expiryDate' }, - { id: 'actions', width: 40 } + { id: 'actions', width: 80 } ]}> Provider @@ -96,9 +111,25 @@ {/if} - + + {#if identity.provider === 'github'} + + + + Reconnect to refresh this identity’s access + + + {/if} + + {/each} diff --git a/src/routes/(console)/organization-[organization]/programMembershipAlert.svelte b/src/routes/(console)/organization-[organization]/programMembershipAlert.svelte new file mode 100644 index 0000000000..e54c1bc6d1 --- /dev/null +++ b/src/routes/(console)/organization-[organization]/programMembershipAlert.svelte @@ -0,0 +1,79 @@ + + +{#if show} + {#if isInvalid} + + GitHub is no longer confirming student status for {$organization.name}, 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. + + + + + {:else if isUnverified} + + We couldn’t verify the Appwrite Education Program membership for {$organization.name} + because its GitHub connection expired or was disconnected, so access to resources has been + restricted. Reconnect GitHub to restore access. + + + + + {:else} + + We can’t verify the Appwrite Education Program membership for {$organization.name} + 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. + + + + + {/if} +{/if} From fef0e24e145b56b250d984ecd00277c636b1ac02 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Aug 2026 10:36:26 +0545 Subject: [PATCH 2/5] fix(billing): keep the program membership alerts in sync both ways headerAlert.add() no-ops on an id it already holds, so adding with show:true was a one-way door: once an organization reverified, the entry stayed visible in the priority store while its component rendered nothing, and because it outranks the alerts below it, they were blanked rather than shown. Both ids now carry the current condition on every call. --- src/lib/stores/billing.ts | 50 +++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts index 209ef15f9d..f797cafbe4 100644 --- a/src/lib/stores/billing.ts +++ b/src/lib/stores/billing.ts @@ -474,33 +474,37 @@ export async function checkForUsageLimit(organization: Models.Organization) { return; } - if ( + const programMembershipRestricted = organization?.status === teamStatusReadonly && (organization?.remarks === programMembershipUnverified || - organization?.remarks === programMembershipInvalid) - ) { - headerAlert.add({ - id: 'programMembershipRestricted', - component: ProgramMembershipAlert, - show: true, - importance: 11 - }); - readOnly.set(true); - return; - } + organization?.remarks === programMembershipInvalid); - // Warning window: the GitHub link is dead but access isn't restricted yet, - // so this only surfaces the alert and lets the usage checks below run. - if ( + // Warning window: the GitHub link is dead but access isn't restricted yet. + const programMembershipWarning = organization?.status !== teamStatusReadonly && - getProgramMembershipUnverifiedSince(organization) - ) { - headerAlert.add({ - id: 'programMembershipWarning', - component: ProgramMembershipAlert, - show: true, - importance: 9 - }); + !!getProgramMembershipUnverifiedSince(organization); + + // add() no-ops on a known id, so show has to be pushed on every call. A stale show:true + // outranks lower-priority alerts and renders nothing in their place. + headerAlert.add({ + id: 'programMembershipRestricted', + component: ProgramMembershipAlert, + show: programMembershipRestricted, + importance: 11 + }); + headerAlert.updateShow('programMembershipRestricted', programMembershipRestricted); + + headerAlert.add({ + id: 'programMembershipWarning', + component: ProgramMembershipAlert, + show: programMembershipWarning, + importance: 9 + }); + headerAlert.updateShow('programMembershipWarning', programMembershipWarning); + + if (programMembershipRestricted) { + readOnly.set(true); + return; } if (!organization?.billingLimits && organization?.status !== teamStatusReadonly) { From 393f8dea6c1919566234121dcf3a65f732b14f48 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Aug 2026 10:48:17 +0545 Subject: [PATCH 3/5] fix(billing): sync the membership banners outside the organization-id gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkForUsageLimits returns early when the organization id is unchanged, so an organization reverified or restricted mid-session never re-ran the checks — and the banner it already had stayed put, outranking the alerts below it while rendering nothing. The sync is now its own exported function, called from the layout on every organization change rather than only on a new id. checkForUsageLimit still calls it, so the readOnly and delete-member paths are unchanged, and activeHeaderAlert already recomputes from the store. --- src/lib/stores/billing.ts | 61 +++++++++++++++++------------ src/routes/(console)/+layout.svelte | 5 +++ 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts index f797cafbe4..d8afcab60a 100644 --- a/src/lib/stores/billing.ts +++ b/src/lib/stores/billing.ts @@ -459,50 +459,63 @@ export function calculateTrialDay(org: Models.Organization) { return days; } -export async function checkForUsageLimit(organization: Models.Organization) { - if ( - organization?.status === teamStatusReadonly && - organization?.remarks === billingLimitOutstandingInvoice - ) { - headerAlert.add({ - id: 'teamReadOnlyFailedInvoices', - component: TeamReadonlyAlert, - show: true, - importance: 11 - }); - readOnly.set(true); - return; - } - - const programMembershipRestricted = +/** + * 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 programMembershipWarning = + const warning = + !!organization && organization?.status !== teamStatusReadonly && !!getProgramMembershipUnverifiedSince(organization); - // add() no-ops on a known id, so show has to be pushed on every call. A stale show:true - // outranks lower-priority alerts and renders nothing in their place. + // add() no-ops on a known id, so show has to be pushed on every call. headerAlert.add({ id: 'programMembershipRestricted', component: ProgramMembershipAlert, - show: programMembershipRestricted, + show: restricted, importance: 11 }); - headerAlert.updateShow('programMembershipRestricted', programMembershipRestricted); + headerAlert.updateShow('programMembershipRestricted', restricted); headerAlert.add({ id: 'programMembershipWarning', component: ProgramMembershipAlert, - show: programMembershipWarning, + show: warning, importance: 9 }); - headerAlert.updateShow('programMembershipWarning', programMembershipWarning); + headerAlert.updateShow('programMembershipWarning', warning); + + return restricted; +} + +export async function checkForUsageLimit(organization: Models.Organization) { + if ( + organization?.status === teamStatusReadonly && + organization?.remarks === billingLimitOutstandingInvoice + ) { + headerAlert.add({ + id: 'teamReadOnlyFailedInvoices', + component: TeamReadonlyAlert, + show: true, + importance: 11 + }); + readOnly.set(true); + return; + } - if (programMembershipRestricted) { + if (syncProgramMembershipAlerts(organization)) { readOnly.set(true); return; } diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index b0f4267981..49e5c6082f 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -20,6 +20,7 @@ checkForNewDevUpgradePro, checkForUpgradingStatus, checkForUsageLimit, + syncProgramMembershipAlerts, checkPaymentAuthorizationRequired, paymentExpired, showUsageRatesModal @@ -319,6 +320,10 @@ $: checkForUsageLimits($organization); + // Not gated on the organization id: status and remarks change without it, and the banners + // above have to follow. checkForUsageLimits returns early on a repeat id. + $: syncProgramMembershipAlerts($organization); + $: if ($requestedMigration) { openMigrationWizard(); } From 124d5eb840abe0a2b8a1b55d8ed775b2634de7a7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Aug 2026 13:27:21 +0545 Subject: [PATCH 4/5] fix(billing): raise readOnly when the membership restriction is detected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unguarded sync updated the banner but threw away what it returned, while readOnly stayed behind the organization-id gate. An organization restricted mid-session therefore showed "access restricted" with its member, project and resource controls still enabled — the banner and the write gates disagreeing, which is worse than both being stale together. It only ever raises readOnly, never clears it: the flag may already be true for an unpaid invoice or a budget, and this check knows nothing about those. --- src/routes/(console)/+layout.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index 49e5c6082f..a4ac0fe083 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -20,6 +20,7 @@ checkForNewDevUpgradePro, checkForUpgradingStatus, checkForUsageLimit, + readOnly, syncProgramMembershipAlerts, checkPaymentAuthorizationRequired, paymentExpired, @@ -322,7 +323,11 @@ // Not gated on the organization id: status and remarks change without it, and the banners // above have to follow. checkForUsageLimits returns early on a repeat id. - $: syncProgramMembershipAlerts($organization); + // Only ever raises readOnly: it may already be true for an invoice or a budget, and this + // check knows nothing about those. + $: if (syncProgramMembershipAlerts($organization)) { + readOnly.set(true); + } $: if ($requestedMigration) { openMigrationWizard(); From 733ecc43242b21ec2ab2a910b1b356e40085137c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 26 Aug 2026 14:12:19 +0545 Subject: [PATCH 5/5] fix(billing): re-run the usage check when membership state changes Deciding readOnly from the membership sync alone could only ever be half right. Raising it left a reverified organization locked; clearing it would have unlocked one held for an unpaid invoice or a budget, which this check knows nothing about. checkForUsageLimit already owns that decision for every reason at once. The layout gates it on the organization id, so it is re-run when status, remarks or the unverified stamp change under a stable id, and left alone otherwise. First sight of an organization still goes through the id-gated path, which runs the rest of the billing checks with it. --- src/routes/(console)/+layout.svelte | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index a4ac0fe083..ffe0033fec 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -20,7 +20,7 @@ checkForNewDevUpgradePro, checkForUpgradingStatus, checkForUsageLimit, - readOnly, + getProgramMembershipUnverifiedSince, syncProgramMembershipAlerts, checkPaymentAuthorizationRequired, paymentExpired, @@ -321,12 +321,26 @@ $: checkForUsageLimits($organization); - // Not gated on the organization id: status and remarks change without it, and the banners - // above have to follow. checkForUsageLimits returns early on a repeat id. - // Only ever raises readOnly: it may already be true for an invoice or a budget, and this - // check knows nothing about those. - $: if (syncProgramMembershipAlerts($organization)) { - readOnly.set(true); + // 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) {