Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 35 additions & 47 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ import {
openWorkspace,
signOutHub,
workspaceTenantId,
type HubUser,
} from "./api/auth.ts";
import { WorkspaceMenu } from "./workspace-menu.tsx";
import { Settings, type SettingsTab } from "./pages/settings/settings.jsx";
Expand Down Expand Up @@ -547,8 +546,20 @@ export function App() {
};

const slowHost = useSlowHost();
const [auth, setAuth] = useState<HubAuthState>("unknown");
const [sessionReadFailed, setSessionReadFailed] = useState(false);
// Read once and never saved: a stale identity must not open the workspace.
const sessionRead = useQuery({
queryKey: keys.session,
queryFn: getHubSession,
staleTime: Infinity,
// Only a hub that is down or unreachable answers later.
retry: (_, cause) => cause instanceof ApiFailure && cause.detail.retryable,
meta: { persist: false },
});
let auth: HubAuthState = "unknown";
if (sessionRead.data) auth = "signed-in";
else if (sessionRead.data === null || sessionRead.isError)
auth = "signed-out";
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
const user = sessionRead.data?.user ?? null;

// A refused read says nothing about what there is, so each query keeps what
// it last read.
Expand Down Expand Up @@ -589,7 +600,7 @@ export function App() {
// Auth and "no coordinator yet" are not a dead hub. Only a dropped
// connection or a 5xx is offline.
const offline =
sessionReadFailed ||
(sessionRead.isPending && sessionRead.failureCount > 0) ||
(readFailure !== null &&
!signedOutByHost &&
(readStatus === undefined || readStatus >= 500));
Expand All @@ -599,48 +610,33 @@ export function App() {
(providersRead.isError && providersRead.data === undefined) ||
(projectsRead.isError && projectsRead.data === undefined);
useEffect(() => {
if (signedOutByHost) setAuth("signed-out");
if (signedOutByHost) queryClient.setQueryData(keys.session, null);
}, [signedOutByHost]);
// Query keys are not scoped to a person, so the cache belongs to whoever was
// signed in. Clearing it also saves the empty cache over the stored one.
useEffect(() => {
if (auth !== "signed-out") return;
queryClient.clear();
queryClient.removeQueries({
predicate: ({ queryKey }) => queryKey[0] !== keys.session[0],
});
setSelected(null);
}, [auth]);
const [setupLater, setSetupLater] = useState(modelSetupDeferred);
const [user, setUser] = useState<HubUser | null>(null);
// Retried failures stay on the loading screen; only a final one is a toast.
useEffect(() => {
if (auth !== "signed-in") {
setUser(null);
return;
}
let cancelled = false;
const readUser = async () => {
try {
const session = await getHubSession();
if (!cancelled) setUser(session?.user ?? null);
} catch (cause) {
if (cancelled) return;
// A hub that is down or unreachable is the loading screen's to show, never a toast.
if (cause instanceof ApiFailure && cause.detail.retryable) return;
toast.error(humanFault(cause, wording.auth.accountUnreadable));
}
};
void readUser();
return () => {
cancelled = true;
};
}, [auth]);
if (sessionRead.error)
toast.error(
humanFault(sessionRead.error, wording.auth.accountUnreadable),
);
}, [sessionRead.error]);
const signOut = async () => {
try {
await signOutHub();
} catch (cause) {
// The session is ended locally either way; the person hears the hub kept it.
toast.error(humanFault(cause, wording.auth.signOutFailed));
}
setUser(null);
setAuth("signed-out");
queryClient.setQueryData(keys.session, null);
};

// Re-reads everything the fold rests on: the first read, every write that
Expand All @@ -661,22 +657,12 @@ export function App() {
setSetupLater(false);
setSelected(null);
setView("projects");
void queryClient.resetQueries();
// Who is signed in is the same in every workspace.
void queryClient.resetQueries({
predicate: ({ queryKey }) => queryKey[0] !== keys.session[0],
});
};

useEffect(() => {
const readSession = async () => {
try {
const session = await getHubSession();
setAuth(session ? "signed-in" : "signed-out");
} catch (cause) {
if (!(cause instanceof ApiFailure)) throw cause;
setSessionReadFailed(true);
}
};
void readSession();
}, []);

// The workspace tenant is created the first time this session signs in; the
// fold reads below all scope themselves with it, so they wait for it.
useEffect(() => {
Expand Down Expand Up @@ -778,7 +764,7 @@ export function App() {
return (
<Auth
onSignedIn={() => {
setAuth("signed-in");
void queryClient.invalidateQueries({ queryKey: keys.session });
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
}}
/>
);
Expand Down Expand Up @@ -808,7 +794,7 @@ export function App() {
if (view === "invite" && urlInvite !== null) {
return (
<>
<InvitationAccept invitationId={urlInvite} />
<InvitationAccept invitationId={urlInvite} user={user} />
<Toaster theme={resolvedMode} richColors closeButton />
</>
);
Expand Down Expand Up @@ -922,7 +908,9 @@ export function App() {
<Settings
onChanged={refresh}
user={user}
onUserChanged={setUser}
onUserChanged={(changed) =>
queryClient.setQueryData(keys.session, { user: changed })
}
onSignOut={signOut}
{...(settingsTab !== undefined
? { requestedTab: settingsTab }
Expand Down
17 changes: 9 additions & 8 deletions apps/web/src/pages/invite-accept.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ import { toast } from "sonner";
import { ProductApiError } from "@corbits-builder/client";
import { Banner, Button, Mark, StateLabel } from "../components.jsx";
import { productClient } from "../api/transport.ts";
import { getHubSession, type HubUser } from "../api/auth.ts";
import type { HubUser } from "../api/auth.ts";
import { navigate } from "../routes.ts";
import type { Invitation } from "@corbits-builder/schemas/invitation";

export function InvitationAccept({ invitationId }: { invitationId: string }) {
export function InvitationAccept({
invitationId,
user,
}: {
invitationId: string;
user: HubUser | null;
}) {
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [roleName, setRoleName] = useState<string | null>(null);
const [user, setUser] = useState<HubUser | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const [accepted, setAccepted] = useState<Invitation | null>(null);
const [busy, setBusy] = useState(false);
Expand All @@ -30,13 +35,9 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) {
async function load() {
try {
const client = productClient();
const [record, session] = await Promise.all([
client.invitation(invitationId),
getHubSession(),
]);
const record = await client.invitation(invitationId);
if (cancelled) return;
setInvitation(record);
setUser(session?.user ?? null);
try {
const tenantId = record.tenantId;
const roles = await client.tenantRoles(tenantId);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/queries/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* instance of that resource.
*/
export const keys = {
/** Who is signed in, or null when no one is. */
session: ["session"] as const,
workspaces: ["workspaces"] as const,
designDraft: {
all: ["designDraft"] as const,
Expand Down