| author | |
| committer | |
| log | d0b8d6306b4686528913948b21c8a9eeda01645a |
| tree | 061b082c3a27e0ab4353dbbc9741b276c2cfab71 |
| parent | 9666b875164581b67e5d75cf26aa05753e83e49e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>10 files changed, 573 insertions(+), 70 deletions(-)
apps/web/src/cloud/managedRelayState.ts+22| ... | ... | @@ -1,14 +1,20 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | createManagedRelayQueryManager, |
| 4 | deregisterManagedRelayEnvironment, | |
| 4 | 5 | ManagedRelay, |
| 5 | 6 | managedRelaySessionAtom, |
| 6 | 7 | readManagedRelaySnapshotState, |
| 7 | 8 | } from "@t3tools/client-runtime/relay"; |
| 9 | import { | |
| 10 | createAtomCommandScheduler, | |
| 11 | createRuntimeCommand, | |
| 12 | } from "@t3tools/client-runtime/state/runtime"; | |
| 8 | 13 | import type { |
| 9 | 14 | RelayClientDeviceRecord, |
| 10 | 15 | RelayClientEnvironmentRecord, |
| 11 | 16 | } from "@t3tools/contracts/relay"; |
| 17 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 12 | 18 | import * as Context from "effect/Context"; |
| 13 | 19 | import * as Effect from "effect/Effect"; |
| 14 | 20 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime( |
| 29 | 35 | |
| 30 | 36 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); |
| 31 | 37 | |
| 38 | const managedRelayMutationScheduler = createAtomCommandScheduler(); | |
| 39 | ||
| 40 | export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( | |
| 41 | managedRelayAtomRuntime, | |
| 42 | { | |
| 43 | label: "web:managed-relay:deregister-environment", | |
| 44 | scheduler: managedRelayMutationScheduler, | |
| 45 | concurrency: { | |
| 46 | mode: "serial", | |
| 47 | key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => | |
| 48 | input.accountId, | |
| 49 | }, | |
| 50 | execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), | |
| 51 | }, | |
| 52 | ); | |
| 53 | ||
| 32 | 54 | const EMPTY_ENVIRONMENTS_ATOM = Atom.make( |
| 33 | 55 | AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]), |
| 34 | 56 | ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); |
apps/web/src/components/clerk/ClerkUserProfilePage.tsx created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import { RefreshCwIcon } from "lucide-react"; | |
| 2 | import type { ReactNode } from "react"; | |
| 3 | ||
| 4 | import { cn } from "../../lib/utils"; | |
| 5 | import { Button } from "../ui/button"; | |
| 6 | ||
| 7 | export function ClerkUserProfilePage({ | |
| 8 | action, | |
| 9 | children, | |
| 10 | className, | |
| 11 | description, | |
| 12 | title, | |
| 13 | }: { | |
| 14 | readonly action?: ReactNode; | |
| 15 | readonly children: ReactNode; | |
| 16 | readonly className?: string; | |
| 17 | readonly description?: ReactNode; | |
| 18 | readonly title: ReactNode; | |
| 19 | }) { | |
| 20 | return ( | |
| 21 | <div className={cn("w-full min-w-0 text-foreground", className)}> | |
| 22 | <header className="flex flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4"> | |
| 23 | <div className="min-w-0"> | |
| 24 | <h2 className="text-[1.0625rem] leading-6 font-semibold">{title}</h2> | |
| 25 | {description ? ( | |
| 26 | <p className="mt-1 max-w-[34rem] text-[0.8125rem] leading-[1.125rem] text-muted-foreground sm:min-h-9"> | |
| 27 | {description} | |
| 28 | </p> | |
| 29 | ) : null} | |
| 30 | </div> | |
| 31 | {action ? <div className="shrink-0">{action}</div> : null} | |
| 32 | </header> | |
| 33 | ||
| 34 | {children} | |
| 35 | </div> | |
| 36 | ); | |
| 37 | } | |
| 38 | ||
| 39 | export function ClerkUserProfileRefreshButton({ | |
| 40 | className, | |
| 41 | disabled = false, | |
| 42 | isPending, | |
| 43 | onClick, | |
| 44 | }: { | |
| 45 | readonly className?: string; | |
| 46 | readonly disabled?: boolean; | |
| 47 | readonly isPending: boolean; | |
| 48 | readonly onClick: () => void; | |
| 49 | }) { | |
| 50 | return ( | |
| 51 | <Button | |
| 52 | size="sm" | |
| 53 | variant="outline" | |
| 54 | className={cn("text-[0.8125rem]", className)} | |
| 55 | disabled={disabled || isPending} | |
| 56 | onClick={onClick} | |
| 57 | > | |
| 58 | <RefreshCwIcon aria-hidden="true" className={cn("size-3.5", isPending && "animate-spin")} /> | |
| 59 | Refresh | |
| 60 | </Button> | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| 64 | export function ClerkUserProfileRow({ | |
| 65 | children, | |
| 66 | className, | |
| 67 | icon, | |
| 68 | }: { | |
| 69 | readonly children: ReactNode; | |
| 70 | readonly className?: string; | |
| 71 | readonly icon: ReactNode; | |
| 72 | }) { | |
| 73 | return ( | |
| 74 | <li className={cn("border-t py-4 first:border-t-0", className)}> | |
| 75 | <div className="flex items-start gap-3"> | |
| 76 | <div | |
| 77 | aria-hidden="true" | |
| 78 | className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-muted/32 text-muted-foreground" | |
| 79 | > | |
| 80 | {icon} | |
| 81 | </div> | |
| 82 | <div className="min-w-0 flex-1">{children}</div> | |
| 83 | </div> | |
| 84 | </li> | |
| 85 | ); | |
| 86 | } |
apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx+52-63| ... | ... | @@ -1,8 +1,7 @@ |
| 1 | 1 | import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; |
| 2 | import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; | |
| 2 | import { SmartphoneIcon } from "lucide-react"; | |
| 3 | 3 | |
| 4 | 4 | import { useManagedRelayDevices } from "../../cloud/managedRelayState"; |
| 5 | import { cn } from "../../lib/utils"; | |
| 6 | 5 | import { Badge } from "../ui/badge"; |
| 7 | 6 | import { Button } from "../ui/button"; |
| 8 | 7 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| ... | ... | @@ -12,6 +11,11 @@ import { |
| 12 | 11 | mobileClientPlatformLabel, |
| 13 | 12 | mobileClientUpdatedAtLabel, |
| 14 | 13 | } from "./MobileClientsUserProfilePage.logic"; |
| 14 | import { | |
| 15 | ClerkUserProfilePage, | |
| 16 | ClerkUserProfileRefreshButton, | |
| 17 | ClerkUserProfileRow, | |
| 18 | } from "./ClerkUserProfilePage"; | |
| 15 | 19 | |
| 16 | 20 | const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; |
| 17 | 21 | |
| ... | ... | @@ -31,53 +35,47 @@ function MobileClientStatusBadge({ |
| 31 | 35 | |
| 32 | 36 | function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { |
| 33 | 37 | return ( |
| 34 | <li className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm/4"> | |
| 35 | <div className="flex items-start gap-3"> | |
| 36 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground"> | |
| 37 | <SmartphoneIcon className="size-4" /> | |
| 38 | </div> | |
| 39 | <div className="min-w-0 flex-1"> | |
| 40 | <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> | |
| 41 | <div className="min-w-0"> | |
| 42 | <h3 className="truncate text-sm font-semibold text-foreground">{device.label}</h3> | |
| 43 | <p className="text-xs text-muted-foreground">{mobileClientPlatformLabel(device)}</p> | |
| 44 | </div> | |
| 45 | <p className="shrink-0 text-[11px] text-muted-foreground/75"> | |
| 46 | {mobileClientUpdatedAtLabel(device.updatedAt)} | |
| 47 | </p> | |
| 48 | </div> | |
| 49 | <div className="mt-3 flex flex-wrap gap-1.5"> | |
| 50 | <MobileClientStatusBadge | |
| 51 | enabled={device.notifications.enabled} | |
| 52 | label="Push notifications" | |
| 53 | /> | |
| 54 | <MobileClientStatusBadge | |
| 55 | enabled={device.liveActivities.enabled} | |
| 56 | label="Live Activities" | |
| 57 | /> | |
| 58 | </div> | |
| 59 | <p className="mt-2 text-xs leading-relaxed text-muted-foreground/80"> | |
| 60 | {mobileClientNotificationDetail(device)} | |
| 38 | <ClerkUserProfileRow icon={<SmartphoneIcon className="size-4" />}> | |
| 39 | <div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> | |
| 40 | <div className="min-w-0"> | |
| 41 | <h3 className="truncate text-[0.8125rem] leading-[1.125rem] font-medium text-foreground"> | |
| 42 | {device.label} | |
| 43 | </h3> | |
| 44 | <p className="text-xs leading-[1.125rem] text-muted-foreground"> | |
| 45 | {mobileClientPlatformLabel(device)} | |
| 61 | 46 | </p> |
| 62 | 47 | </div> |
| 48 | <p className="shrink-0 text-[0.6875rem] leading-4 text-muted-foreground/75"> | |
| 49 | {mobileClientUpdatedAtLabel(device.updatedAt)} | |
| 50 | </p> | |
| 63 | 51 | </div> |
| 64 | </li> | |
| 52 | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 53 | <MobileClientStatusBadge | |
| 54 | enabled={device.notifications.enabled} | |
| 55 | label="Push notifications" | |
| 56 | /> | |
| 57 | <MobileClientStatusBadge enabled={device.liveActivities.enabled} label="Live Activities" /> | |
| 58 | </div> | |
| 59 | <p className="mt-1.5 text-xs leading-[1.125rem] text-muted-foreground/80"> | |
| 60 | {mobileClientNotificationDetail(device)} | |
| 61 | </p> | |
| 62 | </ClerkUserProfileRow> | |
| 65 | 63 | ); |
| 66 | 64 | } |
| 67 | 65 | |
| 68 | 66 | function MobileClientsSkeleton() { |
| 69 | 67 | return ( |
| 70 | <div aria-label="Loading mobile clients" className="space-y-3" role="status"> | |
| 68 | <div aria-label="Loading mobile clients" className="divide-y border-t" role="status"> | |
| 71 | 69 | {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( |
| 72 | <div key={row} className="rounded-xl border p-4"> | |
| 70 | <div key={row} className="py-4"> | |
| 73 | 71 | <div className="flex gap-3"> |
| 74 | <Skeleton className="size-9 shrink-0 rounded-lg" /> | |
| 72 | <Skeleton className="size-8 shrink-0 rounded-lg" /> | |
| 75 | 73 | <div className="flex-1 space-y-2"> |
| 76 | <Skeleton className="h-4 w-36" /> | |
| 74 | <Skeleton className="h-3.5 w-36" /> | |
| 77 | 75 | <Skeleton className="h-3 w-28" /> |
| 78 | <div className="flex gap-2 pt-1"> | |
| 79 | <Skeleton className="h-5 w-28" /> | |
| 80 | <Skeleton className="h-5 w-24" /> | |
| 76 | <div className="flex gap-2"> | |
| 77 | <Skeleton className="h-4.5 w-28" /> | |
| 78 | <Skeleton className="h-4.5 w-24" /> | |
| 81 | 79 | </div> |
| 82 | 80 | </div> |
| 83 | 81 | </div> |
| ... | ... | @@ -89,13 +87,13 @@ function MobileClientsSkeleton() { |
| 89 | 87 | |
| 90 | 88 | function EmptyMobileClients() { |
| 91 | 89 | return ( |
| 92 | <Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15"> | |
| 93 | <EmptyMedia variant="icon"> | |
| 90 | <Empty className="min-h-64 gap-4 border-t px-6 py-10 md:p-10"> | |
| 91 | <EmptyMedia className="mb-0" variant="icon"> | |
| 94 | 92 | <SmartphoneIcon /> |
| 95 | 93 | </EmptyMedia> |
| 96 | 94 | <EmptyHeader> |
| 97 | <EmptyTitle>No mobile clients</EmptyTitle> | |
| 98 | <EmptyDescription> | |
| 95 | <EmptyTitle className="text-[1.0625rem] leading-6">No mobile clients</EmptyTitle> | |
| 96 | <EmptyDescription className="text-[0.8125rem] leading-[1.125rem]"> | |
| 99 | 97 | Sign in to T3 Code on your iPhone to register it for push notifications and Live |
| 100 | 98 | Activities. |
| 101 | 99 | </EmptyDescription> |
| ... | ... | @@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() { |
| 112 | 110 | const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; |
| 113 | 111 | |
| 114 | 112 | return ( |
| 115 | <div className="flex min-h-[30rem] w-full flex-col bg-background text-foreground"> | |
| 116 | <header className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between"> | |
| 117 | <div> | |
| 118 | <h2 className="text-base font-semibold tracking-[-0.01em]">Mobile clients</h2> | |
| 119 | <p className="mt-1 text-sm text-muted-foreground"> | |
| 120 | Devices registered to receive T3 Connect activity from your environments. | |
| 121 | </p> | |
| 122 | </div> | |
| 123 | <Button | |
| 124 | size="sm" | |
| 125 | variant="outline" | |
| 126 | disabled={devicesState.isPending} | |
| 113 | <ClerkUserProfilePage | |
| 114 | title="Mobile clients" | |
| 115 | description="Devices registered to receive T3 Connect activity from your environments." | |
| 116 | action={ | |
| 117 | <ClerkUserProfileRefreshButton | |
| 118 | isPending={devicesState.isPending} | |
| 127 | 119 | onClick={devicesState.refresh} |
| 128 | > | |
| 129 | <RefreshCwIcon className={cn("size-3.5", devicesState.isPending && "animate-spin")} /> | |
| 130 | Refresh | |
| 131 | </Button> | |
| 132 | </header> | |
| 133 | ||
| 134 | <div className="flex-1 p-6"> | |
| 120 | /> | |
| 121 | } | |
| 122 | > | |
| 123 | <div> | |
| 135 | 124 | {devicesState.error ? ( |
| 136 | 125 | <div |
| 137 | className="mb-4 flex flex-col gap-3 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm sm:flex-row sm:items-center sm:justify-between" | |
| 126 | className="mb-4 flex flex-col gap-3 border-t border-destructive/35 py-3 text-[0.8125rem] sm:flex-row sm:items-center sm:justify-between" | |
| 138 | 127 | role="alert" |
| 139 | 128 | > |
| 140 | 129 | <div> |
| ... | ... | @@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() { |
| 152 | 141 | {isInitialLoad ? ( |
| 153 | 142 | <MobileClientsSkeleton /> |
| 154 | 143 | ) : hasErrorWithoutData ? null : devices.length > 0 ? ( |
| 155 | <ul className="space-y-3"> | |
| 144 | <ul className="border-t"> | |
| 156 | 145 | {devices.map((device) => ( |
| 157 | 146 | <MobileClientRow key={device.deviceId} device={device} /> |
| 158 | 147 | ))} |
| ... | ... | @@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() { |
| 161 | 150 | <EmptyMobileClients /> |
| 162 | 151 | )} |
| 163 | 152 | </div> |
| 164 | </div> | |
| 153 | </ClerkUserProfilePage> | |
| 165 | 154 | ); |
| 166 | 155 | } |
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+9-1| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import { UserButton, useAuth } from "@clerk/react"; |
| 2 | import { LogInIcon, SmartphoneIcon } from "lucide-react"; | |
| 2 | import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react"; | |
| 3 | 3 | |
| 4 | 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; |
| 5 | 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; |
| 6 | 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; |
| 7 | import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; | |
| 7 | 8 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; |
| 8 | 9 | |
| 9 | 10 | export function T3ConnectSidebarSignIn() { |
| ... | ... | @@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() { |
| 39 | 40 | > |
| 40 | 41 | <MobileClientsUserProfilePage /> |
| 41 | 42 | </UserButton.UserProfilePage> |
| 43 | <UserButton.UserProfilePage | |
| 44 | label="T3 Connect" | |
| 45 | labelIcon={<ServerIcon className="size-4" />} | |
| 46 | url="t3-connect" | |
| 47 | > | |
| 48 | <T3ConnectUserProfilePage /> | |
| 49 | </UserButton.UserProfilePage> | |
| 42 | 50 | </UserButton> |
| 43 | 51 | ); |
| 44 | 52 | } |
apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { T3ConnectEnvironmentRow } from "./T3ConnectUserProfilePage"; | |
| 7 | ||
| 8 | const environment: RelayClientEnvironmentRecord = { | |
| 9 | environmentId: "environment-1" as EnvironmentId, | |
| 10 | label: "Studio Mac", | |
| 11 | endpoint: { | |
| 12 | httpBaseUrl: "https://studio.example.com", | |
| 13 | wsBaseUrl: "wss://studio.example.com", | |
| 14 | providerKind: "cloudflare_tunnel", | |
| 15 | }, | |
| 16 | linkedAt: "2026-08-12T12:00:00.000Z", | |
| 17 | }; | |
| 18 | ||
| 19 | function renderRow({ | |
| 20 | confirmationOpen = false, | |
| 21 | mutationPending = false, | |
| 22 | }: { | |
| 23 | readonly confirmationOpen?: boolean; | |
| 24 | readonly mutationPending?: boolean; | |
| 25 | } = {}) { | |
| 26 | return renderToStaticMarkup( | |
| 27 | <T3ConnectEnvironmentRow | |
| 28 | environment={environment} | |
| 29 | confirmationOpen={confirmationOpen} | |
| 30 | mutationPending={mutationPending} | |
| 31 | onConfirmationChange={vi.fn()} | |
| 32 | onDeregister={vi.fn()} | |
| 33 | />, | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | describe("T3 Connect environment row", () => { | |
| 38 | it("keeps deregistration confirmation inline and collapsed by default", () => { | |
| 39 | const markup = renderRow(); | |
| 40 | ||
| 41 | expect(markup).toContain("Studio Mac"); | |
| 42 | expect(markup).toContain("Deregister"); | |
| 43 | expect(markup).not.toContain("Deregister server"); | |
| 44 | expect(markup).not.toContain("Confirm deregistration of Studio Mac"); | |
| 45 | }); | |
| 46 | ||
| 47 | it("expands Clerk-style confirmation content beneath the environment row", () => { | |
| 48 | const markup = renderRow({ confirmationOpen: true }); | |
| 49 | ||
| 50 | expect(markup).toContain("Deregister server"); | |
| 51 | expect(markup).toContain("“Studio Mac” will be removed from this account."); | |
| 52 | expect(markup).toContain("Confirm deregistration of Studio Mac"); | |
| 53 | expect(markup).toContain("Local connections on your devices are not changed."); | |
| 54 | expect(markup).toContain("Cancel"); | |
| 55 | }); | |
| 56 | ||
| 57 | it("locks the confirmation actions while deregistration is pending", () => { | |
| 58 | const markup = renderRow({ confirmationOpen: true, mutationPending: true }); | |
| 59 | ||
| 60 | expect(markup).toContain("Deregistering…"); | |
| 61 | expect(markup.match(/ disabled=""/g)).toHaveLength(3); | |
| 62 | }); | |
| 63 | }); |
apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx created+260| ... | ... | @@ -0,0 +1,260 @@ |
| 1 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | |
| 2 | import { | |
| 3 | isAtomCommandInterrupted, | |
| 4 | squashAtomCommandFailure, | |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 7 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | |
| 8 | import { ServerIcon } from "lucide-react"; | |
| 9 | import { useRef, useState } from "react"; | |
| 10 | ||
| 11 | import { | |
| 12 | deregisterManagedRelayEnvironmentCommand, | |
| 13 | useManagedRelayEnvironments, | |
| 14 | } from "../../cloud/managedRelayState"; | |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 16 | import { Button } from "../ui/button"; | |
| 17 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | |
| 18 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | |
| 19 | import { toastManager } from "../ui/toast"; | |
| 20 | import { | |
| 21 | ClerkUserProfilePage, | |
| 22 | ClerkUserProfileRefreshButton, | |
| 23 | ClerkUserProfileRow, | |
| 24 | } from "./ClerkUserProfilePage"; | |
| 25 | ||
| 26 | const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); | |
| 27 | ||
| 28 | function linkedAtLabel(value: string): string { | |
| 29 | const linkedAt = new Date(value); | |
| 30 | return Number.isNaN(linkedAt.getTime()) | |
| 31 | ? "Link date unavailable" | |
| 32 | : `Linked ${linkedAtFormatter.format(linkedAt)}`; | |
| 33 | } | |
| 34 | ||
| 35 | function endpointLabel(environment: RelayClientEnvironmentRecord): string { | |
| 36 | return environment.endpoint.providerKind === "cloudflare_tunnel" | |
| 37 | ? "Managed tunnel" | |
| 38 | : "Activity publishing only"; | |
| 39 | } | |
| 40 | ||
| 41 | export function T3ConnectEnvironmentRow(props: { | |
| 42 | readonly environment: RelayClientEnvironmentRecord; | |
| 43 | readonly confirmationOpen: boolean; | |
| 44 | readonly mutationPending: boolean; | |
| 45 | readonly onConfirmationChange: (open: boolean) => void; | |
| 46 | readonly onDeregister: (environment: RelayClientEnvironmentRecord) => void; | |
| 47 | }) { | |
| 48 | const { environment } = props; | |
| 49 | return ( | |
| 50 | <ClerkUserProfileRow icon={<ServerIcon className="size-4" />}> | |
| 51 | <Collapsible open={props.confirmationOpen} onOpenChange={props.onConfirmationChange}> | |
| 52 | <div className="flex items-start gap-4"> | |
| 53 | <div className="min-w-0 flex-1"> | |
| 54 | <h3 className="truncate text-[0.8125rem] leading-[1.125rem] font-medium text-foreground"> | |
| 55 | {environment.label} | |
| 56 | </h3> | |
| 57 | <p className="mt-1 text-xs leading-[1.125rem] text-muted-foreground"> | |
| 58 | {linkedAtLabel(environment.linkedAt)} · {endpointLabel(environment)} | |
| 59 | </p> | |
| 60 | </div> | |
| 61 | <CollapsibleTrigger | |
| 62 | render={ | |
| 63 | <Button | |
| 64 | size="sm" | |
| 65 | variant="destructive-outline" | |
| 66 | className="text-[0.8125rem]" | |
| 67 | disabled={props.mutationPending} | |
| 68 | > | |
| 69 | Deregister | |
| 70 | </Button> | |
| 71 | } | |
| 72 | /> | |
| 73 | </div> | |
| 74 | ||
| 75 | <CollapsiblePanel> | |
| 76 | <div className="pt-3"> | |
| 77 | <div | |
| 78 | className="rounded-lg border border-input bg-muted/32 px-5 py-4 shadow-xs/5" | |
| 79 | role="group" | |
| 80 | aria-label={`Confirm deregistration of ${environment.label}`} | |
| 81 | > | |
| 82 | <h4 className="text-[0.8125rem] leading-[1.125rem] font-semibold text-foreground"> | |
| 83 | Deregister server | |
| 84 | </h4> | |
| 85 | <p className="mt-1 text-[0.8125rem] leading-[1.125rem] text-muted-foreground"> | |
| 86 | “{environment.label}” will be removed from this account. | |
| 87 | </p> | |
| 88 | <p className="mt-4 max-w-xl text-[0.8125rem] leading-[1.125rem] text-muted-foreground"> | |
| 89 | T3 Connect access will be revoked, any managed tunnel will be removed, and a host | |
| 90 | space will become available. Local connections on your devices are not changed. | |
| 91 | </p> | |
| 92 | <div className="mt-4 flex justify-end gap-2"> | |
| 93 | <Button | |
| 94 | size="sm" | |
| 95 | variant="ghost" | |
| 96 | className="text-[0.8125rem]" | |
| 97 | disabled={props.mutationPending} | |
| 98 | onClick={() => props.onConfirmationChange(false)} | |
| 99 | > | |
| 100 | Cancel | |
| 101 | </Button> | |
| 102 | <Button | |
| 103 | size="sm" | |
| 104 | variant="destructive" | |
| 105 | className="text-[0.8125rem]" | |
| 106 | disabled={props.mutationPending} | |
| 107 | onClick={() => props.onDeregister(environment)} | |
| 108 | > | |
| 109 | {props.mutationPending ? "Deregistering…" : "Deregister"} | |
| 110 | </Button> | |
| 111 | </div> | |
| 112 | </div> | |
| 113 | </div> | |
| 114 | </CollapsiblePanel> | |
| 115 | </Collapsible> | |
| 116 | </ClerkUserProfileRow> | |
| 117 | ); | |
| 118 | } | |
| 119 | ||
| 120 | export function T3ConnectUserProfilePage() { | |
| 121 | const environmentsState = useManagedRelayEnvironments(); | |
| 122 | const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { | |
| 123 | reportFailure: false, | |
| 124 | }); | |
| 125 | const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = | |
| 126 | useState<EnvironmentId | null>(null); | |
| 127 | const [confirmingEnvironmentId, setConfirmingEnvironmentId] = useState<EnvironmentId | null>( | |
| 128 | null, | |
| 129 | ); | |
| 130 | const mutationPendingRef = useRef(false); | |
| 131 | const [removedEnvironments, setRemovedEnvironments] = useState<{ | |
| 132 | readonly accountId: string | null; | |
| 133 | readonly linkedAtById: ReadonlyMap<EnvironmentId, string>; | |
| 134 | }>({ accountId: null, linkedAtById: new Map() }); | |
| 135 | ||
| 136 | const handleDeregister = async (environment: RelayClientEnvironmentRecord) => { | |
| 137 | const accountId = environmentsState.accountId; | |
| 138 | if (!accountId || mutationPendingRef.current) return; | |
| 139 | ||
| 140 | mutationPendingRef.current = true; | |
| 141 | setDeregisteringEnvironmentId(environment.environmentId); | |
| 142 | const result = await deregisterEnvironment({ | |
| 143 | accountId, | |
| 144 | environmentId: environment.environmentId, | |
| 145 | }); | |
| 146 | mutationPendingRef.current = false; | |
| 147 | setDeregisteringEnvironmentId(null); | |
| 148 | ||
| 149 | if (result._tag === "Success") { | |
| 150 | setConfirmingEnvironmentId(null); | |
| 151 | setRemovedEnvironments((current) => { | |
| 152 | const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []); | |
| 153 | linkedAtById.set(environment.environmentId, environment.linkedAt); | |
| 154 | return { accountId, linkedAtById }; | |
| 155 | }); | |
| 156 | environmentsState.refresh(); | |
| 157 | toastManager.add({ | |
| 158 | type: "success", | |
| 159 | title: "Server deregistered", | |
| 160 | description: "T3 Connect access was revoked and a host space is now available.", | |
| 161 | }); | |
| 162 | return; | |
| 163 | } | |
| 164 | if (isAtomCommandInterrupted(result)) return; | |
| 165 | ||
| 166 | const cause = squashAtomCommandFailure(result); | |
| 167 | const message = cause instanceof Error ? cause.message : "Could not deregister the server."; | |
| 168 | const traceId = findErrorTraceId(cause); | |
| 169 | console.error("[t3-connect] Could not deregister environment", { | |
| 170 | environmentId: environment.environmentId, | |
| 171 | message, | |
| 172 | traceId, | |
| 173 | cause, | |
| 174 | }); | |
| 175 | toastManager.add({ | |
| 176 | type: "error", | |
| 177 | title: "Could not deregister server", | |
| 178 | description: message, | |
| 179 | data: traceId | |
| 180 | ? { | |
| 181 | secondaryActionProps: { | |
| 182 | children: "Copy trace ID", | |
| 183 | onClick: () => void navigator.clipboard?.writeText(traceId), | |
| 184 | }, | |
| 185 | } | |
| 186 | : undefined, | |
| 187 | }); | |
| 188 | }; | |
| 189 | ||
| 190 | const removedEnvironmentLinkedAt = | |
| 191 | removedEnvironments.accountId === environmentsState.accountId | |
| 192 | ? removedEnvironments.linkedAtById | |
| 193 | : new Map<EnvironmentId, string>(); | |
| 194 | const environments = (environmentsState.data ?? []).filter( | |
| 195 | (environment) => | |
| 196 | removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, | |
| 197 | ); | |
| 198 | const isInitialLoad = | |
| 199 | !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); | |
| 200 | ||
| 201 | return ( | |
| 202 | <ClerkUserProfilePage | |
| 203 | title="T3 Connect" | |
| 204 | description="Environments registered to your account. Connections on this device are managed in Settings." | |
| 205 | action={ | |
| 206 | <ClerkUserProfileRefreshButton | |
| 207 | disabled={deregisteringEnvironmentId !== null} | |
| 208 | isPending={environmentsState.isPending} | |
| 209 | onClick={environmentsState.refresh} | |
| 210 | /> | |
| 211 | } | |
| 212 | > | |
| 213 | <div> | |
| 214 | {environmentsState.error ? ( | |
| 215 | <div className="mb-4 border-t border-destructive/35 py-3 text-[0.8125rem]" role="alert"> | |
| 216 | <p className="font-medium text-destructive-foreground"> | |
| 217 | Could not load T3 Connect environments | |
| 218 | </p> | |
| 219 | <p className="mt-1 text-xs text-muted-foreground">{environmentsState.error}</p> | |
| 220 | </div> | |
| 221 | ) : null} | |
| 222 | ||
| 223 | {isInitialLoad ? ( | |
| 224 | <p className="border-t py-4 text-[0.8125rem] text-muted-foreground" role="status"> | |
| 225 | Loading environments… | |
| 226 | </p> | |
| 227 | ) : environments.length > 0 ? ( | |
| 228 | <ul className="border-t"> | |
| 229 | {environments.map((environment) => ( | |
| 230 | <T3ConnectEnvironmentRow | |
| 231 | key={environment.environmentId} | |
| 232 | environment={environment} | |
| 233 | confirmationOpen={confirmingEnvironmentId === environment.environmentId} | |
| 234 | mutationPending={deregisteringEnvironmentId !== null} | |
| 235 | onConfirmationChange={(open) => | |
| 236 | setConfirmingEnvironmentId(open ? environment.environmentId : null) | |
| 237 | } | |
| 238 | onDeregister={(selected) => void handleDeregister(selected)} | |
| 239 | /> | |
| 240 | ))} | |
| 241 | </ul> | |
| 242 | ) : environmentsState.error ? null : ( | |
| 243 | <Empty className="min-h-64 gap-4 border-t px-6 py-10 md:p-10"> | |
| 244 | <EmptyMedia className="mb-0" variant="icon"> | |
| 245 | <ServerIcon /> | |
| 246 | </EmptyMedia> | |
| 247 | <EmptyHeader> | |
| 248 | <EmptyTitle className="text-[1.0625rem] leading-6"> | |
| 249 | No T3 Connect environments | |
| 250 | </EmptyTitle> | |
| 251 | <EmptyDescription className="text-[0.8125rem] leading-[1.125rem]"> | |
| 252 | Link an environment from its local Settings to make it available through T3 Connect. | |
| 253 | </EmptyDescription> | |
| 254 | </EmptyHeader> | |
| 255 | </Empty> | |
| 256 | )} | |
| 257 | </div> | |
| 258 | </ClerkUserProfilePage> | |
| 259 | ); | |
| 260 | } |
apps/web/src/components/ui/alert-dialog.tsx+3-1| ... | ... | @@ -46,12 +46,14 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp |
| 46 | 46 | function AlertDialogPopup({ |
| 47 | 47 | className, |
| 48 | 48 | bottomStickOnMobile = true, |
| 49 | portalContainer, | |
| 49 | 50 | ...props |
| 50 | 51 | }: AlertDialogPrimitive.Popup.Props & { |
| 51 | 52 | bottomStickOnMobile?: boolean; |
| 53 | portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; | |
| 52 | 54 | }) { |
| 53 | 55 | return ( |
| 54 | <AlertDialogPortal> | |
| 56 | <AlertDialogPortal container={portalContainer}> | |
| 55 | 57 | <AlertDialogBackdrop /> |
| 56 | 58 | <AlertDialogViewport |
| 57 | 59 | className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")} |
docs/user/remote-access.md+11| ... | ... | @@ -218,6 +218,17 @@ Typical uses: |
| 218 | 218 | |
| 219 | 219 | Use `t3 auth --help` and the nested subcommand help pages for the full reference. |
| 220 | 220 | |
| 221 | ### Deregister a T3 Connect Environment | |
| 222 | ||
| 223 | Open your account menu and choose **T3 Connect** to see every environment registered to your | |
| 224 | account. On mobile, open **Settings** → **T3 Connect**. Choose **Deregister** to revoke an | |
| 225 | environment's T3 Connect access, remove any managed tunnel, and free its host space. | |
| 226 | ||
| 227 | Deregistration is an account action and does not need a connection to the environment, so it also | |
| 228 | works for a server that was wiped or is no longer reachable. Device-local connect and disconnect | |
| 229 | controls remain in **Settings** → **Connections** on web and desktop or **Settings** → | |
| 230 | **Environments** on mobile. | |
| 231 | ||
| 221 | 232 | ## Security Notes |
| 222 | 233 | |
| 223 | 234 | - Treat pairing URLs and pairing tokens like passwords. |
packages/client-runtime/src/relay/managedRelayState.test.ts+48-5| ... | ... | @@ -16,8 +16,10 @@ import * as ManagedRelay from "./managedRelay.ts"; |
| 16 | 16 | import { |
| 17 | 17 | createManagedRelayQueryManager, |
| 18 | 18 | createManagedRelaySession, |
| 19 | deregisterManagedRelayEnvironment, | |
| 19 | 20 | managedRelayAccountChanges, |
| 20 | 21 | type ManagedRelayQueryEvent, |
| 22 | ManagedRelaySessionError, | |
| 21 | 23 | managedRelaySessionAtom, |
| 22 | 24 | readManagedRelaySnapshotState, |
| 23 | 25 | setManagedRelaySession, |
| ... | ... | @@ -61,11 +63,8 @@ function resetRegistry() { |
| 61 | 63 | registry = AtomRegistry.make(); |
| 62 | 64 | } |
| 63 | 65 | |
| 64 | function createManager( | |
| 65 | overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>, | |
| 66 | onQueryEvent?: (event: ManagedRelayQueryEvent) => void, | |
| 67 | ) { | |
| 68 | const client = ManagedRelay.ManagedRelayClient.of({ | |
| 66 | function createClient(overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>) { | |
| 67 | return ManagedRelay.ManagedRelayClient.of({ | |
| 69 | 68 | relayUrl: "https://relay.example.test", |
| 70 | 69 | listEnvironments: () => Effect.succeed([environment]), |
| 71 | 70 | listDevices: () => Effect.succeed([device]), |
| ... | ... | @@ -87,6 +86,13 @@ function createManager( |
| 87 | 86 | resetTokenCache: Effect.void, |
| 88 | 87 | ...overrides, |
| 89 | 88 | }); |
| 89 | } | |
| 90 | ||
| 91 | function createManager( | |
| 92 | overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>, | |
| 93 | onQueryEvent?: (event: ManagedRelayQueryEvent) => void, | |
| 94 | ) { | |
| 95 | const client = createClient(overrides); | |
| 90 | 96 | const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); |
| 91 | 97 | return createManagedRelayQueryManager(runtime, { |
| 92 | 98 | staleTimeMs: 60_000, |
| ... | ... | @@ -121,6 +127,43 @@ describe("createManagedRelayQueryManager", () => { |
| 121 | 127 | }), |
| 122 | 128 | ); |
| 123 | 129 | |
| 130 | it.effect("deregisters an environment through the current Clerk session", () => | |
| 131 | Effect.gen(function* () { | |
| 132 | const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); | |
| 133 | setSession(); | |
| 134 | ||
| 135 | yield* deregisterManagedRelayEnvironment(registry, { | |
| 136 | accountId: "account-1", | |
| 137 | environmentId: environment.environmentId, | |
| 138 | }).pipe( | |
| 139 | Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })), | |
| 140 | ); | |
| 141 | ||
| 142 | expect(unlinkEnvironment).toHaveBeenCalledWith({ | |
| 143 | clerkToken: "clerk-token", | |
| 144 | environmentId: environment.environmentId, | |
| 145 | }); | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("rejects deregistration after the account changes", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); | |
| 152 | setSession(); | |
| 153 | ||
| 154 | const error = yield* deregisterManagedRelayEnvironment(registry, { | |
| 155 | accountId: "previous-account", | |
| 156 | environmentId: environment.environmentId, | |
| 157 | }).pipe( | |
| 158 | Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })), | |
| 159 | Effect.flip, | |
| 160 | ); | |
| 161 | ||
| 162 | expect(error).toBeInstanceOf(ManagedRelaySessionError); | |
| 163 | expect(unlinkEnvironment).not.toHaveBeenCalled(); | |
| 164 | }), | |
| 165 | ); | |
| 166 | ||
| 124 | 167 | it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => |
| 125 | 168 | Effect.gen(function* () { |
| 126 | 169 | const token = clerkToken(4_102_444_800); |
packages/client-runtime/src/relay/managedRelayState.ts+19| ... | ... | @@ -2,6 +2,7 @@ import type { |
| 2 | 2 | RelayClientEnvironmentRecord, |
| 3 | 3 | RelayEnvironmentStatusResponse, |
| 4 | 4 | } from "@t3tools/contracts/relay"; |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 5 | 6 | import { |
| 6 | 7 | RelayEnvironmentConnectScope, |
| 7 | 8 | RelayEnvironmentStatusScope, |
| ... | ... | @@ -218,6 +219,24 @@ export const waitForManagedRelayClerkToken = Effect.fn( |
| 218 | 219 | }); |
| 219 | 220 | }); |
| 220 | 221 | |
| 222 | /** Removes an environment from the signed-in account without contacting that environment. */ | |
| 223 | export const deregisterManagedRelayEnvironment = Effect.fn( | |
| 224 | "clientRuntime.managedRelaySession.deregisterEnvironment", | |
| 225 | )(function* ( | |
| 226 | registry: AtomRegistry.AtomRegistry, | |
| 227 | input: { readonly accountId: string; readonly environmentId: EnvironmentId }, | |
| 228 | ) { | |
| 229 | const session = registry.get(managedRelaySessionAtom); | |
| 230 | if (!session || session.accountId !== input.accountId) { | |
| 231 | return yield* new ManagedRelaySessionError({ | |
| 232 | message: "Sign in to T3 Connect before deregistering an environment.", | |
| 233 | }); | |
| 234 | } | |
| 235 | const clerkToken = yield* readSessionClerkToken(session); | |
| 236 | const relay = yield* ManagedRelay.ManagedRelayClient; | |
| 237 | yield* relay.unlinkEnvironment({ clerkToken, environmentId: input.environmentId }); | |
| 238 | }); | |
| 239 | ||
| 221 | 240 | function requireClerkToken( |
| 222 | 241 | get: Atom.AtomContext, |
| 223 | 242 | accountId: string, |