Skip to content

Commit aa5223f

Browse files
[Dashboard] feat: Add Engine instance permission handling and UI components
1 parent 89beb3c commit aa5223f

File tree

5 files changed

+314
-12
lines changed

5 files changed

+314
-12
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"use client";
2+
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
3+
import { CopyTextButton } from "@/components/ui/CopyTextButton";
4+
import { Button } from "@/components/ui/button";
5+
import { Separator } from "@/components/ui/separator";
6+
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
7+
import { ArrowLeftIcon, CircleAlertIcon } from "lucide-react";
8+
import Link from "next/link";
9+
import invariant from "tiny-invariant";
10+
import type { EngineInstance } from "../../../../../../../../../@3rdweb-sdk/react/hooks/useEngine";
11+
import { useEngineInstances } from "./useEngineInstances";
12+
import { useHasEnginePermission } from "./useHasEnginePermission";
13+
import { EngineVersionBadge } from "./version";
14+
15+
const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL;
16+
17+
export function WithEngineInstance(props: {
18+
engineId: string;
19+
content: React.FC<{ instance: EngineInstance }>;
20+
teamSlug: string;
21+
twAccount: Account;
22+
authToken: string;
23+
}) {
24+
const rootPath = `/team/${props.teamSlug}/~/engine`;
25+
if (props.engineId === "sandbox") {
26+
invariant(
27+
NEXT_PUBLIC_DEMO_ENGINE_URL,
28+
"missing NEXT_PUBLIC_DEMO_ENGINE_URL",
29+
);
30+
const sandboxEngine: EngineInstance = {
31+
id: "sandbox",
32+
url: NEXT_PUBLIC_DEMO_ENGINE_URL,
33+
name: "Demo Engine",
34+
status: "active",
35+
lastAccessedAt: new Date().toISOString(),
36+
accountId: props.twAccount.id,
37+
};
38+
return (
39+
<RenderEngineInstanceHeader
40+
instance={sandboxEngine}
41+
content={props.content}
42+
rootPath={rootPath}
43+
teamSlug={props.teamSlug}
44+
/>
45+
);
46+
}
47+
return (
48+
<QueryAndRenderInstanceHeader
49+
content={props.content}
50+
engineId={props.engineId}
51+
rootPath={rootPath}
52+
teamSlug={props.teamSlug}
53+
authToken={props.authToken}
54+
/>
55+
);
56+
}
57+
function QueryAndRenderInstanceHeader(props: {
58+
engineId: string;
59+
content: React.FC<{ instance: EngineInstance }>;
60+
rootPath: string;
61+
teamSlug: string;
62+
authToken: string;
63+
}) {
64+
const instancesQuery = useEngineInstances();
65+
const instance = instancesQuery.data?.find((x) => x.id === props.engineId);
66+
if (instancesQuery.isPending) {
67+
return <GenericLoadingPage />;
68+
}
69+
if (!instance) {
70+
return (
71+
<EngineErrorPage rootPath={props.rootPath}>
72+
Engine Instance Not Found
73+
</EngineErrorPage>
74+
);
75+
}
76+
return (
77+
<EnsurePermissionAndRenderInstance
78+
instance={instance}
79+
content={props.content}
80+
rootPath={props.rootPath}
81+
teamSlug={props.teamSlug}
82+
authToken={props.authToken}
83+
/>
84+
);
85+
}
86+
function EnsurePermissionAndRenderInstance(props: {
87+
content: React.FC<{ instance: EngineInstance }>;
88+
instance: EngineInstance;
89+
rootPath: string;
90+
teamSlug: string;
91+
authToken: string;
92+
}) {
93+
const permissionQuery = useHasEnginePermission({
94+
instanceUrl: props.instance.url,
95+
authToken: props.authToken,
96+
});
97+
if (permissionQuery.isPending) {
98+
return <GenericLoadingPage />;
99+
}
100+
if (permissionQuery.error instanceof Error) {
101+
if (permissionQuery.error.message.includes("Failed to fetch")) {
102+
return (
103+
<EngineErrorPage rootPath={props.rootPath}>
104+
<p>Unable to connect to Engine</p>
105+
<p>Ensure that your Engine is publicly accessible</p>
106+
</EngineErrorPage>
107+
);
108+
}
109+
return (
110+
<EngineErrorPage rootPath={props.rootPath}>
111+
<p>There was an unexpected error reaching your Engine instance</p>
112+
<p>Try again or contact us if this issue persists.</p>
113+
</EngineErrorPage>
114+
);
115+
}
116+
if (
117+
permissionQuery.data &&
118+
permissionQuery.data.hasPermission === false &&
119+
permissionQuery.data.reason === "Unauthorized"
120+
) {
121+
return (
122+
<div>
123+
You are not an admin for this Engine instance. Contact the owner to add
124+
your wallet as an admin
125+
</div>
126+
);
127+
}
128+
return (
129+
<RenderEngineInstanceHeader
130+
rootPath={props.rootPath}
131+
instance={props.instance}
132+
content={props.content}
133+
teamSlug={props.teamSlug}
134+
/>
135+
);
136+
}
137+
function RenderEngineInstanceHeader(props: {
138+
instance: EngineInstance;
139+
content: React.FC<{ instance: EngineInstance }>;
140+
rootPath: string;
141+
teamSlug: string;
142+
}) {
143+
const { instance } = props;
144+
return (
145+
<div>
146+
<div className="flex">
147+
<Button
148+
variant="ghost"
149+
className="-translate-x-2 flex h-auto items-center gap-2 px-2 py-1 text-muted-foreground hover:text-foreground"
150+
>
151+
<Link
152+
href={props.rootPath}
153+
aria-label="Go Back"
154+
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
155+
>
156+
<ArrowLeftIcon className="size-4" /> Back
157+
</Link>
158+
</Button>
159+
</div>
160+
<div className="h-5" />
161+
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
162+
<div>
163+
<h1 className="font-semibold text-3xl tracking-tighter md:text-5xl">
164+
{instance.name}
165+
</h1>
166+
<div className="h-1" />
167+
<div className="flex items-center gap-3">
168+
{!instance.name.startsWith("https://") && (
169+
<CopyTextButton
170+
copyIconPosition="right"
171+
textToShow={instance.url}
172+
textToCopy={instance.url}
173+
tooltip="Copy Engine URL"
174+
variant="ghost"
175+
className="-translate-x-2 h-auto px-2 py-1 text-muted-foreground"
176+
/>
177+
)}
178+
</div>
179+
</div>
180+
<EngineVersionBadge instance={instance} teamSlug={props.teamSlug} />
181+
</div>
182+
<div className="h-5" />
183+
<Separator />
184+
<div className="h-10 " />
185+
<props.content instance={instance} />
186+
</div>
187+
);
188+
}
189+
function EngineErrorPage(props: {
190+
children: React.ReactNode;
191+
rootPath: string;
192+
}) {
193+
return (
194+
<div className="flex grow flex-col">
195+
<Link
196+
href={props.rootPath}
197+
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
198+
>
199+
<ArrowLeftIcon className="size-5" />
200+
Back
201+
</Link>
202+
<div className="mt-5 flex min-h-[300px] grow flex-col items-center justify-center rounded-lg border border-border px-4 lg:min-h-[400px]">
203+
<div className="flex flex-col items-center gap-4">
204+
<CircleAlertIcon className="size-16 text-destructive-text" />
205+
<div className="text-center text-muted-foreground">
206+
{props.children}
207+
</div>
208+
</div>
209+
</div>
210+
</div>
211+
);
212+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useActiveAccount } from "thirdweb/react";
3+
import { apiServerProxy } from "../../../../../../../../../@/actions/proxies";
4+
import { engineKeys } from "../../../../../../../../../@3rdweb-sdk/react/cache-keys";
5+
import type { EngineInstance } from "../../../../../../../../../@3rdweb-sdk/react/hooks/useEngine";
6+
7+
export function useEngineInstances() {
8+
const address = useActiveAccount()?.address;
9+
return useQuery({
10+
queryKey: engineKeys.instances(address || ""),
11+
queryFn: async (): Promise<EngineInstance[]> => {
12+
type Result = {
13+
data?: {
14+
instances: EngineInstance[];
15+
};
16+
};
17+
const res = await apiServerProxy<Result>({
18+
pathname: "/v1/engine",
19+
method: "GET",
20+
});
21+
if (!res.ok) {
22+
throw new Error(res.error);
23+
}
24+
const json = res.data;
25+
const instances = json.data?.instances || [];
26+
return instances.map((instance) => {
27+
// Sanitize: Add trailing slash if not present.
28+
const url = instance.url.endsWith("/")
29+
? instance.url
30+
: `${instance.url}/`;
31+
return {
32+
...instance,
33+
url,
34+
};
35+
});
36+
},
37+
enabled: !!address,
38+
});
39+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
// If it fails to fetch, the server is unreachable.
5+
// If it returns a 401, the user is not a valid admin.
6+
export function useHasEnginePermission(props: {
7+
instanceUrl: string;
8+
authToken: string;
9+
}) {
10+
const { authToken, instanceUrl } = props;
11+
return useQuery({
12+
queryKey: ["auth/permissions/get-all", instanceUrl, authToken],
13+
queryFn: async () => {
14+
const res = await fetch(`${instanceUrl}auth/permissions/get-all`, {
15+
method: "GET",
16+
headers: {
17+
"Content-Type": "application/json",
18+
Authorization: `Bearer ${authToken}`,
19+
},
20+
});
21+
if (res.ok) {
22+
return {
23+
hasPermission: true,
24+
};
25+
}
26+
if (res.status === 401) {
27+
return {
28+
hasPermission: false,
29+
reason: "Unauthorized",
30+
};
31+
}
32+
throw new Error("Unexpected status code");
33+
},
34+
retry: false,
35+
});
36+
}

apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/layout.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,15 @@ function EngineInstanceLayoutContent(props: {
8989
);
9090
}
9191

92-
if (permission.status === 401 || permission.status === 500) {
92+
if (permission.status === 500) {
93+
return (
94+
<EngineErrorPage rootPath={rootPath}>
95+
<p> Engine Instance Could Not Be Reached </p>
96+
</EngineErrorPage>
97+
);
98+
}
99+
100+
if (permission.status === 401) {
93101
return (
94102
<EngineErrorPage rootPath={rootPath}>
95103
<div>

apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/_utils/getEngineAccessPermission.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@ export async function getEngineAccessPermission(params: {
22
authToken: string;
33
instanceUrl: string;
44
}) {
5-
const res = await fetch(`${params.instanceUrl}auth/permissions/get-all`, {
6-
method: "GET",
7-
headers: {
8-
"Content-Type": "application/json",
9-
Authorization: `Bearer ${params.authToken}`,
10-
},
11-
});
5+
try {
6+
const res = await fetch(`${params.instanceUrl}auth/permissions/get-all`, {
7+
method: "GET",
8+
headers: {
9+
"Content-Type": "application/json",
10+
Authorization: `Bearer ${params.authToken}`,
11+
},
12+
});
1213

13-
return {
14-
ok: res.ok, // has access if this is true
15-
status: res.status,
16-
};
14+
return {
15+
ok: res.ok, // has access if this is true
16+
status: res.status,
17+
};
18+
} catch {
19+
return {
20+
ok: false,
21+
status: 500,
22+
};
23+
}
1724
}

0 commit comments

Comments
 (0)