1- import { useEffect , useRef } from "react" ;
1+ import { useEffect , useRef , useState , type ReactNode } from "react" ;
22import { useAtomValue , useAtomSet } from "@effect/atom-react" ;
33import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" ;
44import * as Exit from "effect/Exit" ;
@@ -8,55 +8,54 @@ import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate";
88import { organizationsAtom , switchOrganization } from "../auth" ;
99
1010// ---------------------------------------------------------------------------
11- // Foreign-slug resolution for the cloud org-slug gate: the URL carries a slug
12- // that isn't the active org's. If the caller is a member of that org (their
13- // bookmark, a teammate's link into a shared org), switch the session to it
14- // and reload so the whole app re-scopes. Anything else is a WRONG ADDRESS and
15- // renders not-found — a slug must never silently resolve to a different
16- // workspace than the URL names, and single-segment typos (/this-page-does-
17- // not-exist) match the slugged index route, so this page IS the app's 404 for
18- // them. Membership comes from the already-cached organizations atom; the
19- // fallback while it loads is a blank screen, not a skeleton.
11+ // Foreign-slug resolution for the cloud org-slug gate: the URL names a slug
12+ // that isn't the active org's. Resolve it against the caller's memberships:
13+ // - a member of that org (their bookmark, a teammate's shared link) → switch
14+ // the session into it and reload, so every atom re-scopes
15+ // - anything else → a WRONG ADDRESS → not-found. A slug must never silently
16+ // resolve to a different workspace than the URL names, and single-segment
17+ // typos (/this-page-does-not-exist) match the slugged index route, so this
18+ // IS the app's 404 for them.
19+ // Membership comes from the already-cached organizations atom; the matched
20+ // case alone performs a side effect, isolated in `SwitchIntoOrg`.
2021// ---------------------------------------------------------------------------
2122
22- export function ForeignOrgSlug ( props : { readonly slug : string } ) {
23- const organizations = useAtomValue ( organizationsAtom ) ;
23+ const Centered = ( props : { children ?: ReactNode } ) => (
24+ < div className = "flex min-h-full flex-1 items-center justify-center text-sm text-muted-foreground" >
25+ { props . children }
26+ </ div >
27+ ) ;
28+
29+ // Switch the session into `organizationId`, then full-reload so the whole app
30+ // re-scopes. One-shot: the ref guards React's double-invoke and the render
31+ // between resolution and the reload landing. A failed switch means the slug
32+ // named an org we can't actually enter — fall back to not-found.
33+ function SwitchIntoOrg ( props : { readonly organizationId : string } ) {
2434 const doSwitchOrganization = useAtomSet ( switchOrganization , { mode : "promiseExit" } ) ;
25- const switching = useRef ( false ) ;
26-
27- const target = AsyncResult . match ( organizations , {
28- onInitial : ( ) => null ,
29- onFailure : ( ) => "none" as const ,
30- onSuccess : ( { value } ) =>
31- value . organizations . find ( ( org : { slug : string } ) => org . slug === props . slug ) ??
32- ( "none" as const ) ,
33- } ) ;
34-
35- const targetOrgId = target !== null && target !== "none" ? target . id : null ;
35+ const [ failed , setFailed ] = useState ( false ) ;
36+ const started = useRef ( false ) ;
3637
3738 useEffect ( ( ) => {
38- if ( ! targetOrgId || switching . current ) return ;
39- switching . current = true ;
39+ if ( started . current ) return ;
40+ started . current = true ;
4041 void doSwitchOrganization ( {
41- payload : { organizationId : targetOrgId } ,
42+ payload : { organizationId : props . organizationId } ,
4243 reactivityKeys : authWriteKeys ,
43- } ) . then ( ( exit ) => {
44- // Keep the URL (it already names the target org); reload re-scopes the
45- // app to the switched session. On failure fall through to not-found by
46- // releasing the guard — the next render still has no active match.
47- if ( Exit . isSuccess ( exit ) ) {
48- window . location . reload ( ) ;
49- } else {
50- switching . current = false ;
51- }
52- } ) ;
53- } , [ targetOrgId , doSwitchOrganization ] ) ;
54-
55- if ( target === "none" ) return < OrgSlugNotFound /> ;
56-
57- return (
58- < div className = "flex min-h-full flex-1 items-center justify-center text-sm text-muted-foreground" >
59- { targetOrgId ? "Switching organization…" : "" }
60- </ div >
61- ) ;
44+ } ) . then ( ( exit ) => ( Exit . isSuccess ( exit ) ? window . location . reload ( ) : setFailed ( true ) ) ) ;
45+ } , [ props . organizationId , doSwitchOrganization ] ) ;
46+
47+ return failed ? < OrgSlugNotFound /> : < Centered > Switching organization…</ Centered > ;
48+ }
49+
50+ export function ForeignOrgSlug ( props : { readonly slug : string } ) {
51+ const organizations = useAtomValue ( organizationsAtom ) ;
52+
53+ return AsyncResult . match ( organizations , {
54+ onInitial : ( ) => < Centered /> ,
55+ onFailure : ( ) => < OrgSlugNotFound /> ,
56+ onSuccess : ( { value } ) => {
57+ const match = value . organizations . find ( ( org : { slug : string } ) => org . slug === props . slug ) ;
58+ return match ? < SwitchIntoOrg organizationId = { match . id } /> : < OrgSlugNotFound /> ;
59+ } ,
60+ } ) ;
6261}
0 commit comments