diff --git a/web-admin/src/features/admin/billing/selectors.ts b/web-admin/src/features/admin/billing/selectors.ts new file mode 100644 index 00000000000..34effd26dca --- /dev/null +++ b/web-admin/src/features/admin/billing/selectors.ts @@ -0,0 +1,41 @@ +// web-admin/src/features/admin/billing/selectors.ts +import { + adminServiceGetPaymentsPortalURL, + createAdminServiceSudoExtendTrial, + createAdminServiceSudoTriggerBillingRepair, + createAdminServiceSudoDeleteOrganizationBillingIssue, + createAdminServiceSudoUpdateOrganizationBillingCustomer, + createAdminServiceListOrganizationBillingIssues, +} from "@rilldata/web-admin/client"; + +export async function getBillingSetupURL(org: string): Promise { + const resp = await adminServiceGetPaymentsPortalURL(org, { + setup: true, + superuserForceAccess: true, + }); + return resp.url ?? ""; +} + +export function createExtendTrialMutation() { + return createAdminServiceSudoExtendTrial(); +} + +export function createBillingRepairMutation() { + return createAdminServiceSudoTriggerBillingRepair(); +} + +export function createDeleteBillingIssueMutation() { + return createAdminServiceSudoDeleteOrganizationBillingIssue(); +} + +export function createSetBillingCustomerMutation() { + return createAdminServiceSudoUpdateOrganizationBillingCustomer(); +} + +export function getBillingIssues(org: string) { + return createAdminServiceListOrganizationBillingIssues( + org, + { superuserForceAccess: true }, + { query: { enabled: org.length > 0 } }, + ); +} diff --git a/web-admin/src/features/admin/layout/AdminPageHeader.svelte b/web-admin/src/features/admin/layout/AdminPageHeader.svelte new file mode 100644 index 00000000000..da7eb7bfdce --- /dev/null +++ b/web-admin/src/features/admin/layout/AdminPageHeader.svelte @@ -0,0 +1,15 @@ + + +
+

+ {title} +

