diff --git a/README.md b/README.md index b679b04c6..f8acc7dee 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,11 @@ OAUTH_CLIENT_ID= OAUTH_CLIENT_SECRET= SESSION_SECRET=gNSzdRbs4dJYz0obHfeRwaD+u5QbZgJx+V8/rgUH6AiOdoppP3wjeaM97nZmxeJa -# One of admin, editor, or user, depending on desired access to the app. Does nothing in production. +# One of admin, editor, or user. Sets the max access level granted. Does nothing in production. ACCESS_LEVEL_OVERRIDE=admin + +# One of admin, editor, or user. The level the app is viewed as by default (client-side). +VITE_DEFAULT_ACCESS_LEVEL=admin ``` ## Onshape OAuth App Setup @@ -124,6 +127,31 @@ To see documents, add one or more documents and push a new app version to rebuil To view the state of Cloudflare, type `e` in Vite to launch the local Cloudflare UI instance. +## Standalone (not-signed-in) mode + +The app also runs without an Onshape login. Opening it directly (e.g. +`https://localhost:3000/`, rather than launching it from the Onshape panel) +serves the read-only library UI: browse groups, search, and open the +configuration menu. Anything that needs Onshape — inserting/deriving, favorites, +saving settings server-side, and live configuration previews — is hidden and +guarded server-side behind sign-in. Settings (theme/library) fall back to +`localStorage`, and the configuration menu uses default units and the stored +(static) thumbnail. + +This needs a populated database. Import a dump of the loaded cert DB into local +D1, and (optionally) its thumbnails into local R2: + +``` +npx wrangler d1 execute DB --local --file=.sql +npx wrangler r2 object put frc-design-app-dev-thumbnails/thumbnails// --file=.gif +``` + +To exercise the signed-in-only UI (favorites, insert button) without a real +Onshape session, set `FORCE_SIGNED_IN=true` in your `.env`. This is a +testing-only escape hatch — it uses a fake user id and Onshape calls it reveals +won't actually work, so leave it unset normally. Combine with +`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls. + # Troubleshooting ## Onshape fails to load diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index 5527efa45..c97ebd285 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -9,6 +9,11 @@ export interface TestAppOptions { accessLevel?: AccessLevel; /** Onshape mock returned by `c.var.getOnshapeApi()` (default a fresh mock). */ onshapeApi?: MockOnshapeApi; + /** + * When false, `getOnshapeApi` rejects so `isSignedIn()` is false (simulating + * a not-signed-in caller). Default true. + */ + signedIn?: boolean; /** Whether the caller passes the auth gate (default true). */ isAuthenticated?: boolean; } @@ -19,8 +24,12 @@ export interface TestAppOptions { * `app.request(path, init, env)` (pass `env` from `cloudflare:workers`). */ export function createTestApp(options: TestAppOptions = {}) { + const signedIn = options.signedIn ?? true; return createApp(() => ({ - getOnshapeApi: () => Promise.resolve(MOCK_ONSHAPE_API), + getOnshapeApi: () => + signedIn + ? Promise.resolve(options.onshapeApi ?? MOCK_ONSHAPE_API) + : Promise.reject(new Error("Not signed in")), getUserId: () => Promise.resolve(options.userId ?? "test-user"), getAccessLevel: () => Promise.resolve(options.accessLevel ?? AccessLevel.ADMIN), diff --git a/src/backend/app.ts b/src/backend/app.ts index f955666c6..e15d6a675 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -15,11 +15,15 @@ export interface AppBindings { ADD_GROUP_WORKFLOW: Workflow; ADMIN_TEAM: string; ACCESS_LEVEL_OVERRIDE?: string; + /** Testing-only: treat requests as signed in with a fake user. Not for production. */ + FORCE_SIGNED_IN?: string; } interface AppVariables { /** Internal cache for {@link getOnshapeApi} in auth.ts. */ onshapeApi?: OAuthApi; + /** Internal cache for isSignedIn in sign-in-utils.ts. */ + signedIn?: boolean; /** Injected getters — see {@link AppServices} / `createApp`. */ getOnshapeApi: () => Promise; getUserId: () => Promise; diff --git a/src/backend/auth.ts b/src/backend/auth.ts index a825e9bab..4b3ab51e9 100644 --- a/src/backend/auth.ts +++ b/src/backend/auth.ts @@ -118,7 +118,9 @@ authRoutes.get("/sign-in", async (c) => { }); } - const companyId = query.sessionCompanyId ?? "cad"; + // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the + // user can pick their account on Onshape. + const companyId = query.sessionCompanyId; const authorizationUrl = await doSignIn(c, redirectUrl, companyId); return c.redirect(authorizationUrl); }); @@ -135,7 +137,7 @@ authRoutes.get("/callback", async (c) => { export async function doSignIn( c: AppContext, redirectUrl: string, - companyId: string + companyId?: string ): Promise { const oauthClient = getOauthClient(); @@ -149,7 +151,11 @@ export async function doSignIn( state, [] ); - authorizationUrl.searchParams.set("company_id", companyId); + // Onshape-launched sign-in scopes to a company; standalone sign-in omits it + // so the user picks their account. + if (companyId) { + authorizationUrl.searchParams.set("company_id", companyId); + } return authorizationUrl.toString(); } diff --git a/src/backend/routes/favorites.ts b/src/backend/routes/favorites.ts index e4f798ab5..c81e253b2 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/routes/favorites.ts @@ -6,6 +6,7 @@ import { type Favorite, type FavoritesData } from "../../shared/api-models"; import { type LibraryId } from "../../shared/types"; import { HttpStatus } from "http-status-ts"; import { type ParameterValues } from "../../shared/configuration-models"; +import { requireSignInMiddleware } from "../sign-in-utils"; export const favoriteRoutes = getApp(); @@ -46,6 +47,7 @@ async function getFavorites( */ favoriteRoutes.get( "/favorites" + libraryRoute(), + requireSignInMiddleware, cacheMiddleware(), async (c) => { const userId = await c.var.getUserId(); @@ -58,99 +60,117 @@ favoriteRoutes.get( /** * Creates a new favorite. */ -favoriteRoutes.post("/favorites" + libraryRoute(), async (c) => { - const libraryId = getLibraryParam(c); - const userId = await c.var.getUserId(); - const insertableId = c.req.query("insertableId"); - const favoriteId = c.req.query("id"); - if (!insertableId) - return c.json( - { error: "insertableId required" }, - HttpStatus.BAD_REQUEST - ); - if (!favoriteId) - return c.json({ error: "id required" }, HttpStatus.BAD_REQUEST); +favoriteRoutes.post( + "/favorites" + libraryRoute(), + requireSignInMiddleware, + async (c) => { + const libraryId = getLibraryParam(c); + const userId = await c.var.getUserId(); + const insertableId = c.req.query("insertableId"); + const favoriteId = c.req.query("id"); + if (!insertableId) + return c.json( + { error: "insertableId required" }, + HttpStatus.BAD_REQUEST + ); + if (!favoriteId) + return c.json({ error: "id required" }, HttpStatus.BAD_REQUEST); - const db = getDb(c.env.DB); + const db = getDb(c.env.DB); - await db.insert(users).values({ id: userId }).onConflictDoNothing(); + await db.insert(users).values({ id: userId }).onConflictDoNothing(); - const existingCount = await db - .select({ sortOrder: favorites.sortOrder }) - .from(favorites) - .where( - and( - eq(favorites.userId, userId), - eq(favorites.libraryId, libraryId) + const existingCount = await db + .select({ sortOrder: favorites.sortOrder }) + .from(favorites) + .where( + and( + eq(favorites.userId, userId), + eq(favorites.libraryId, libraryId) + ) ) - ) - .all(); - - await db - .insert(favorites) - .values({ - id: favoriteId, - userId, - libraryId, - insertableId, - sortOrder: existingCount.length - }) - .onConflictDoNothing(); - - return c.json({ success: true }); -}); + .all(); + + await db + .insert(favorites) + .values({ + id: favoriteId, + userId, + libraryId, + insertableId, + sortOrder: existingCount.length + }) + .onConflictDoNothing(); + + return c.json({ success: true }); + } +); /** * Deletes a user's favorites. */ -favoriteRoutes.delete("/favorites/:favoriteId", async (c) => { - const favoriteId = c.req.param("favoriteId"); - if (!favoriteId) { - return c.json( - { error: "favoriteId is required" }, - HttpStatus.BAD_REQUEST - ); - } - const userId = await c.var.getUserId(); - const db = getDb(c.env.DB); +favoriteRoutes.delete( + "/favorites/:favoriteId", + requireSignInMiddleware, + async (c) => { + const favoriteId = c.req.param("favoriteId"); + if (!favoriteId) { + return c.json( + { error: "favoriteId is required" }, + HttpStatus.BAD_REQUEST + ); + } + const userId = await c.var.getUserId(); + const db = getDb(c.env.DB); - // security: Require the user to also match - await db - .delete(favorites) - .where(and(eq(favorites.id, favoriteId), eq(favorites.userId, userId))); + // security: Require the user to also match + await db + .delete(favorites) + .where( + and(eq(favorites.id, favoriteId), eq(favorites.userId, userId)) + ); - return c.json({ success: true }); -}); + return c.json({ success: true }); + } +); /** POST /api/favorite-order/library/:libraryId */ -favoriteRoutes.post("/favorite-order" + libraryRoute(), async (c) => { - const body = await c.req.json<{ favoriteOrder: string[] }>(); - - const db = getDb(c.env.DB); - await Promise.all( - body.favoriteOrder.map((id, i) => - db - .update(favorites) - .set({ sortOrder: i }) - .where(eq(favorites.id, id)) - ) - ); +favoriteRoutes.post( + "/favorite-order" + libraryRoute(), + requireSignInMiddleware, + async (c) => { + const body = await c.req.json<{ favoriteOrder: string[] }>(); - return c.json({ success: true }); -}); + const db = getDb(c.env.DB); + await Promise.all( + body.favoriteOrder.map((id, i) => + db + .update(favorites) + .set({ sortOrder: i }) + .where(eq(favorites.id, id)) + ) + ); + + return c.json({ success: true }); + } +); /** POST /api/default-configuration/:favoriteId */ -favoriteRoutes.post("/default-configuration/:favoriteId", async (c) => { - const favoriteId = c.req.param("favoriteId"); - const body = await c.req.json<{ - defaultConfiguration: ParameterValues; - }>(); - - const db = getDb(c.env.DB); - await db - .update(favorites) - .set({ defaultConfiguration: body.defaultConfiguration }) - .where(eq(favorites.id, favoriteId)); - - return c.json({ success: true }); -}); +favoriteRoutes.post( + "/default-configuration/:favoriteId", + requireSignInMiddleware, + async (c) => { + const favoriteId = c.req.param("favoriteId"); + const body = await c.req.json<{ + defaultConfiguration: ParameterValues; + }>(); + + const db = getDb(c.env.DB); + await db + .update(favorites) + .set({ defaultConfiguration: body.defaultConfiguration }) + .where(eq(favorites.id, favoriteId)); + + return c.json({ success: true }); + } +); diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index 520e95abd..bd0f49673 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -4,6 +4,7 @@ import { HttpStatus } from "http-status-ts"; import { getApp, getInsertableParam, insertableRoute } from "../app"; import { getDb, type Db } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; +import { requireSignInMiddleware } from "../sign-in-utils"; import { insertables, configurations } from "../../shared/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; import { type ElementPath } from "../../shared/onshape-path"; @@ -205,6 +206,7 @@ insertableRoutes.post( "/add-to-part-studio" + insertableRoute() + "/d/:documentId/:instanceType/:instanceId/e/:elementId", + requireSignInMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); @@ -275,6 +277,7 @@ insertableRoutes.post( "/add-to-assembly" + insertableRoute() + "/d/:documentId/:instanceType/:instanceId/e/:elementId", + requireSignInMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); diff --git a/src/backend/routes/not-signed-in.test.ts b/src/backend/routes/not-signed-in.test.ts new file mode 100644 index 000000000..038bafead --- /dev/null +++ b/src/backend/routes/not-signed-in.test.ts @@ -0,0 +1,66 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel, LibraryId, Theme } from "../../shared/types"; +import { + createTestApp, + jsonRequest, + resetDb, + seedLibrary +} from "../../__test_utils__"; +import { getDb } from "../db"; + +const db = getDb(env.DB); + +describe("not-signed-in access", () => { + beforeEach(async () => { + await resetDb(db); + }); + + it("GET /access-data reports signedIn: false when not signed in", async () => { + const app = createTestApp({ + signedIn: false, + accessLevel: AccessLevel.USER + }); + + const res = await app.request( + "/api/access-data", + jsonRequest("GET"), + env + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + maxAccessLevel: AccessLevel.USER, + signedIn: false + }); + }); + + it("blocks sign-in-only routes with 401 when not signed in", async () => { + const app = createTestApp({ signedIn: false }); + + const favorites = await app.request( + "/api/favorites/library/" + LibraryId.FRC_DESIGN_LIB, + jsonRequest("GET"), + env + ); + expect(favorites.status).toBe(401); + + const userData = await app.request( + "/api/user-data", + jsonRequest("POST", { theme: Theme.DARK }), + env + ); + expect(userData.status).toBe(401); + }); + + it("allows sign-in-only routes when signed in", async () => { + await seedLibrary(db); + const app = createTestApp({ signedIn: true }); + + const favorites = await app.request( + "/api/favorites/library/" + LibraryId.FRC_DESIGN_LIB, + jsonRequest("GET"), + env + ); + expect(favorites.status).toBe(200); + }); +}); diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index c8aa1edd7..a1390de12 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -10,6 +10,7 @@ import { import { getInsertableElementPath } from "./insertables"; import { getDb } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; +import { requireSignInMiddleware } from "../sign-in-utils"; import { bumpLibraryVersion } from "../library-data"; import { getElementThumbnail, @@ -142,6 +143,7 @@ thumbnailRoutes.get( /** GET /api/thumbnail?size=X&thumbnailId=Y&v=:microversionId — live from Onshape */ thumbnailRoutes.get( "/thumbnail", + requireSignInMiddleware, cacheMiddleware(CachePolicy.PUBLIC_CACHE), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); @@ -164,6 +166,7 @@ thumbnailRoutes.get( /** GET /api/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId */ thumbnailRoutes.get( "/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId", + requireSignInMiddleware, // Its url names an immutable version, so there is no `?v=` to bust. cacheMiddleware(CachePolicy.PUBLIC_CACHE, { versioned: false }), async (c) => { diff --git a/src/backend/routes/user.test.ts b/src/backend/routes/user.test.ts index 492868635..b4cc6b06d 100644 --- a/src/backend/routes/user.test.ts +++ b/src/backend/routes/user.test.ts @@ -34,7 +34,7 @@ describe("user routes", () => { expect(await res.json()).toEqual({ maxAccessLevel: AccessLevel.ADMIN, - currentAccessLevel: AccessLevel.ADMIN + signedIn: true }); // Per-user, and Workers Cache keys ignore cookies. expect(res.headers.get("Cache-Control")).toBe("private, no-store"); diff --git a/src/backend/routes/user.ts b/src/backend/routes/user.ts index 3f3e69fd5..e8336619a 100644 --- a/src/backend/routes/user.ts +++ b/src/backend/routes/user.ts @@ -2,31 +2,21 @@ import { eq } from "drizzle-orm"; import { cacheMiddleware, getApp } from "../app"; import { getDb } from "../db"; import { users } from "../../shared/schema"; -import { - AccessLevel, - type AccessData, - type SettingsUpdate -} from "../../shared/types"; -import { env } from "process"; +import { type AccessData, type SettingsUpdate } from "../../shared/types"; +import { isSignedIn, requireSignInMiddleware } from "../sign-in-utils"; export const userRoutes = getApp(); /** GET /api/access-data */ userRoutes.get("/access-data", cacheMiddleware(), async (c) => { - const maxAccessLevel = await c.var.getAccessLevel(); - - // Always default to user in dev and the max in production - const currentAccessLevel = - env.NODE_ENV === "production" ? AccessLevel.USER : maxAccessLevel; - return c.json({ - maxAccessLevel, - currentAccessLevel + maxAccessLevel: await c.var.getAccessLevel(), + signedIn: await isSignedIn(c) } satisfies AccessData); }); /** POST /api/user-data — update settings */ -userRoutes.post("/user-data", async (c) => { +userRoutes.post("/user-data", requireSignInMiddleware, async (c) => { const userId = await c.var.getUserId(); const body = await c.req.json(); diff --git a/src/backend/services.ts b/src/backend/services.ts index 37f3198e4..c01090fd1 100644 --- a/src/backend/services.ts +++ b/src/backend/services.ts @@ -1,16 +1,34 @@ import { type AppServicesFactory } from "./app"; import { getCachedUserId, getOnshapeApi, isAuthenticated } from "./auth"; import { getCachedAccessLevel } from "./access-level-utils"; -import { type AccessLevel } from "../shared/types"; +import { isForceSignedIn, isSignedIn } from "./sign-in-utils"; +import { AccessLevel } from "../shared/types"; -/** Production dependency wiring, memoizing the Onshape lookups in KV by session. */ +/** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ +export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; + +/** + * Production dependency wiring, memoizing the Onshape lookups in KV by session. + * getUserId only runs behind requireSignInMiddleware; getAccessLevel falls back to USER. + */ export const productionServices: AppServicesFactory = (c) => ({ getOnshapeApi: () => getOnshapeApi(c), - getUserId: () => getCachedUserId(c), + getUserId: () => { + // FORCE_SIGNED_IN has no real Onshape session; use a stable fake id. + if (isForceSignedIn(c)) { + return Promise.resolve(FORCE_SIGNED_IN_USER_ID); + } + return getCachedUserId(c); + }, getAccessLevel: async () => { const override = c.env.ACCESS_LEVEL_OVERRIDE; if (override) return override as AccessLevel; - return getCachedAccessLevel(c); + // getCachedAccessLevel needs a real Onshape session, so only call it + // for a genuinely signed-in caller (not FORCE_SIGNED_IN). + if (!isForceSignedIn(c) && (await isSignedIn(c))) { + return getCachedAccessLevel(c); + } + return AccessLevel.USER; }, isAuthenticated: () => isAuthenticated(c) }); diff --git a/src/backend/sign-in-utils.ts b/src/backend/sign-in-utils.ts new file mode 100644 index 000000000..61e394f0b --- /dev/null +++ b/src/backend/sign-in-utils.ts @@ -0,0 +1,50 @@ +import type { MiddlewareHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { HttpStatus } from "http-status-ts"; +import { env } from "process"; +import { type AppContext, type AppContextEnv } from "./app"; + +/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ +export function isForceSignedIn(c: AppContext): boolean { + return !!c.env.FORCE_SIGNED_IN && env.NODE_ENV !== "production"; +} + +/** + * Whether the caller has a valid Onshape session, memoized on the request. + * `FORCE_SIGNED_IN` forces it true for testing (see services.ts). + */ +export async function isSignedIn(c: AppContext): Promise { + const cached = c.get("signedIn"); + if (cached !== undefined) return cached; + + let signedIn: boolean; + if (isForceSignedIn(c)) { + signedIn = true; + } else { + try { + await c.var.getOnshapeApi(); + signedIn = true; + } catch { + signedIn = false; + } + } + + c.set("signedIn", signedIn); + return signedIn; +} + +/** + * Middleware which requires the caller to be signed in to Onshape. + */ +export const requireSignInMiddleware: MiddlewareHandler = async ( + c, + next +) => { + if (!(await isSignedIn(c))) { + throw new HTTPException(HttpStatus.UNAUTHORIZED, { + message: + "You must be signed in to Onshape to use this functionality" + }); + } + await next(); +}; diff --git a/src/frontend/api-utils/access-level.tsx b/src/frontend/api-utils/access-level.tsx index 6da079c41..db02b456b 100644 --- a/src/frontend/api-utils/access-level.tsx +++ b/src/frontend/api-utils/access-level.tsx @@ -1,37 +1,72 @@ -import { PropsWithChildren } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { PropsWithChildren, useMemo } from "react"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { hasEditorAccess } from "../../shared/types"; import { hasAdminAccess } from "../../shared/types"; +import { isWithinAccessLevel } from "../../shared/types"; import { AccessLevel, type AccessData } from "../../shared/types"; -import { getAccessDataQuery } from "../queries"; +import { apiGet } from "./api"; +import { useUiState } from "./ui-state"; -/** What an unresolved caller gets: the least the app can show anyone. */ const DEFAULT_ACCESS_DATA: AccessData = { maxAccessLevel: AccessLevel.USER, - currentAccessLevel: AccessLevel.USER + signedIn: false }; -/** The caller's access, which nothing waits for — editor affordances appear late. */ -export function useAccessData(): AccessData { - return useQuery(getAccessDataQuery()).data ?? DEFAULT_ACCESS_DATA; +/** The level the app is viewed as by default; overridable in dev via a Vite var. */ +const DEFAULT_ACCESS_LEVEL = + (import.meta.env.VITE_DEFAULT_ACCESS_LEVEL as AccessLevel | undefined) ?? + AccessLevel.USER; + +export function accessDataQueryKey() { + return ["access-data"]; +} + +export function getAccessDataQuery() { + return queryOptions({ + queryKey: accessDataQueryKey(), + queryFn: () => apiGet("/access-data") + }); +} + +/** Server access plus the level the app is currently viewed as. */ +export interface ResolvedAccessData extends AccessData { + currentAccessLevel: AccessLevel; +} + +/** + * The caller's access. The viewed level is a local choice (the settings menu can + * drop below the granted max), so it survives the query refetching on navigation. + */ +export function useAccessData(): ResolvedAccessData { + const serverData = + useQuery(getAccessDataQuery()).data ?? DEFAULT_ACCESS_DATA; + const chosenLevel = useUiState()[0].accessLevel; + return useMemo(() => { + const desired = chosenLevel ?? DEFAULT_ACCESS_LEVEL; + // A stored choice can outlive the access that allowed it; clamp to max. + const currentAccessLevel = isWithinAccessLevel( + desired, + serverData.maxAccessLevel + ) + ? desired + : serverData.maxAccessLevel; + return { ...serverData, currentAccessLevel }; + }, [serverData, chosenLevel]); +} + +/** Whether the caller is signed in to Onshape (from access-data). */ +export function useIsSignedIn(): boolean { + return useAccessData().signedIn; } interface RequireAccessLevelProps extends PropsWithChildren { - /** - * @optional - * @default AccessLevel.EDITOR - */ + /** @default AccessLevel.EDITOR */ accessLevel?: AccessLevel; - /** - * If specified, this will check against the maxAccessLevel instead of currentAccessLevel. - * @default false - */ + /** Check against maxAccessLevel instead of the viewed level. @default false */ useMaxAccessLevel?: boolean; } -/** - * Simple component which renders children only if the given accessLevel requirement is met. - */ +/** Renders children only when the access-level requirement is met. */ export function RequireAccessLevel(props: RequireAccessLevelProps) { const accessData = useAccessData(); const requiredAccessLevel = props.accessLevel ?? AccessLevel.EDITOR; @@ -52,3 +87,8 @@ export function RequireAccessLevel(props: RequireAccessLevelProps) { } return null; } + +/** Renders children only when the caller is signed in to Onshape. */ +export function RequireSignIn(props: PropsWithChildren) { + return useIsSignedIn() ? props.children : null; +} diff --git a/src/frontend/api-utils/library.ts b/src/frontend/api-utils/library.ts index 7037b1862..411385a4e 100644 --- a/src/frontend/api-utils/library.ts +++ b/src/frontend/api-utils/library.ts @@ -1,9 +1,15 @@ import { useParams } from "@tanstack/react-router"; -import { LibraryId } from "../../shared/types"; +import { DEFAULT_LIBRARY_ID, LibraryId } from "../../shared/types"; /** Returns the library being displayed, which the url is the source of truth for. */ export function useLibraryId(): LibraryId { - return useParams({ from: "/app/library/$libraryId" }).libraryId; + // Callers can sit outside the library route — modals mount at the root and + // error components replace the match — so fall back instead of throwing. + const params = useParams({ + from: "/app/library/$libraryId", + shouldThrow: false + }); + return params?.libraryId ?? DEFAULT_LIBRARY_ID; } export function toLibraryPath(libraryId: LibraryId): string { diff --git a/src/frontend/api-utils/messages.ts b/src/frontend/api-utils/messages.ts index e9562ec6b..7023a438e 100644 --- a/src/frontend/api-utils/messages.ts +++ b/src/frontend/api-utils/messages.ts @@ -10,13 +10,18 @@ import { useSearch } from "@tanstack/react-router"; import { type ElementPath } from "../../shared/onshape-path"; import { useCallback, useEffect } from "react"; +import { useIsConnectedToOnshape } from "./onshape-params"; export function useMessageListener() { const search = useSearch({ from: "/app" }); + // Nothing to message unless embedded in an Onshape document. + const isConnected = useIsConnectedToOnshape(); useEffect(() => { - sendInitMessage(search); - }, [search]); + if (isConnected) { + sendInitMessage(search); + } + }, [search, isConnected]); useEffect(() => { const handlePostMessage = (event: MessageEvent) => { @@ -38,11 +43,13 @@ export function useMessageListener() { export function useMessageSender() { const search = useSearch({ from: "/app" }); + const isConnected = useIsConnectedToOnshape(); return useCallback( (message: Message) => { + if (!isConnected) return; sendMessage(search, message); }, - [search] + [search, isConnected] ); } diff --git a/src/frontend/api-utils/onshape-params.ts b/src/frontend/api-utils/onshape-params.ts index 8f9d57d3f..85638cc2f 100644 --- a/src/frontend/api-utils/onshape-params.ts +++ b/src/frontend/api-utils/onshape-params.ts @@ -1,6 +1,7 @@ +import { useSearch } from "@tanstack/react-router"; import { ElementType } from "../../shared/types"; import { Theme } from "../../shared/types"; -import { ElementPath } from "../../shared/onshape-path"; +import { ElementPath, isElementPath } from "../../shared/onshape-path"; /** * Documents search parameter values received from Onshape. @@ -12,6 +13,8 @@ export interface OnshapeParams extends ElementPath { /** The caller's saved theme, seeded by the entry redirect. */ theme: Theme; server: string; + /** Set on the sign-in redirect so the app confirms success once. */ + justSignedIn?: string; } /** @@ -30,3 +33,11 @@ export function getColorTheme( } return theme; } + +/** + * Whether the app is embedded in an Onshape document, i.e. the url carries a + * full element path. A signed-in caller opening the app directly is not. + */ +export function useIsConnectedToOnshape(): boolean { + return isElementPath(useSearch({ strict: false })); +} diff --git a/src/frontend/api-utils/refresh.ts b/src/frontend/api-utils/refresh.ts index fdccfabc7..e8d0c46a3 100644 --- a/src/frontend/api-utils/refresh.ts +++ b/src/frontend/api-utils/refresh.ts @@ -3,15 +3,14 @@ import { useRouter } from "@tanstack/react-router"; import { queryClient } from "../query-client"; import { buildStatusQueryMatchKey, - accessDataQueryKey, favoritesQueryKey, libraryQueryMatchKey, libraryVersionQueryMatchKey, useJobStatusQuery } from "../queries"; +import { accessDataQueryKey } from "./access-level"; import { useLibraryId } from "./library"; -import { type AccessData, type LibraryId } from "../../shared/types"; -import { getQueryUpdater } from "../common/utils"; +import { type LibraryId } from "../../shared/types"; /** Refetches the current user's favorites, which aren't version-keyed. */ function refetchFavorites(libraryId: LibraryId): Promise { @@ -68,10 +67,3 @@ export function useJobStatus(): boolean { }, [running, refreshLibrary]); return running; } - -type AccessDataUpdate = (data: AccessData) => void; - -/** Optimistically patches the cached access data, which re-renders its readers. */ -export function updateAccessData(update: AccessDataUpdate): void { - queryClient.setQueryData(accessDataQueryKey(), getQueryUpdater(update)); -} diff --git a/src/frontend/api-utils/sign-in.ts b/src/frontend/api-utils/sign-in.ts new file mode 100644 index 000000000..1363fe884 --- /dev/null +++ b/src/frontend/api-utils/sign-in.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { showSuccessToast } from "../common/notifications"; + +const SIGNED_IN_PARAM = "justSignedIn"; + +/** + * Redirects to the Onshape OAuth flow, returning to the current location with a + * marker so the app can confirm the sign-in once it lands back. + */ +export function startSignIn(): void { + const url = new URL(window.location.href); + url.searchParams.set(SIGNED_IN_PARAM, "true"); + const redirectUrl = url.pathname + url.search; + window.location.href = + "/auth/sign-in?redirectUrl=" + encodeURIComponent(redirectUrl); +} + +/** Confirms a sign-in once the caller lands back from Onshape, then clears the marker. */ +export function useSignInToast(): void { + const justSignedIn = useSearch({ from: "/app" }).justSignedIn; + const navigate = useNavigate({ from: "/app" }); + useEffect(() => { + // The OAuth callback only redirects here on success, so the marker's + // presence is the confirmation. + if (!justSignedIn) { + return; + } + showSuccessToast("Signed in to Onshape."); + void navigate({ + search: (prev) => ({ ...prev, [SIGNED_IN_PARAM]: undefined }), + replace: true + }); + }, [justSignedIn, navigate]); +} diff --git a/src/frontend/api-utils/ui-state.ts b/src/frontend/api-utils/ui-state.ts index 0db6282d9..b09057be6 100644 --- a/src/frontend/api-utils/ui-state.ts +++ b/src/frontend/api-utils/ui-state.ts @@ -1,11 +1,12 @@ import { useSyncExternalStore } from "react"; import * as z from "zod"; -import { Vendor } from "../../shared/types"; +import { AccessLevel, Vendor } from "../../shared/types"; // Increment this when a breaking change is made to the schema const LATEST_VERSION = 3; const VendorType = z.enum(Object.values(Vendor)); +const AccessLevelType = z.enum(Object.values(AccessLevel)); const UiStateSchema = z.object({ version: z.number().default(1), // We can't default the parsed version to LATEST_VERSION because of old versions floating around @@ -14,7 +15,9 @@ const UiStateSchema = z.object({ vendorFilters: z.array(VendorType).optional(), searchQuery: z.string().default(""), openGroupId: z.string().optional(), - fasten: z.boolean().default(true) + fasten: z.boolean().default(true), + /** The access level to view the app as; absent means the granted default. */ + accessLevel: AccessLevelType.optional() }); type UiState = z.infer; diff --git a/src/frontend/app-common/app-menu.tsx b/src/frontend/app-common/app-menu.tsx index c634db1fb..e3795281d 100644 --- a/src/frontend/app-common/app-menu.tsx +++ b/src/frontend/app-common/app-menu.tsx @@ -63,16 +63,26 @@ export function AppContextMenu(props: AppContextMenuProps): ReactNode { * An explicit button which opens a menu with the given items. Used alongside * the right-click context menu so the menu is reachable without a right-click. */ -export function MenuButton(props: PropsWithChildren): ReactNode { +interface MenuButtonProps extends PropsWithChildren { + /** + * Sizes the button to sit beside a full-height button rather than in a card row. + * @default false + */ + large?: boolean; +} + +export function MenuButton(props: MenuButtonProps): ReactNode { + const { large, children } = props; return ( - + e.stopPropagation()} > - + ); diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx index d6da3bb45..fd5928000 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/app/app-navbar.tsx @@ -9,7 +9,7 @@ import { Tooltip } from "@mantine/core"; import { IconChevronDown, IconSearch, IconSettings } from "@tabler/icons-react"; -import { IconSize, PrimaryColor } from "../common/style-constants"; +import { HEADER_CONTROL_COLOR, IconSize } from "../common/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -20,6 +20,8 @@ import { useUiState } from "../api-utils/ui-state"; import { getLibraryName, useLibraryId } from "../api-utils/library"; import { RequireAccessLevel } from "../api-utils/access-level"; import { useSaveSettings } from "../settings/settings"; +import { useIsSignedIn } from "../api-utils/access-level"; +import { startSignIn } from "../api-utils/sign-in"; import { useJobStatus } from "../api-utils/refresh"; import { LibraryId } from "../../shared/types"; import { queryClient } from "../query-client"; @@ -45,12 +47,32 @@ export function AppNavbar(): ReactNode { {leftGroup} + ); } +/** + * Shown only when not signed in; starts the Onshape OAuth flow and returns to + * the current location, after which access-data reports the caller signed in. + */ +function SignInButton(): ReactNode { + const isSignedIn = useIsSignedIn(); + if (isSignedIn) return null; + + return ( + + ); +} + /** Editor-only spinner shown while a library-load job is running. */ function JobIndicator(): ReactNode { return ( @@ -69,7 +91,7 @@ function RunningJobLoader(): ReactNode { withArrow label="The library is being loaded from Onshape in the background" > - + ); } @@ -140,11 +162,13 @@ export function SettingsButton() { return ( openSettingsMenu()} > - + ); } diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index 7b0aad755..a6be44103 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -209,7 +209,7 @@ export function useCloseBuildCard(): () => void { * given admin menu. Only rendered for editors and admins. */ export function BuildStatusBadge(props: BuildStatusBadgeProps): ReactNode { - // Gate first so only editors mount the child (and thus poll job status). + // Gate first so the card and its admin controls only exist for editors. return ( diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index a6d8d86e7..9e710df4b 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -25,6 +25,8 @@ import { openCannotDeriveAssemblyAlert } from "../app/alerts"; import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { openInsertMenu } from "../insert/insert-menu"; import { useFavoritesQuery } from "../queries"; +import { RequireSignIn } from "../api-utils/access-level"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; interface InsertableCardProps extends PropsWithChildren { insertable: InsertableOut; @@ -85,7 +87,12 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { /> } rightSection={ - + + + } menuItems={ - {!inInsertMenu && ( + {!inInsertMenu && isConnected && ( <> )} - - + + + + diff --git a/src/frontend/common/style-constants.ts b/src/frontend/common/style-constants.ts index 9ad83acad..cdd7ea36f 100644 --- a/src/frontend/common/style-constants.ts +++ b/src/frontend/common/style-constants.ts @@ -8,6 +8,8 @@ export enum IconSize { SMALL = 16, /** Buttons */ MEDIUM = 18, + /** Input-height controls, which sit next to full-height buttons */ + CONTROL = 24, /** In-line error states */ LARGE = 36, /** Full-page error states */ @@ -29,12 +31,6 @@ export const BORDER = "1px solid var(--mantine-color-default-border)"; /** The app's primary color as a filled background. */ export enum PrimaryColor { - /** - * The current primary color, typically white. - * - * Used to color the buttons that go over the colored app header. - */ - PRIMARY = "var(--mantine-primary-color)", /** * The current library color, e.g., green for FRCDesign. */ @@ -45,6 +41,14 @@ export enum PrimaryColor { CONTRAST = "var(--mantine-primary-color-contrast)" } +/** + * The `color` for Mantine controls sitting on the filled header. Hex, and not + * {@link PrimaryColor.CONTRAST} or `"white"`: Mantine derives each variant's + * border and hover tint by parsing `color`, and its parser understands only + * hex/rgb/hsl — a css var or a named color silently resolves to black. + */ +export const HEADER_CONTROL_COLOR = "#fff"; + /** Red used for heart/favorite icons. */ export const HeartIconColor = "var(--mantine-color-red-6)"; diff --git a/src/frontend/favorites/favorite-button.tsx b/src/frontend/favorites/favorite-button.tsx index dc437329a..2ea8fd126 100644 --- a/src/frontend/favorites/favorite-button.tsx +++ b/src/frontend/favorites/favorite-button.tsx @@ -103,20 +103,30 @@ function useUpdateFavoritesMutation() { interface FavoriteButtonProps { favorite: Favorite | undefined; insertable: InsertableOut; + /** + * Sizes the button to sit beside a full-height button rather than in a card row. + * @default false + */ + large?: boolean; } export function FavoriteButton(props: FavoriteButtonProps): ReactNode { - const { favorite, insertable } = props; + const { favorite, insertable, large } = props; const isFavorite = favorite !== undefined; const [isHovered, setIsHovered] = useState(false); const mutation = useUpdateFavoritesMutation(); + const iconSize = large ? IconSize.CONTROL : IconSize.SMALL; let favoriteIcon; if (isHovered) { - favoriteIcon = isFavorite ? : ; + favoriteIcon = isFavorite ? ( + + ) : ( + + ); } else { - favoriteIcon = ; + favoriteIcon = ; } const operation = isFavorite ? Operation.REMOVE : Operation.ADD; @@ -125,6 +135,7 @@ export function FavoriteButton(props: FavoriteButtonProps): ReactNode { { event.stopPropagation(); const favoriteId = favorite?.id ?? crypto.randomUUID(); @@ -178,17 +189,29 @@ interface HeartIconProps { * @default true */ full?: boolean; + /** + * @default IconSize.SMALL + */ + size?: IconSize; } export function HeartIcon(props: HeartIconProps): ReactNode { - const full = props.full ?? true; + const { full = true, size = IconSize.SMALL } = props; return full ? ( - + ) : ( - + ); } -export function HeartBrokenIcon(): ReactNode { - return ; +interface HeartBrokenIconProps { + /** + * @default IconSize.SMALL + */ + size?: IconSize; +} + +export function HeartBrokenIcon(props: HeartBrokenIconProps): ReactNode { + const { size = IconSize.SMALL } = props; + return ; } diff --git a/src/frontend/favorites/favorite-card.tsx b/src/frontend/favorites/favorite-card.tsx index 4b98e8a69..e5a1132ac 100644 --- a/src/frontend/favorites/favorite-card.tsx +++ b/src/frontend/favorites/favorite-card.tsx @@ -20,6 +20,7 @@ import { useIsInsertableHidden } from "../cards/card-hooks"; import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { ChangeOrderItems } from "../common/change-order"; import { useUiState } from "../api-utils/ui-state"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; import { openCannotDeriveAssemblyAlert, openCannotEditDefaultConfigurationAlert, @@ -96,18 +97,23 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { const { insertable, favorite } = props; const uiState = useUiState()[0]; + const isConnected = useIsConnectedToOnshape(); const setFavoriteOrderMutation = useSetFavoriteOrderMutation(); const favoriteOrder = useFavoritesQuery().data?.favoriteOrder ?? []; return ( <> - - + {isConnected && ( + <> + + + + )} } onClick={() => { diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index 1671f722b..582df0c49 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -121,6 +121,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { path={insertable.path} microversionId={insertable.microversionId} configuration={configuration} + thumbnailUrls={insertable.thumbnailUrls} /> { // Doing this in a useEffect rather than a .then inside useQuery to prevent some buggy behavior @@ -88,7 +94,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { setConfiguration(defaultConfiguration); }, [query.data, configuration, setConfiguration]); - if (query.isPending || unitInfoQuery.isPending || !configuration) { + if (query.isPending || !configuration) { return (
@@ -96,8 +102,6 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { ); } else if (query.isError) { return ; - } else if (unitInfoQuery.isError) { - return ; } return ( @@ -105,7 +109,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { configurationResult={query.data} configuration={configuration} setConfiguration={setConfiguration} - unitInfo={unitInfoQuery.data} + unitInfo={unitInfo} /> ); } @@ -351,33 +355,40 @@ function StringInput(props: ParameterProps): ReactNode { ); } +/** Display precision used when the document's units aren't available. */ +const DEFAULT_QUANTITY_PRECISION = 3; + function getEvaluateOptions( parameter: QuantityParameter, - contextData: UnitInfo + unitInfo: UnitInfo ): EvaluateOptions { const quantityType = parameter.quantityType; const minAndMax = { min: valueWithUnits(parameter.min, parameter.unit), max: valueWithUnits(parameter.max, parameter.unit) }; + // Fall back to the parameter's own unit when the document's isn't available. if (quantityType === QuantityType.LENGTH) { return { quantityType, - displayPrecision: contextData.lengthPrecision, - displayUnit: contextData.lengthUnit, + displayPrecision: + unitInfo.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo.lengthUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType === QuantityType.ANGLE) { return { quantityType, - displayPrecision: contextData.anglePrecision, - displayUnit: contextData.angleUnit, + displayPrecision: + unitInfo.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo.angleUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType == QuantityType.REAL) { return { quantityType, - displayPrecision: contextData.realPrecision, + displayPrecision: + unitInfo.realPrecision ?? DEFAULT_QUANTITY_PRECISION, displayUnit: Unit.UNITLESS, ...minAndMax }; diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index 1518a8889..9525ffab3 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -1,5 +1,5 @@ import { useSearch } from "@tanstack/react-router"; -import { ReactNode, useCallback, useState } from "react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; import { getFavoriteForInsertable, InsertableOut @@ -24,6 +24,9 @@ import { ParameterValues } from "../../shared/configuration-models"; import { useFavoritesQuery } from "../queries"; import { useUiState } from "../api-utils/ui-state"; import { notifications } from "@mantine/notifications"; +import { RequireSignIn, useIsSignedIn } from "../api-utils/access-level"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +import { startSignIn } from "../api-utils/sign-in"; interface OpenInsertMenuProps { insertable: InsertableOut; @@ -64,11 +67,18 @@ interface InsertMenuContentProps { function InsertMenuContent(props: InsertMenuContentProps): ReactNode { const { insertable, onInsert } = props; const favorites = useFavoritesQuery().data?.favorites; + const isSignedIn = useIsSignedIn(); const [configuration, setConfiguration] = useState< ParameterValues | undefined >(props.defaultConfiguration); + useEffect(() => { + if (!isSignedIn) { + showSignInPreviewToast(); + } + }, [isSignedIn]); + if (!favorites) { return null; } @@ -93,15 +103,19 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { path={insertable.path} microversionId={insertable.microversionId} configuration={configuration} + thumbnailUrls={insertable.thumbnailUrls} /> {parameters} - - + + + + {canFasten && ( @@ -176,6 +197,20 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { ); } +/** Prompts a not-signed-in viewer that the live preview needs Onshape. */ +function showSignInPreviewToast() { + notifications.hide("sign-in-preview"); + notifications.show({ + id: "sign-in-preview", + color: "blue", + icon: , + message: renderNotification( + "Sign in to Onshape to see the configuration preview.", + { text: "Sign in", onClick: startSignIn } + ) + }); +} + function showRestoreToast( insertable: InsertableOut, configuration?: ParameterValues diff --git a/src/frontend/insert/thumbnail.tsx b/src/frontend/insert/thumbnail.tsx index 936cdb6b3..093ee8232 100644 --- a/src/frontend/insert/thumbnail.tsx +++ b/src/frontend/insert/thumbnail.tsx @@ -11,6 +11,8 @@ import { encodeConfigurationForQuery } from "../../shared/configuration-utils"; import { getConfigurationMatchKey } from "../queries"; import { SectionError } from "../app-common/app-zero-state"; import { useTargetElementType } from "./insert-hooks"; +import { useIsSignedIn } from "../api-utils/access-level"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; interface HeightAndWidth { height: number; @@ -128,11 +130,15 @@ interface PreviewImageProps { path: ElementPath; microversionId: string; configuration?: ParameterValues; + /** Stored thumbnail, shown instead of the live preview when not signed in. */ + thumbnailUrls?: ThumbnailUrls; } export function PreviewImage(props: PreviewImageProps): ReactNode { - const { path, microversionId, configuration } = props; + const { path, microversionId, configuration, thumbnailUrls } = props; const size = ThumbnailSize.SMALL; + const isSignedIn = useIsSignedIn(); + const isConnected = useIsConnectedToOnshape(); const isFetchingConfiguration = useIsFetching({ queryKey: getConfigurationMatchKey() }) > 0; const targetElementType = useTargetElementType(); @@ -153,7 +159,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { }, // Don't retry since failures are almost certainly due to an invalid configuration retry: false, - enabled: !isFetchingConfiguration + enabled: !isFetchingConfiguration && isSignedIn }); const thumbnailId = thumbnailIdQuery.data; @@ -183,11 +189,24 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { return 15000; }, retry: 5, - enabled: !isFetchingConfiguration && thumbnailId !== undefined + enabled: + !isFetchingConfiguration && thumbnailId !== undefined && isSignedIn }); const heightAndWidth = getHeightAndWidth(size, 0.7); + // Not signed in: no live Onshape preview, so show the stored thumbnail + // (Thumbnail falls back to a placeholder when there's none). + if (!isSignedIn) { + return ( + + ); + } + if (thumbnailIdQuery.isError || thumbnailQuery.isError) { const action = targetElementType === ElementType.ASSEMBLY ? "insert" : "derive"; @@ -195,7 +214,11 @@ export function PreviewImage(props: PreviewImageProps): ReactNode {
); diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index ed973632d..520e337d0 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -12,10 +12,10 @@ import { type LibraryBuildStatus, type LibraryOut } from "../shared/api-models"; -import { LibraryId } from "../shared/types"; -import { type AccessData } from "../shared/types"; +import { hasEditorAccess, LibraryId } from "../shared/types"; +import { useAccessData } from "./api-utils/access-level"; import { toLibraryPath, useLibraryId } from "./api-utils/library"; -import { type UnitInfo } from "../shared/configuration-models"; +import { EMPTY_UNIT_INFO, type UnitInfo } from "../shared/configuration-models"; import MiniSearch from "minisearch"; import { SEARCH_OPTIONS } from "../shared/search"; import { InstancePath } from "../shared/onshape-path"; @@ -85,20 +85,12 @@ export function useCacheVersion(): number { return useQuery(getLibraryVersionQuery(libraryId)).data ?? 0; } -export function accessDataQueryKey() { - return ["access-data"]; -} - -/** The caller's access level, which gates editor-only affordances. */ -export function getAccessDataQuery() { - return queryOptions({ - queryKey: accessDataQueryKey(), - queryFn: () => apiGet("/access-data") - }); -} - -/** Returns information needed to format unit expressions in the Insert dialog. */ -export function useUnitInfoQuery(instancePath: InstancePath) { +/** + * Returns the current document's units for the Insert dialog. Hits Onshape, so + * it's disabled when not connected to a document; each quantity then falls back + * to its own default unit. + */ +export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { return useQuery({ queryKey: ["unit-info", instancePath], queryFn: () => @@ -108,7 +100,9 @@ export function useUnitInfoQuery(instancePath: InstancePath) { instanceId: instancePath.instanceId, instanceType: instancePath.instanceType } - }) + }), + enabled, + placeholderData: EMPTY_UNIT_INFO }); } @@ -145,10 +139,15 @@ export function favoritesQueryKey(libraryId: LibraryId) { return ["favorites", libraryId]; } -export function getFavoritesQuery(libraryId: LibraryId) { +const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] }; + +export function getFavoritesQuery(libraryId: LibraryId, enabled = true) { return queryOptions({ queryKey: favoritesQueryKey(libraryId), - queryFn: () => apiGet("/favorites/library/" + libraryId) + queryFn: () => apiGet("/favorites/library/" + libraryId), + enabled, + // Not signed in: the endpoint 401s, so present no favorites. + placeholderData: EMPTY_FAVORITES }); } @@ -189,7 +188,9 @@ export function useBuildStatusQuery() { export function useFavoritesQuery() { const libraryId = useLibraryId(); - return useQuery(getFavoritesQuery(libraryId)); + // Favorites require sign-in; don't fetch (or display) them otherwise. + const signedIn = useAccessData().signedIn; + return useQuery(getFavoritesQuery(libraryId, signedIn)); } export function jobStatusQueryMatchKey() { @@ -201,16 +202,26 @@ export function jobStatusQueryKey(libraryId: LibraryId) { } /** Whether a library-load job is running; polled so indicators stay live. */ -export function getJobStatusQuery(libraryId: LibraryId) { +export function getJobStatusQuery(libraryId: LibraryId, enabled = true) { return queryOptions<{ running: boolean }>({ queryKey: jobStatusQueryKey(libraryId), queryFn: () => apiGet("/job-status/library/" + libraryId), - refetchInterval: 10_000 + refetchInterval: 10_000, + enabled }); } -/** Only mounted by editor-gated components, so only editors poll. */ +/** + * Job status for the current library. The endpoint is editor-only and needs an + * Onshape session, so callers who have neither don't poll it at all. + */ export function useJobStatusQuery() { const libraryId = useLibraryId(); - return useQuery(getJobStatusQuery(libraryId)); + const { signedIn, currentAccessLevel } = useAccessData(); + return useQuery( + getJobStatusQuery( + libraryId, + signedIn && hasEditorAccess(currentAccessLevel) + ) + ); } diff --git a/src/frontend/routeTree.gen.ts b/src/frontend/routeTree.gen.ts index 56c1db867..dea2f3a0d 100644 --- a/src/frontend/routeTree.gen.ts +++ b/src/frontend/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AppRouteRouteImport } from './routes/app/route' +import { Route as IndexRouteImport } from './routes/index' import { Route as PagesSafariErrorRouteImport } from './routes/_pages/safari-error' import { Route as PagesLicenseRouteImport } from './routes/_pages/license' import { Route as PagesGrantDeniedRouteImport } from './routes/_pages/grant-denied' @@ -24,6 +25,11 @@ const AppRouteRoute = AppRouteRouteImport.update({ path: '/app', getParentRoute: () => rootRouteImport, } as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) const PagesSafariErrorRoute = PagesSafariErrorRouteImport.update({ id: '/_pages/safari-error', path: '/safari-error', @@ -69,6 +75,7 @@ const AppLibraryLibraryIdGroupsGroupIdRoute = } as any) export interface FileRoutesByFullPath { + '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren '/beta-complete': typeof PagesBetaCompleteRoute '/cookie-error': typeof PagesCookieErrorRoute @@ -80,6 +87,7 @@ export interface FileRoutesByFullPath { '/app/library/$libraryId/groups/$groupId': typeof AppLibraryLibraryIdGroupsGroupIdRoute } export interface FileRoutesByTo { + '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren '/beta-complete': typeof PagesBetaCompleteRoute '/cookie-error': typeof PagesCookieErrorRoute @@ -91,6 +99,7 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren '/_pages/beta-complete': typeof PagesBetaCompleteRoute '/_pages/cookie-error': typeof PagesCookieErrorRoute @@ -104,6 +113,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/' | '/app' | '/beta-complete' | '/cookie-error' @@ -115,6 +125,7 @@ export interface FileRouteTypes { | '/app/library/$libraryId/groups/$groupId' fileRoutesByTo: FileRoutesByTo to: + | '/' | '/app' | '/beta-complete' | '/cookie-error' @@ -125,6 +136,7 @@ export interface FileRouteTypes { | '/app/library/$libraryId/groups/$groupId' id: | '__root__' + | '/' | '/app' | '/_pages/beta-complete' | '/_pages/cookie-error' @@ -137,6 +149,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + IndexRoute: typeof IndexRoute AppRouteRoute: typeof AppRouteRouteWithChildren PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute PagesCookieErrorRoute: typeof PagesCookieErrorRoute @@ -154,6 +167,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRouteRouteImport parentRoute: typeof rootRouteImport } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } '/_pages/safari-error': { id: '/_pages/safari-error' path: '/safari-error' @@ -243,6 +263,7 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( ) const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, AppRouteRoute: AppRouteRouteWithChildren, PagesBetaCompleteRoute: PagesBetaCompleteRoute, PagesCookieErrorRoute: PagesCookieErrorRoute, diff --git a/src/frontend/routes/app/library/$libraryId/index.tsx b/src/frontend/routes/app/library/$libraryId/index.tsx index ac66b93af..3b0085db9 100644 --- a/src/frontend/routes/app/library/$libraryId/index.tsx +++ b/src/frontend/routes/app/library/$libraryId/index.tsx @@ -21,6 +21,7 @@ import { FavoritesList } from "../../../../favorites/favorites-list"; import { useLibraryQuery } from "../../../../queries"; import { getLibraryName, useLibraryId } from "../../../../api-utils/library"; import { updateUiState, useUiState } from "../../../../api-utils/ui-state"; +import { useIsSignedIn } from "../../../../api-utils/access-level"; export const Route = createFileRoute("/app/library/$libraryId/")({ component: HomeList, @@ -33,6 +34,8 @@ function HomeList(): ReactNode { const [uiState, setUiState] = useUiState(); const [isSearchOpen, setIsSearchOpen] = useState(true); const libraryId = useLibraryId(); + // Favorites are per-user and hidden until signed in. + const isSignedIn = useIsSignedIn(); const isSearch = !!uiState.searchQuery; const listKey = isSearch ? "search" : "library"; @@ -124,7 +127,7 @@ function HomeList(): ReactNode { } }} > - {favoritesAccordion} + {isSignedIn && favoritesAccordion} {childAccordion} diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index 39365845d..6433d627a 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -1,5 +1,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { queryClient } from "../../../../query-client"; +import { getAccessDataQuery } from "../../../../api-utils/access-level"; import { getFavoritesQuery, getLibraryQuery, @@ -52,6 +53,15 @@ export const Route = createFileRoute("/app/library/$libraryId")({ void queryClient.prefetchQuery( getSearchDbQuery(libraryId, cacheVersion) ); - void queryClient.prefetchQuery(getFavoritesQuery(libraryId)); + // Favorites are per-user, so the endpoint 401s a signed-out caller. + void queryClient + .ensureQueryData(getAccessDataQuery()) + .then((accessData) => { + if (accessData.signedIn) { + void queryClient.prefetchQuery( + getFavoritesQuery(libraryId) + ); + } + }); } }); diff --git a/src/frontend/routes/app/route.tsx b/src/frontend/routes/app/route.tsx index aff6b878f..b341a4a29 100644 --- a/src/frontend/routes/app/route.tsx +++ b/src/frontend/routes/app/route.tsx @@ -12,6 +12,7 @@ import { OnshapeParams } from "../../api-utils/onshape-params"; import { AppNavbar } from "../../app/app-navbar"; import { SectionLoading } from "../../app-common/app-zero-state"; import { useMessageListener } from "../../api-utils/messages"; +import { useSignInToast } from "../../api-utils/sign-in"; import { RootAppError } from "../../app/root-error"; import { PrimaryColor } from "../../common/style-constants"; @@ -32,6 +33,7 @@ function App() { const { ref: headerRef, height: headerHeight } = useElementSize(); useMessageListener(); + useSignInToast(); return ( diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx new file mode 100644 index 000000000..76462c099 --- /dev/null +++ b/src/frontend/routes/index.tsx @@ -0,0 +1,17 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { readLocalSettings } from "../settings/local-settings"; +import { RootAppError } from "../app/root-error"; + +// Direct entry for a user opening the app outside Onshape. Onshape's own launch +// is handled server-side, so it never reaches this route. +export const Route = createFileRoute("/")({ + beforeLoad: () => { + const { libraryId, theme } = readLocalSettings(); + throw redirect({ + to: "/app/library/$libraryId", + params: { libraryId }, + search: { theme } + }); + }, + errorComponent: RootAppError +}); diff --git a/src/frontend/settings/local-settings.ts b/src/frontend/settings/local-settings.ts new file mode 100644 index 000000000..2f8fd6d88 --- /dev/null +++ b/src/frontend/settings/local-settings.ts @@ -0,0 +1,37 @@ +import { + DEFAULT_LIBRARY_ID, + DEFAULT_SETTINGS, + type LibraryId, + type SettingsUpdate, + type Theme +} from "../../shared/types"; + +const SETTINGS_STORAGE_KEY = "frc-design-app-settings"; + +function readStored(): SettingsUpdate { + try { + const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); + return raw ? (JSON.parse(raw) as SettingsUpdate) : {}; + } catch { + return {}; + } +} + +/** Locally-persisted settings (used when not signed in), with defaults filled. */ +export function readLocalSettings(): { theme: Theme; libraryId: LibraryId } { + const stored = readStored(); + return { + theme: stored.theme ?? DEFAULT_SETTINGS.theme, + libraryId: stored.libraryId ?? DEFAULT_LIBRARY_ID + }; +} + +/** Merges and persists settings locally, used when not signed in. */ +export function writeLocalSettings(newSettings: SettingsUpdate): void { + try { + const merged = { ...readStored(), ...newSettings }; + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(merged)); + } catch { + // Ignore storage failures (e.g. private browsing). + } +} diff --git a/src/frontend/settings/settings-menu.tsx b/src/frontend/settings/settings-menu.tsx index aafb00b44..1b4c05513 100644 --- a/src/frontend/settings/settings-menu.tsx +++ b/src/frontend/settings/settings-menu.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { DEFAULT_SETTINGS } from "../../shared/types"; import { Divider, Group, Text, Title } from "@mantine/core"; import { modals } from "@mantine/modals"; @@ -6,13 +6,14 @@ import { FontWeight } from "../common/style-constants"; import { Dispatch, ReactNode, useMemo } from "react"; import { Theme } from "../../shared/types"; import { hasEditorAccess } from "../../shared/types"; +import { isWithinAccessLevel } from "../../shared/types"; import { AccessLevel } from "../../shared/types"; import { useSaveSettings } from "./settings"; import { capitalize } from "../common/utils"; import { OpenUrlButton } from "../common/open-url-button"; import { RequireAccessLevel, useAccessData } from "../api-utils/access-level"; +import { useUiState } from "../api-utils/ui-state"; import { FEEDBACK_FORM_URL } from "../common/url"; -import { updateAccessData } from "../api-utils/refresh"; import { AppSelect } from "../app-common/app-select"; import { makeSelectOption, useSelectOptions } from "./select-utils"; import { ReloadGroupsButton } from "./reload-groups-button"; @@ -65,19 +66,25 @@ function SettingsMenuContent(): ReactNode { } function UserSettings(): ReactNode { - const search = useSearch({ from: "/app" }); - const navigate = useNavigate({ from: "/app" }); + // The modal renders at the root, outside the route matches, so read the + // location instead of a route-scoped hook and navigate back to the exact + // path — a bare navigate would resolve to the route the `from` names. + const location = useRouterState({ select: (state) => state.location }); + const navigate = useNavigate(); const saveSettings = useSaveSettings(); return ( <> { // The url renders it; the write-behind decides what the // entry redirect seeds next time. saveSettings({ theme }); - void navigate({ search: (prev) => ({ ...prev, theme }) }); + void navigate({ + to: location.pathname, + search: (prev) => ({ ...prev, theme }) + }); }} /> @@ -130,15 +137,18 @@ function AdminSettings(): ReactNode { function AccessLevelSelect(): ReactNode { const accessData = useAccessData(); + const setUiState = useUiState()[1]; const { maxAccessLevel, currentAccessLevel } = accessData; // Use a memo to stabilize access levels so Select's activeItem tracks properly between renders const accessLevels = useSelectOptions( useMemo( () => - maxAccessLevel === AccessLevel.ADMIN - ? [AccessLevel.ADMIN, AccessLevel.EDITOR, AccessLevel.USER] - : [AccessLevel.EDITOR, AccessLevel.USER], + [ + AccessLevel.ADMIN, + AccessLevel.EDITOR, + AccessLevel.USER + ].filter((level) => isWithinAccessLevel(level, maxAccessLevel)), [maxAccessLevel] ), capitalize @@ -150,9 +160,7 @@ function AccessLevelSelect(): ReactNode { option={makeSelectOption(currentAccessLevel, capitalize)} options={accessLevels} onSelect={(value) => { - updateAccessData((data) => { - data.currentAccessLevel = value as AccessLevel; - }); + setUiState({ accessLevel: value as AccessLevel }); }} /> ); diff --git a/src/frontend/settings/settings.ts b/src/frontend/settings/settings.ts index 3aae09615..c2b56cfc8 100644 --- a/src/frontend/settings/settings.ts +++ b/src/frontend/settings/settings.ts @@ -2,12 +2,22 @@ import { useMutation } from "@tanstack/react-query"; import { type SettingsUpdate } from "../../shared/types"; import { showErrorToast } from "../common/notifications"; import { apiPost } from "../api-utils/api"; +import { useIsSignedIn } from "../api-utils/access-level"; +import { writeLocalSettings } from "./local-settings"; export function useSaveSettings() { + const isSignedIn = useIsSignedIn(); + const { mutate } = useMutation({ mutationKey: ["user-data"], - mutationFn: (newSettings: SettingsUpdate) => - apiPost("/user-data", { body: newSettings }), + mutationFn: async (newSettings: SettingsUpdate) => { + // Not signed in: no server-side user row; persist locally instead. + if (!isSignedIn) { + writeLocalSettings(newSettings); + return; + } + return apiPost("/user-data", { body: newSettings }); + }, onError: () => { showErrorToast("Unexpectedly failed to update settings."); } diff --git a/src/frontend/settings/vendor-filters.tsx b/src/frontend/settings/vendor-filters.tsx index 8137cc327..a7648270a 100644 --- a/src/frontend/settings/vendor-filters.tsx +++ b/src/frontend/settings/vendor-filters.tsx @@ -1,6 +1,6 @@ import { ActionIcon, Button, Menu } from "@mantine/core"; import { IconFilter, IconFilterOff } from "@tabler/icons-react"; -import { IconSize, PrimaryColor } from "../common/style-constants"; +import { HEADER_CONTROL_COLOR, IconSize } from "../common/style-constants"; import { ReactNode } from "react"; import { getVendorName } from "../../shared/types"; import { Vendor } from "../../shared/types"; @@ -82,10 +82,12 @@ export function VendorMenu(): ReactNode { - + ); diff --git a/src/frontend/vite-env.d.ts b/src/frontend/vite-env.d.ts new file mode 100644 index 000000000..892a08a5f --- /dev/null +++ b/src/frontend/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + /** Dev-only: the access level the app is viewed as by default. */ + readonly VITE_DEFAULT_ACCESS_LEVEL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/shared/configuration-models.ts b/src/shared/configuration-models.ts index 866c5e36a..fb7115fc9 100644 --- a/src/shared/configuration-models.ts +++ b/src/shared/configuration-models.ts @@ -141,12 +141,17 @@ export interface Configuration { } /** - * Custom data collected from the current tab the user has open. + * The current document's units. Fields are optional: when a unit is absent (not + * connected to a document, or units no longer fetched) each quantity falls back + * to its own default unit. */ export interface UnitInfo { - angleUnit: Unit; - lengthUnit: Unit; - lengthPrecision: number; - anglePrecision: number; - realPrecision: number; + angleUnit?: Unit; + lengthUnit?: Unit; + lengthPrecision?: number; + anglePrecision?: number; + realPrecision?: number; } + +/** No document units available; each quantity falls back to its own unit. */ +export const EMPTY_UNIT_INFO: UnitInfo = {}; diff --git a/src/shared/types.ts b/src/shared/types.ts index f3aba0fc2..c4473f0a2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -18,6 +18,20 @@ export function hasUserAccess(accessLevel: AccessLevel) { return accessLevel === AccessLevel.USER; } +const ACCESS_LEVEL_RANK: Record = { + [AccessLevel.USER]: 0, + [AccessLevel.EDITOR]: 1, + [AccessLevel.ADMIN]: 2 +}; + +/** Whether `accessLevel` grants no more than `maxAccessLevel` does. */ +export function isWithinAccessLevel( + accessLevel: AccessLevel, + maxAccessLevel: AccessLevel +): boolean { + return ACCESS_LEVEL_RANK[accessLevel] <= ACCESS_LEVEL_RANK[maxAccessLevel]; +} + export enum Vendor { AM = "AM", LAI = "LAI", @@ -80,9 +94,13 @@ export interface SettingsUpdate { libraryId?: LibraryId; } +/** + * Server-provided access: the highest level granted plus sign-in state. The + * level the app is currently viewed as is client-side (see useAccessData). + */ export interface AccessData { maxAccessLevel: AccessLevel; - currentAccessLevel: AccessLevel; + signedIn: boolean; } export interface ThumbnailUrls {