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..d8afcab60a 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, @@ -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 + }); + headerAlert.updateShow('programMembershipWarning', warning); + + return restricted; +} + export async function checkForUsageLimit(organization: Models.Organization) { if ( organization?.status === teamStatusReadonly && @@ -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; diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index b0f4267981..ffe0033fec 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -20,6 +20,8 @@ checkForNewDevUpgradePro, checkForUpgradingStatus, checkForUsageLimit, + getProgramMembershipUnverifiedSince, + syncProgramMembershipAlerts, checkPaymentAuthorizationRequired, paymentExpired, showUsageRatesModal @@ -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(); } 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}