+ {#if description} +

+ {description} +

+ {/if} +
diff --git a/web-admin/src/features/admin/layout/AdminSidebar.svelte b/web-admin/src/features/admin/layout/AdminSidebar.svelte new file mode 100644 index 00000000000..2de43af91ee --- /dev/null +++ b/web-admin/src/features/admin/layout/AdminSidebar.svelte @@ -0,0 +1,68 @@ + + + + diff --git a/web-admin/src/features/admin/organizations/selectors.ts b/web-admin/src/features/admin/organizations/selectors.ts new file mode 100644 index 00000000000..0e0d25046f9 --- /dev/null +++ b/web-admin/src/features/admin/organizations/selectors.ts @@ -0,0 +1,39 @@ +// web-admin/src/features/admin/organizations/selectors.ts +import { + createAdminServiceGetOrganization, + createAdminServiceListOrganizationMemberUsers, + createAdminServiceListProjectsForOrganization, + createAdminServiceSearchProjectNames, +} from "@rilldata/web-admin/client"; + +export function getOrganization(org: string) { + return createAdminServiceGetOrganization( + org, + { superuserForceAccess: true }, + { query: { enabled: org.length > 0 } }, + ); +} + +export function getOrgMembers(org: string) { + return createAdminServiceListOrganizationMemberUsers( + org, + { superuserForceAccess: true }, + { query: { enabled: org.length > 0 } }, + ); +} + +export function getOrgProjects(org: string) { + return createAdminServiceListProjectsForOrganization( + org, + {}, + { query: { enabled: org.length > 0 } }, + ); +} + +// Search for org names by searching project paths (org/project) and extracting unique org names +export function searchOrgNames(query: string) { + return createAdminServiceSearchProjectNames( + { namePattern: `%${query}%/%`, pageSize: 100 }, + { query: { enabled: query.length >= 3 } }, + ); +} diff --git a/web-admin/src/features/admin/projects/selectors.ts b/web-admin/src/features/admin/projects/selectors.ts new file mode 100644 index 00000000000..b1c2595ab1c --- /dev/null +++ b/web-admin/src/features/admin/projects/selectors.ts @@ -0,0 +1,31 @@ +// web-admin/src/features/admin/projects/selectors.ts +import { + createAdminServiceSearchProjectNames, + createAdminServiceGetProject, + createAdminServiceUpdateProject, + createAdminServiceRedeployProject, + createAdminServiceHibernateProject, +} from "@rilldata/web-admin/client"; + +export function searchProjects(namePattern: string) { + return createAdminServiceSearchProjectNames( + { namePattern: `%${namePattern}%`, pageSize: 50 }, + { query: { enabled: namePattern.length >= 3 } }, + ); +} + +export function getProject(org: string, project: string) { + return createAdminServiceGetProject(org, project); +} + +export function createUpdateProjectMutation() { + return createAdminServiceUpdateProject(); +} + +export function createRedeployProjectMutation() { + return createAdminServiceRedeployProject(); +} + +export function createHibernateProjectMutation() { + return createAdminServiceHibernateProject(); +} diff --git a/web-admin/src/features/admin/quotas/selectors.ts b/web-admin/src/features/admin/quotas/selectors.ts new file mode 100644 index 00000000000..86220b40870 --- /dev/null +++ b/web-admin/src/features/admin/quotas/selectors.ts @@ -0,0 +1,17 @@ +// web-admin/src/features/admin/quotas/selectors.ts +import { + createAdminServiceGetOrganization, + createAdminServiceSudoUpdateOrganizationQuotas, +} from "@rilldata/web-admin/client"; + +export function getOrgForQuotas(org: string) { + return createAdminServiceGetOrganization( + org, + { superuserForceAccess: true }, + { query: { enabled: org.length > 0 } }, + ); +} + +export function createUpdateOrgQuotasMutation() { + return createAdminServiceSudoUpdateOrganizationQuotas(); +} diff --git a/web-admin/src/features/admin/shared/ConfirmDialog.svelte b/web-admin/src/features/admin/shared/ConfirmDialog.svelte new file mode 100644 index 00000000000..50a8b869dbd --- /dev/null +++ b/web-admin/src/features/admin/shared/ConfirmDialog.svelte @@ -0,0 +1,71 @@ + + + +{#if open} + + +
+
+

+ {title} +

+ {#if description} +

+ {description} +

+ {/if} +
+ + +
+
+
+{/if} diff --git a/web-admin/src/features/admin/shared/OrgSearchInput.svelte b/web-admin/src/features/admin/shared/OrgSearchInput.svelte new file mode 100644 index 00000000000..3c55936d734 --- /dev/null +++ b/web-admin/src/features/admin/shared/OrgSearchInput.svelte @@ -0,0 +1,92 @@ + + + +
+ + {#if $orgSearchQuery.isFetching && value.length >= 3} +
+ {/if} + {#if showDropdown && orgNames.length > 0} +
+ {#each orgNames as org} + + {/each} +
+ {/if} +
diff --git a/web-admin/src/features/admin/shared/SearchInput.svelte b/web-admin/src/features/admin/shared/SearchInput.svelte new file mode 100644 index 00000000000..518f542158e --- /dev/null +++ b/web-admin/src/features/admin/shared/SearchInput.svelte @@ -0,0 +1,37 @@ + + + +
+ e.key === "Enter" && handleSubmit()} + /> +
diff --git a/web-admin/src/features/admin/shared/UserSearchInput.svelte b/web-admin/src/features/admin/shared/UserSearchInput.svelte new file mode 100644 index 00000000000..2ad5c147cd7 --- /dev/null +++ b/web-admin/src/features/admin/shared/UserSearchInput.svelte @@ -0,0 +1,81 @@ + + + +
+ + {#if $usersQuery.isFetching && value.length >= 3} +
+ {/if} + {#if showDropdown && emails.length > 0} +
+ {#each emails as email} + + {/each} +
+ {/if} +
diff --git a/web-admin/src/features/admin/shared/notify.ts b/web-admin/src/features/admin/shared/notify.ts new file mode 100644 index 00000000000..515ed09b97b --- /dev/null +++ b/web-admin/src/features/admin/shared/notify.ts @@ -0,0 +1,9 @@ +import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; + +export function notifySuccess(message: string) { + eventBus.emit("notification", { type: "success", message }); +} + +export function notifyError(message: string) { + eventBus.emit("notification", { type: "error", message }); +} diff --git a/web-admin/src/features/admin/users/RepresentingBanner.svelte b/web-admin/src/features/admin/users/RepresentingBanner.svelte new file mode 100644 index 00000000000..a834fdd734c --- /dev/null +++ b/web-admin/src/features/admin/users/RepresentingBanner.svelte @@ -0,0 +1,50 @@ + + diff --git a/web-admin/src/features/admin/users/assume-state.ts b/web-admin/src/features/admin/users/assume-state.ts new file mode 100644 index 00000000000..a770d05bc1a --- /dev/null +++ b/web-admin/src/features/admin/users/assume-state.ts @@ -0,0 +1,53 @@ +// Manages assume/unassume user state for the admin console. +// Uses sessionStorage to track the assumed user across navigations, +// and server-side auth endpoints for cookie management. +import { writable } from "svelte/store"; +import { browser } from "$app/environment"; +import { ADMIN_URL } from "@rilldata/web-admin/client/http-client"; + +export const STORAGE_KEY = "rill-representing-user"; + +function appendPath(path: string, suffix: string) { + return `${path.replace(/\/$/, "")}/${suffix}`; +} + +// Store tracks the currently assumed user email +const initial = browser ? sessionStorage.getItem(STORAGE_KEY) ?? "" : ""; +const { subscribe, set } = writable(initial); + +export const assumedUser = { + subscribe, + + /** + * Navigates to Rill Cloud as the given user in the current tab. + * Stores the email in sessionStorage so the banner knows who we're browsing as. + */ + assume(email: string, ttlMinutes = 60) { + set(email); + if (browser) sessionStorage.setItem(STORAGE_KEY, email); + + const u = new URL(ADMIN_URL); + u.pathname = appendPath(u.pathname, "auth/assume-open"); + u.searchParams.set("representing_user", email); + u.searchParams.set("ttl_minutes", String(ttlMinutes)); + window.location.href = u.toString(); + }, + + /** + * Reverts to the original superuser session. + * Redirects to /auth/login which re-authenticates through the auth provider. + * Since the superuser's auth provider session is still active, this auto-completes + * and issues a fresh superuser cookie, effectively "unassuming". + */ + unassume() { + set(""); + if (browser) sessionStorage.removeItem(STORAGE_KEY); + + // Redirect to login; the auth provider (Auth0) session is the real superuser, + // so it auto-completes and issues a fresh superuser token. + const u = new URL(ADMIN_URL); + u.pathname = appendPath(u.pathname, "auth/login"); + u.searchParams.set("redirect", window.location.origin); + window.location.href = u.toString(); + }, +}; diff --git a/web-admin/src/features/admin/users/selectors.ts b/web-admin/src/features/admin/users/selectors.ts new file mode 100644 index 00000000000..e72ea26636d --- /dev/null +++ b/web-admin/src/features/admin/users/selectors.ts @@ -0,0 +1,16 @@ +// web-admin/src/features/admin/users/selectors.ts +import { + createAdminServiceSearchUsers, + createAdminServiceDeleteUser, +} from "@rilldata/web-admin/client"; + +export function searchUsers(emailPattern: string) { + return createAdminServiceSearchUsers( + { emailPattern: `%${emailPattern}%` }, + { query: { enabled: emailPattern.length >= 3 } }, + ); +} + +export function createDeleteUserMutation() { + return createAdminServiceDeleteUser(); +} diff --git a/web-admin/src/features/authentication/AvatarButton.svelte b/web-admin/src/features/authentication/AvatarButton.svelte index 95dc0bb5d04..5fe56042474 100644 --- a/web-admin/src/features/authentication/AvatarButton.svelte +++ b/web-admin/src/features/authentication/AvatarButton.svelte @@ -22,12 +22,22 @@ type UserLike, } from "@rilldata/web-common/features/help/initPylonChat"; import { posthogIdentify } from "@rilldata/web-common/lib/analytics/posthog"; - import { createAdminServiceGetCurrentUser } from "../../client"; + import { + createAdminServiceGetCurrentUser, + createAdminServiceListSuperusers, + } from "../../client"; import ProjectAccessControls from "../projects/ProjectAccessControls.svelte"; import ViewAsUserPopover from "../view-as-user/ViewAsUserPopover.svelte"; import ThemeToggle from "@rilldata/web-common/features/themes/ThemeToggle.svelte"; const user = createAdminServiceGetCurrentUser(); + const superusers = createAdminServiceListSuperusers(); + $: isSuperuser = + $superusers.isSuccess && + !!$user.data?.user?.email && + ($superusers.data?.users ?? []).some( + (su) => su.email === $user.data?.user?.email, + ); let imgContainer: HTMLElement; let primaryMenuOpen = false; @@ -126,6 +136,11 @@ {/if} {/if} + {#if isSuperuser} + Admin Console + + {/if} + diff --git a/web-admin/src/routes/+layout.svelte b/web-admin/src/routes/+layout.svelte index 76ba904d827..63c863532ad 100644 --- a/web-admin/src/routes/+layout.svelte +++ b/web-admin/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import { createUserFacingError } from "@rilldata/web-admin/components/errors/user-facing-errors"; import { dynamicHeight } from "@rilldata/web-common/layout/layout-settings.ts"; import BillingBannerManager from "@rilldata/web-admin/features/billing/banner/BillingBannerManager.svelte"; + import RepresentingBanner from "@rilldata/web-admin/features/admin/users/RepresentingBanner.svelte"; import { isBillingUpgradePage, isProjectInvitePage, @@ -144,6 +145,7 @@ use:pageContentSizeHandler > + {#if !hideBillingManager} {/if} diff --git a/web-admin/src/routes/-/admin/+layout.svelte b/web-admin/src/routes/-/admin/+layout.svelte new file mode 100644 index 00000000000..5636b2c3a6c --- /dev/null +++ b/web-admin/src/routes/-/admin/+layout.svelte @@ -0,0 +1,14 @@ + + + + Admin Console | Rill + + +
+ +
+ +
+
diff --git a/web-admin/src/routes/-/admin/+layout.ts b/web-admin/src/routes/-/admin/+layout.ts new file mode 100644 index 00000000000..7dd63351c0b --- /dev/null +++ b/web-admin/src/routes/-/admin/+layout.ts @@ -0,0 +1,64 @@ +// web-admin/src/routes/-/admin/+layout.ts +import { + adminServiceGetCurrentUser, + adminServiceListSuperusers, + getAdminServiceGetCurrentUserQueryKey, + getAdminServiceListSuperusersQueryKey, + type V1GetCurrentUserResponse, + type V1ListSuperusersResponse, +} from "@rilldata/web-admin/client"; +import { redirectToLogin } from "@rilldata/web-admin/client/redirect-utils"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; +import { redirect } from "@sveltejs/kit"; +import { isAxiosError } from "axios"; + +export const load = async () => { + // Get current user + let currentUserEmail: string | undefined; + try { + const userResp = await queryClient.fetchQuery({ + queryKey: getAdminServiceGetCurrentUserQueryKey(), + queryFn: () => adminServiceGetCurrentUser(), + staleTime: 5 * 60 * 1000, + }); + currentUserEmail = userResp.user?.email; + } catch (e) { + if (isAxiosError(e) && e.response?.status === 401) { + // redirectToLogin() throws a SvelteKit redirect internally. + // It's safe to call here; the redirect propagates out of the catch block. + redirectToLogin(); + } + throw redirect(307, "/"); + } + + if (!currentUserEmail) { + throw redirect(307, "/"); + } + + // Check if current user is a superuser + try { + const superusersResp = + await queryClient.fetchQuery({ + queryKey: getAdminServiceListSuperusersQueryKey(), + queryFn: () => adminServiceListSuperusers(), + staleTime: 5 * 60 * 1000, + }); + + const isSuperuser = superusersResp.users?.some( + (u) => u.email === currentUserEmail, + ); + + if (!isSuperuser) { + throw redirect(307, "/"); + } + } catch (e) { + // ListSuperusers itself will 403 if not a superuser + if (isAxiosError(e) && e.response?.status === 403) { + throw redirect(307, "/"); + } + // Re-throw SvelteKit redirects + throw e; + } + + return { currentUserEmail }; +}; diff --git a/web-admin/src/routes/-/admin/+page.svelte b/web-admin/src/routes/-/admin/+page.svelte new file mode 100644 index 00000000000..056c969bad2 --- /dev/null +++ b/web-admin/src/routes/-/admin/+page.svelte @@ -0,0 +1,207 @@ + + + + +{#if $assumedUser} +
+ Currently assumed as {$assumedUser} + +
+{/if} + +
+ +
+ +{#if $usersQuery.isFetching && searchQuery.length >= 3} +
+
+ Searching users... +
+{:else if $usersQuery.data?.users?.length} +

+ {$usersQuery.data.users.length} result{$usersQuery.data.users.length === 1 + ? "" + : "s"} +

+
+ + + + + + + + + + + {#each $usersQuery.data.users as user} + {@const isAssumed = $assumedUser === user.email} + + + + + + + {/each} + +
+ Email + + Display Name + + Created + + Actions +
+ {user.email} + + {user.displayName ?? "-"} + + {user.createdOn + ? new Date(user.createdOn).toLocaleDateString() + : "-"} + +
+ {#if isAssumed} + + {:else} + + {/if} + +
+
+
+{:else if searchQuery.length >= 3 && $usersQuery.isSuccess} +

No users found for "{searchQuery}"

+{:else if searchQuery.length < 3} +

+ Type at least 3 characters to search across all organizations. +

+{/if} + + diff --git a/web-admin/src/routes/-/admin/billing/+page.svelte b/web-admin/src/routes/-/admin/billing/+page.svelte new file mode 100644 index 00000000000..3d5a3f4e619 --- /dev/null +++ b/web-admin/src/routes/-/admin/billing/+page.svelte @@ -0,0 +1,364 @@ + + + + +
+ +
+

+ Billing Setup +

+

+ Generate a Stripe checkout page link for an organization to enter their + billing information. +

+
+
+ +
+ +
+ {#if setupUrl} +
+ Share this link with the customer: +
+ + {setupUrl} + + +
+
+ {/if} +
+ + +
+

+ Extend Trial +

+

+ Add days to an organization's trial period. +

+
+
+ +
+ + +
+
+ + +
+

+ Set Billing Customer ID +

+

+ Associate a Stripe customer ID with an organization. +

+
+
+ +
+ + +
+
+ + +
+

+ Billing Repair +

+

+ Trigger a billing state recalculation for an organization. +

+
+
+ +
+ +
+
+ + +
+

+ Billing Issues +

+

+ View and resolve billing issues for an organization. +

+
+
+ +
+
+ {#if $billingIssuesQuery.isFetching} +
+
+ Loading issues... +
+ {:else if $billingIssuesQuery.data?.issues?.length} +
+ {#each $billingIssuesQuery.data.issues as issue} +
+
+ {issue.type} + {issue.metadata ?? ""} +
+ +
+ {/each} +
+ {:else if issuesOrg && $billingIssuesQuery.isSuccess} +

No billing issues found.

+ {/if} +
+
+ + diff --git a/web-admin/src/routes/-/admin/organizations/+page.svelte b/web-admin/src/routes/-/admin/organizations/+page.svelte new file mode 100644 index 00000000000..b9bf7774153 --- /dev/null +++ b/web-admin/src/routes/-/admin/organizations/+page.svelte @@ -0,0 +1,385 @@ + + + + +
+
+ + {#if $orgSearchQuery.isFetching && searchInput.length >= 3} +
+ {/if} + {#if showDropdown && orgNames.length > 0} +
+ {#each orgNames as org} + + {/each} +
+ {:else if showDropdown && searchInput.length >= 3 && $orgSearchQuery.isSuccess && orgNames.length === 0} +
+
No organizations found
+
+ {/if} +
+

+ Type to search, then select or press Enter for exact match. +

+
+ +{#if $orgQuery.isFetching} +
+
+ Looking up organization... +
+{:else if $orgQuery.isError && lookupOrg} +

+ Organization "{lookupOrg}" not found or access denied. +

+{:else if $orgQuery.data?.organization} + {@const org = $orgQuery.data.organization} +
+
+

+ Organization Details +

+
+
+ ID + {org.id} +
+
+ Name + {org.name} +
+
+ Description + {org.description ?? "-"} +
+
+ Billing Plan + {org.billingPlanDisplayName ?? "-"} +
+
+ Billing Customer ID + {org.billingCustomerId ?? "-"} +
+
+ Custom Domain + {org.customDomain ?? "None"} +
+
+ Created + + {org.createdOn + ? new Date(org.createdOn).toLocaleDateString() + : "-"} + +
+
+ Projects + + {#if $projectsQuery.isFetching} + Loading... + {:else if $projectsQuery.data?.projects} + {$projectsQuery.data.projects.length} + {:else} + 0 + {/if} + +
+
+
+ + + {#if $projectsQuery.data?.projects?.length} +
+

+ Projects ({$projectsQuery.data.projects.length}) +

+
+ {#each $projectsQuery.data.projects as project} +
+ + {project.name} + +
+ + +
+
+ {/each} +
+
+ {/if} + + + {#if $membersQuery.isFetching} +
+
+ Loading members... +
+ {:else if $membersQuery.data?.members?.length} +
+

+ Members ({$membersQuery.data.members.length}) +

+ + + + + + + + + {#each $membersQuery.data.members as member} + + + + + {/each} + +
+ Email + + Role +
+ {member.userEmail} + + {member.roleName} +
+
+ {/if} +
+{/if} + + diff --git a/web-admin/src/routes/-/admin/projects/+page.svelte b/web-admin/src/routes/-/admin/projects/+page.svelte new file mode 100644 index 00000000000..8a5858933f7 --- /dev/null +++ b/web-admin/src/routes/-/admin/projects/+page.svelte @@ -0,0 +1,163 @@ + + + + + +
+ +
+ +{#if $projectsQuery.isFetching && searchQuery.length >= 3} +
+
+ Searching projects... +
+{:else if $projectsQuery.data?.names?.length} +

+ {$projectsQuery.data.names.length} result{$projectsQuery.data.names.length === 1 ? "" : "s"} +

+ + + + + + + + + {#each $projectsQuery.data.names as name} + + + + + {/each} + +
+ Project + + Actions +
+ {name} + +
+ + View + + + +
+
+{:else if searchQuery.length >= 3 && $projectsQuery.isSuccess} +

No projects found for "{searchQuery}"

+{:else if searchQuery.length < 3} +

+ Type at least 3 characters to search across all organizations. +

+{/if} + + diff --git a/web-admin/src/routes/-/admin/quotas/+page.svelte b/web-admin/src/routes/-/admin/quotas/+page.svelte new file mode 100644 index 00000000000..e72589ea66c --- /dev/null +++ b/web-admin/src/routes/-/admin/quotas/+page.svelte @@ -0,0 +1,181 @@ + + + + + +
+
+
+
+ +
+
+ + {#if activeOrg && $orgQuery.isFetching} +
+
+ Loading quotas... +
+ {:else if activeOrg && $orgQuery.data?.organization} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ {/if} +
+
diff --git a/web-admin/src/routes/-/admin/superusers/+page.svelte b/web-admin/src/routes/-/admin/superusers/+page.svelte new file mode 100644 index 00000000000..8847396ae8a --- /dev/null +++ b/web-admin/src/routes/-/admin/superusers/+page.svelte @@ -0,0 +1,165 @@ + + + + + +
+

+ Add Superuser +

+
+ e.key === "Enter" && handleAdd()} + /> + +
+
+ +{#if $superusersQuery.isFetching} +
+
+ Loading superusers... +
+{:else if $superusersQuery.data?.users?.length} +

+ {$superusersQuery.data.users.length} superuser{$superusersQuery.data.users.length === 1 ? "" : "s"} +

+ + + + + + + + + + {#each $superusersQuery.data.users as user} + + + + + + {/each} + +
+ Email + + Display Name + + Actions +
+ {user.email} + + {user.displayName ?? "-"} + + +
+{/if} + +