| 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,14 +1,20 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 3 | createManagedRelayQueryManager, | 3 | createManagedRelayQueryManager, |
| 4 | deregisterManagedRelayEnvironment, | ||
| 4 | ManagedRelay, | 5 | ManagedRelay, |
| 5 | managedRelaySessionAtom, | 6 | managedRelaySessionAtom, |
| 6 | readManagedRelaySnapshotState, | 7 | readManagedRelaySnapshotState, |
| 7 | } from "@t3tools/client-runtime/relay"; | 8 | } from "@t3tools/client-runtime/relay"; |
| 9 | import { | ||
| 10 | createAtomCommandScheduler, | ||
| 11 | createRuntimeCommand, | ||
| 12 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import type { | 13 | import type { |
| 9 | RelayClientDeviceRecord, | 14 | RelayClientDeviceRecord, |
| 10 | RelayClientEnvironmentRecord, | 15 | RelayClientEnvironmentRecord, |
| 11 | } from "@t3tools/contracts/relay"; | 16 | } from "@t3tools/contracts/relay"; |
| 17 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 12 | import * as Context from "effect/Context"; | 18 | import * as Context from "effect/Context"; |
| 13 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; | 20 | import * as Layer from "effect/Layer"; |
| ... | @@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime( | ... | @@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime( |
| 29 | 35 | ||
| 30 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); | 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 | const EMPTY_ENVIRONMENTS_ATOM = Atom.make( | 54 | const EMPTY_ENVIRONMENTS_ATOM = Atom.make( |
| 33 | AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]), | 55 | AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]), |
| 34 | ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); | 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,8 +1,7 @@ |
| 1 | import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; | 1 | import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; |
| 2 | import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; | 2 | import { SmartphoneIcon } from "lucide-react"; |
| 3 | 3 | ||
| 4 | import { useManagedRelayDevices } from "../../cloud/managedRelayState"; | 4 | import { useManagedRelayDevices } from "../../cloud/managedRelayState"; |
| 5 | import { cn } from "../../lib/utils"; | ||
| 6 | import { Badge } from "../ui/badge"; | 5 | import { Badge } from "../ui/badge"; |
| 7 | import { Button } from "../ui/button"; | 6 | import { Button } from "../ui/button"; |
| 8 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | 7 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| ... | @@ -12,6 +11,11 @@ import { | ... | @@ -12,6 +11,11 @@ import { |
| 12 | mobileClientPlatformLabel, | 11 | mobileClientPlatformLabel, |
| 13 | mobileClientUpdatedAtLabel, | 12 | mobileClientUpdatedAtLabel, |
| 14 | } from "./MobileClientsUserProfilePage.logic"; | 13 | } from "./MobileClientsUserProfilePage.logic"; |
| 14 | import { | ||
| 15 | ClerkUserProfilePage, | ||
| 16 | ClerkUserProfileRefreshButton, | ||
| 17 | ClerkUserProfileRow, | ||
| 18 | } from "./ClerkUserProfilePage"; | ||
| 15 | 19 | ||
| 16 | const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; | 20 | const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; |
| 17 | 21 | ||
| ... | @@ -31,53 +35,47 @@ function MobileClientStatusBadge({ | ... | @@ -31,53 +35,47 @@ function MobileClientStatusBadge({ |
| 31 | 35 | ||
| 32 | function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { | 36 | function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { |
| 33 | return ( | 37 | return ( |
| 34 | <li className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm/4"> | 38 | <ClerkUserProfileRow icon={<SmartphoneIcon className="size-4" />}> |
| 35 | <div className="flex items-start gap-3"> | 39 | <div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> |
| 36 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground"> | 40 | <div className="min-w-0"> |
| 37 | <SmartphoneIcon className="size-4" /> | 41 | <h3 className="truncate text-[0.8125rem] leading-[1.125rem] font-medium text-foreground"> |
| 38 | </div> | 42 | {device.label} |
| 39 | <div className="min-w-0 flex-1"> | 43 | </h3> |
| 40 | <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> | 44 | <p className="text-xs leading-[1.125rem] text-muted-foreground"> |
| 41 | <div className="min-w-0"> | 45 | {mobileClientPlatformLabel(device)} |
| 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)} | ||
| 61 | </p> | 46 | </p> |
| 62 | </div> | 47 | </div> |
| 48 | <p className="shrink-0 text-[0.6875rem] leading-4 text-muted-foreground/75"> | ||
| 49 | {mobileClientUpdatedAtLabel(device.updatedAt)} | ||
| 50 | </p> | ||
| 63 | </div> | 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 | function MobileClientsSkeleton() { | 66 | function MobileClientsSkeleton() { |
| 69 | return ( | 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 | {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( | 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 | <div className="flex gap-3"> | 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 | <div className="flex-1 space-y-2"> | 73 | <div className="flex-1 space-y-2"> |
| 76 | <Skeleton className="h-4 w-36" /> | 74 | <Skeleton className="h-3.5 w-36" /> |
| 77 | <Skeleton className="h-3 w-28" /> | 75 | <Skeleton className="h-3 w-28" /> |
| 78 | <div className="flex gap-2 pt-1"> | 76 | <div className="flex gap-2"> |
| 79 | <Skeleton className="h-5 w-28" /> | 77 | <Skeleton className="h-4.5 w-28" /> |
| 80 | <Skeleton className="h-5 w-24" /> | 78 | <Skeleton className="h-4.5 w-24" /> |
| 81 | </div> | 79 | </div> |
| 82 | </div> | 80 | </div> |
| 83 | </div> | 81 | </div> |
| ... | @@ -89,13 +87,13 @@ function MobileClientsSkeleton() { | ... | @@ -89,13 +87,13 @@ function MobileClientsSkeleton() { |
| 89 | 87 | ||
| 90 | function EmptyMobileClients() { | 88 | function EmptyMobileClients() { |
| 91 | return ( | 89 | return ( |
| 92 | <Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15"> | 90 | <Empty className="min-h-64 gap-4 border-t px-6 py-10 md:p-10"> |
| 93 | <EmptyMedia variant="icon"> | 91 | <EmptyMedia className="mb-0" variant="icon"> |
| 94 | <SmartphoneIcon /> | 92 | <SmartphoneIcon /> |
| 95 | </EmptyMedia> | 93 | </EmptyMedia> |
| 96 | <EmptyHeader> | 94 | <EmptyHeader> |
| 97 | <EmptyTitle>No mobile clients</EmptyTitle> | 95 | <EmptyTitle className="text-[1.0625rem] leading-6">No mobile clients</EmptyTitle> |
| 98 | <EmptyDescription> | 96 | <EmptyDescription className="text-[0.8125rem] leading-[1.125rem]"> |
| 99 | Sign in to T3 Code on your iPhone to register it for push notifications and Live | 97 | Sign in to T3 Code on your iPhone to register it for push notifications and Live |
| 100 | Activities. | 98 | Activities. |
| 101 | </EmptyDescription> | 99 | </EmptyDescription> |
| ... | @@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() { | ... | @@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() { |
| 112 | const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; | 110 | const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; |
| 113 | 111 | ||
| 114 | return ( | 112 | return ( |
| 115 | <div className="flex min-h-[30rem] w-full flex-col bg-background text-foreground"> | 113 | <ClerkUserProfilePage |
| 116 | <header className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between"> | 114 | title="Mobile clients" |
| 117 | <div> | 115 | description="Devices registered to receive T3 Connect activity from your environments." |
| 118 | <h2 className="text-base font-semibold tracking-[-0.01em]">Mobile clients</h2> | 116 | action={ |
| 119 | <p className="mt-1 text-sm text-muted-foreground"> | 117 | <ClerkUserProfileRefreshButton |
| 120 | Devices registered to receive T3 Connect activity from your environments. | 118 | isPending={devicesState.isPending} |
| 121 | </p> | ||
| 122 | </div> | ||
| 123 | <Button | ||
| 124 | size="sm" | ||
| 125 | variant="outline" | ||
| 126 | disabled={devicesState.isPending} | ||
| 127 | onClick={devicesState.refresh} | 119 | onClick={devicesState.refresh} |
| 128 | > | 120 | /> |
| 129 | <RefreshCwIcon className={cn("size-3.5", devicesState.isPending && "animate-spin")} /> | 121 | } |
| 130 | Refresh | 122 | > |
| 131 | </Button> | 123 | <div> |
| 132 | </header> | ||
| 133 | |||
| 134 | <div className="flex-1 p-6"> | ||
| 135 | {devicesState.error ? ( | 124 | {devicesState.error ? ( |
| 136 | <div | 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 | role="alert" | 127 | role="alert" |
| 139 | > | 128 | > |
| 140 | <div> | 129 | <div> |
| ... | @@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() { | ... | @@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() { |
| 152 | {isInitialLoad ? ( | 141 | {isInitialLoad ? ( |
| 153 | <MobileClientsSkeleton /> | 142 | <MobileClientsSkeleton /> |
| 154 | ) : hasErrorWithoutData ? null : devices.length > 0 ? ( | 143 | ) : hasErrorWithoutData ? null : devices.length > 0 ? ( |
| 155 | <ul className="space-y-3"> | 144 | <ul className="border-t"> |
| 156 | {devices.map((device) => ( | 145 | {devices.map((device) => ( |
| 157 | <MobileClientRow key={device.deviceId} device={device} /> | 146 | <MobileClientRow key={device.deviceId} device={device} /> |
| 158 | ))} | 147 | ))} |
| ... | @@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() { | ... | @@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() { |
| 161 | <EmptyMobileClients /> | 150 | <EmptyMobileClients /> |
| 162 | )} | 151 | )} |
| 163 | </div> | 152 | </div> |
| 164 | </div> | 153 | </ClerkUserProfilePage> |
| 165 | ); | 154 | ); |
| 166 | } | 155 | } |
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+9-1| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { UserButton, useAuth } from "@clerk/react"; | 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 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; | 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; |
| 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; | 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; |
| 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; | 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; |
| 7 | import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; | ||
| 7 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; | 8 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; |
| 8 | 9 | ||
| 9 | export function T3ConnectSidebarSignIn() { | 10 | export function T3ConnectSidebarSignIn() { |
| ... | @@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() { | ... | @@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() { |
| 39 | > | 40 | > |
| 40 | <MobileClientsUserProfilePage /> | 41 | <MobileClientsUserProfilePage /> |
| 41 | </UserButton.UserProfilePage> | 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 | </UserButton> | 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,12 +46,14 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp |
| 46 | function AlertDialogPopup({ | 46 | function AlertDialogPopup({ |
| 47 | className, | 47 | className, |
| 48 | bottomStickOnMobile = true, | 48 | bottomStickOnMobile = true, |
| 49 | portalContainer, | ||
| 49 | ...props | 50 | ...props |
| 50 | }: AlertDialogPrimitive.Popup.Props & { | 51 | }: AlertDialogPrimitive.Popup.Props & { |
| 51 | bottomStickOnMobile?: boolean; | 52 | bottomStickOnMobile?: boolean; |
| 53 | portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; | ||
| 52 | }) { | 54 | }) { |
| 53 | return ( | 55 | return ( |
| 54 | <AlertDialogPortal> | 56 | <AlertDialogPortal container={portalContainer}> |
| 55 | <AlertDialogBackdrop /> | 57 | <AlertDialogBackdrop /> |
| 56 | <AlertDialogViewport | 58 | <AlertDialogViewport |
| 57 | className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")} | 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,6 +218,17 @@ Typical uses: |
| 218 | 218 | ||
| 219 | Use `t3 auth --help` and the nested subcommand help pages for the full reference. | 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 | ## Security Notes | 232 | ## Security Notes |
| 222 | 233 | ||
| 223 | - Treat pairing URLs and pairing tokens like passwords. | 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,8 +16,10 @@ import * as ManagedRelay from "./managedRelay.ts"; |
| 16 | import { | 16 | import { |
| 17 | createManagedRelayQueryManager, | 17 | createManagedRelayQueryManager, |
| 18 | createManagedRelaySession, | 18 | createManagedRelaySession, |
| 19 | deregisterManagedRelayEnvironment, | ||
| 19 | managedRelayAccountChanges, | 20 | managedRelayAccountChanges, |
| 20 | type ManagedRelayQueryEvent, | 21 | type ManagedRelayQueryEvent, |
| 22 | ManagedRelaySessionError, | ||
| 21 | managedRelaySessionAtom, | 23 | managedRelaySessionAtom, |
| 22 | readManagedRelaySnapshotState, | 24 | readManagedRelaySnapshotState, |
| 23 | setManagedRelaySession, | 25 | setManagedRelaySession, |
| ... | @@ -61,11 +63,8 @@ function resetRegistry() { | ... | @@ -61,11 +63,8 @@ function resetRegistry() { |
| 61 | registry = AtomRegistry.make(); | 63 | registry = AtomRegistry.make(); |
| 62 | } | 64 | } |
| 63 | 65 | ||
| 64 | function createManager( | 66 | function createClient(overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>) { |
| 65 | overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>, | 67 | return ManagedRelay.ManagedRelayClient.of({ |
| 66 | onQueryEvent?: (event: ManagedRelayQueryEvent) => void, | ||
| 67 | ) { | ||
| 68 | const client = ManagedRelay.ManagedRelayClient.of({ | ||
| 69 | relayUrl: "https://relay.example.test", | 68 | relayUrl: "https://relay.example.test", |
| 70 | listEnvironments: () => Effect.succeed([environment]), | 69 | listEnvironments: () => Effect.succeed([environment]), |
| 71 | listDevices: () => Effect.succeed([device]), | 70 | listDevices: () => Effect.succeed([device]), |
| ... | @@ -87,6 +86,13 @@ function createManager( | ... | @@ -87,6 +86,13 @@ function createManager( |
| 87 | resetTokenCache: Effect.void, | 86 | resetTokenCache: Effect.void, |
| 88 | ...overrides, | 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 | const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); | 96 | const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); |
| 91 | return createManagedRelayQueryManager(runtime, { | 97 | return createManagedRelayQueryManager(runtime, { |
| 92 | staleTimeMs: 60_000, | 98 | staleTimeMs: 60_000, |
| ... | @@ -121,6 +127,43 @@ describe("createManagedRelayQueryManager", () => { | ... | @@ -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 | it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => | 167 | it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => |
| 125 | Effect.gen(function* () { | 168 | Effect.gen(function* () { |
| 126 | const token = clerkToken(4_102_444_800); | 169 | const token = clerkToken(4_102_444_800); |
packages/client-runtime/src/relay/managedRelayState.ts+19| ... | @@ -2,6 +2,7 @@ import type { | ... | @@ -2,6 +2,7 @@ import type { |
| 2 | RelayClientEnvironmentRecord, | 2 | RelayClientEnvironmentRecord, |
| 3 | RelayEnvironmentStatusResponse, | 3 | RelayEnvironmentStatusResponse, |
| 4 | } from "@t3tools/contracts/relay"; | 4 | } from "@t3tools/contracts/relay"; |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import { | 6 | import { |
| 6 | RelayEnvironmentConnectScope, | 7 | RelayEnvironmentConnectScope, |
| 7 | RelayEnvironmentStatusScope, | 8 | RelayEnvironmentStatusScope, |
| ... | @@ -218,6 +219,24 @@ export const waitForManagedRelayClerkToken = Effect.fn( | ... | @@ -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 | function requireClerkToken( | 240 | function requireClerkToken( |
| 222 | get: Atom.AtomContext, | 241 | get: Atom.AtomContext, |
| 223 | accountId: string, | 242 | accountId: string, |