Skip to content
Draft
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
29 changes: 28 additions & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export type ApiSession = { accessToken: string; tenantId: string; username: string };
export type ApiSession = {
accessToken: string;
tenantId: string;
username: string;
subjectId: string;
};
export type ApiDownload = { blob: Blob; fileName: string | null; contentType: string | null };

export class ApiError extends Error {
Expand All @@ -12,6 +17,28 @@ export class ApiError extends Error {
}
}

export function accessTokenSubjectId(accessToken: string): string {
try {
const parts = accessToken.split(".");
if (parts.length !== 3 || !parts[1]) throw new Error("Malformed token.");
const base64 = parts[1].replaceAll("-", "+").replaceAll("_", "/") +
"=".repeat((4 - (parts[1].length % 4)) % 4);
const bytes = Uint8Array.from(atob(base64), (value) => value.charCodeAt(0));
const payload = JSON.parse(new TextDecoder().decode(bytes)) as {
sub?: unknown;
};
if (
typeof payload.sub !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(payload.sub)
) {
throw new Error("Token subject is missing.");
}
return payload.sub.toLowerCase();
} catch {
throw new Error("The authenticated account identity is unavailable.");
}
}

export function resolveApiBaseUrl(): string {
return trimTrailingSlash(import.meta.env.VITE_BUNKFY_API_BASE_URL?.trim() || "http://localhost:5194");
}
Expand Down
22 changes: 19 additions & 3 deletions src/app/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
apiDownload,
apiRequest,
apiStream,
accessTokenSubjectId,
resolveApiBaseUrl,
type ApiDownload,
type ApiSession,
Expand Down Expand Up @@ -126,7 +127,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
headers: { "X-Tenant-Id": GLOBAL_IDENTITY_SCOPE },
},
);
const refreshed = { ...identity, accessToken };
const refreshed = {
...identity,
accessToken,
subjectId: accessTokenSubjectId(accessToken),
};
if (!acceptsRefreshRef.current) {
throw new Error("You are signed out.");
}
Expand Down Expand Up @@ -191,6 +196,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
...response,
tenantId: GLOBAL_IDENTITY_SCOPE,
username: credentials.username,
subjectId: accessTokenSubjectId(response.accessToken),
});
return null;
});
Expand Down Expand Up @@ -219,6 +225,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
...response,
tenantId: GLOBAL_IDENTITY_SCOPE,
username,
subjectId: accessTokenSubjectId(response.accessToken),
};
try {
const methods = await apiRequest<AuthenticationMethods>(
Expand Down Expand Up @@ -355,6 +362,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
...response,
tenantId: GLOBAL_IDENTITY_SCOPE,
username: `${providerLabel(provider)} account`,
subjectId: accessTokenSubjectId(response.accessToken),
};
try {
const methods = await apiRequest<AuthenticationMethods>(
Expand Down Expand Up @@ -441,7 +449,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
},
active,
);
setSession({ ...active, accessToken: response.accessToken });
setSession({
...active,
accessToken: response.accessToken,
subjectId: accessTokenSubjectId(response.accessToken),
});
},
[setSession],
);
Expand All @@ -458,7 +470,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
},
active,
);
setSession({ ...active, accessToken: response.accessToken });
setSession({
...active,
accessToken: response.accessToken,
subjectId: accessTokenSubjectId(response.accessToken),
});
return response.recoveryCodes;
},
[setSession],
Expand Down
33 changes: 17 additions & 16 deletions src/app/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import {
useState,
type ReactNode,
} from "react";
import type {
OrganizationListResponse,
OrganizationMembershipSummary,
Property,
} from "../api/types";
import type { OrganizationMembershipSummary, Property } from "../api/types";
import { loadAllProperties } from "../features/properties/propertiesApi";
import {
loadAllWorkspaces,
resolveSelectedWorkspaceId,
} from "../features/workspaces/workspacesApi";
import { useSession } from "./session";

const WORKSPACE_STORAGE_KEY = "bunkfy.workspace.current.v1";
Expand Down Expand Up @@ -45,23 +45,24 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
});
const workspacesQuery = useQuery({
queryKey: ["organizations", "mine"],
queryFn: () => request<OrganizationListResponse>("/api/organizations?page=1&pageSize=100"),
queryFn: (context) => loadAllWorkspaces(request, context.signal),
});
const workspaces = workspacesQuery.data?.items ?? [];
const workspaces = workspacesQuery.data ?? [];

useEffect(() => {
if (workspacesQuery.isLoading) return;
const selectedExists = workspaces.some(
(item) => item.organization.organizationId === selectedWorkspaceId,
);
const nextId = selectedExists
? selectedWorkspaceId
: workspaces[0]?.organization.organizationId ?? "";
if (workspacesQuery.isLoading || workspacesQuery.error) return;
const nextId = resolveSelectedWorkspaceId(workspaces, selectedWorkspaceId);
if (nextId !== selectedWorkspaceId) setSelectedWorkspaceIdState(nextId);
selectWorkspace(nextId);
if (nextId) localStorage.setItem(WORKSPACE_STORAGE_KEY, nextId);
else localStorage.removeItem(WORKSPACE_STORAGE_KEY);
}, [selectWorkspace, selectedWorkspaceId, workspaces, workspacesQuery.isLoading]);
}, [
selectWorkspace,
selectedWorkspaceId,
workspaces,
workspacesQuery.error,
workspacesQuery.isLoading,
]);

const setSelectedWorkspaceId = useCallback(
(id: string) => {
Expand Down Expand Up @@ -115,7 +116,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
selectedWorkspaceId,
setSelectedWorkspaceId,
refetchWorkspaces: async () => {
await workspacesQuery.refetch();
await workspacesQuery.refetch({ throwOnError: true });
},
properties,
propertiesLoading: propertiesQuery.isLoading,
Expand Down
Loading