Skip to content
Merged
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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ OAUTH_CLIENT_ID=<Your OAuth client id>
OAUTH_CLIENT_SECRET=<Your 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
Expand Down Expand Up @@ -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=<cert-dump>.sql
npx wrangler r2 object put frc-design-app-dev-thumbnails/thumbnails/<size>/<elementId> --file=<thumb>.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
Expand Down
11 changes: 10 additions & 1 deletion src/__test_utils__/test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions src/backend/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ export interface AppBindings {
ADD_GROUP_WORKFLOW: Workflow<AddGroupParams>;
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<OAuthApi>;
getUserId: () => Promise<string>;
Expand Down
12 changes: 9 additions & 3 deletions src/backend/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -135,7 +137,7 @@ authRoutes.get("/callback", async (c) => {
export async function doSignIn(
c: AppContext,
redirectUrl: string,
companyId: string
companyId?: string
): Promise<string> {
const oauthClient = getOauthClient();

Expand All @@ -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();
}

Expand Down
182 changes: 101 additions & 81 deletions src/backend/routes/favorites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -46,6 +47,7 @@ async function getFavorites(
*/
favoriteRoutes.get(
"/favorites" + libraryRoute(),
requireSignInMiddleware,
cacheMiddleware(),
async (c) => {
const userId = await c.var.getUserId();
Expand All @@ -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 });
}
);
3 changes: 3 additions & 0 deletions src/backend/routes/insertables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading