Skip to content

Commit 79ce74e

Browse files
feat: short lived API Tokens (#719)
1 parent d28663e commit 79ce74e

File tree

12 files changed

+630
-85
lines changed

12 files changed

+630
-85
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@
3535
"class-variance-authority": "^0.7.0",
3636
"clsx": "^2.1.1",
3737
"immer": "^10.1.1",
38+
"jwt-decode": "^4.0.0",
3839
"lodash.debounce": "^4.0.8",
3940
"papaparse": "^5.4.1",
4041
"prismjs": "^1.29.0",
4142
"react": "^18.3.1",
43+
"react-countdown": "^2.3.6",
4244
"react-dom": "^18.3.1",
4345
"react-error-boundary": "^4.1.2",
4446
"react-hook-form": "^7.53.1",

pnpm-lock.yaml

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/settings/LayoutSidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export function ServerSettingsMenu() {
3434
name: "Members",
3535
href: routes.settings.members
3636
},
37+
{
38+
name: "API Tokens",
39+
href: routes.settings.apiTokens
40+
},
3741
{
3842
name: "Repositories",
3943
href: routes.settings.repositories.overview
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import Clock from "@/assets/icons/clock.svg?react";
2+
import Copy from "@/assets/icons/copy.svg?react";
3+
import ExternalLink from "@/assets/icons/link-external.svg?react";
4+
import { Codesnippet } from "@/components/CodeSnippet";
5+
import { InfoBox } from "@/components/Infobox";
6+
import { Tick } from "@/components/Tick";
7+
import { routes } from "@/router/routes";
8+
import {
9+
Dialog,
10+
DialogContent,
11+
DialogHeader,
12+
DialogTitle
13+
} from "@zenml-io/react-component-library/components/client";
14+
import { Button } from "@zenml-io/react-component-library/components/server";
15+
import { jwtDecode } from "jwt-decode";
16+
import { useState } from "react";
17+
import Countdown, { CountdownRendererFn } from "react-countdown";
18+
import { ErrorBoundary } from "react-error-boundary";
19+
import { Link } from "react-router-dom";
20+
21+
type Props = {
22+
token: string;
23+
open: boolean;
24+
setOpen(open: boolean): void;
25+
};
26+
export function ApiTokenModal({ token, open, setOpen }: Props) {
27+
return (
28+
<Dialog open={open} onOpenChange={setOpen}>
29+
<DialogContent className="max-w-[700px]">
30+
<DialogHeader>
31+
<DialogTitle>API Token Created Successfully</DialogTitle>
32+
</DialogHeader>
33+
<div className="space-y-3 overflow-hidden px-7 py-5">
34+
<Headline />
35+
<ExpiryInfo />
36+
<CopyToken token={token} />
37+
<ErrorBoundary fallbackRender={() => null}>
38+
<ExpiresIn token={token} />
39+
</ErrorBoundary>
40+
<div
41+
role="separator"
42+
aria-hidden="true"
43+
className="h-[1px] bg-theme-border-moderate"
44+
></div>
45+
<UsingApiToken token={token} />
46+
<ApiDocs />
47+
<div
48+
role="separator"
49+
aria-hidden="true"
50+
className="h-[1px] bg-theme-border-moderate"
51+
></div>
52+
<ServiceAccountLink />
53+
</div>
54+
</DialogContent>
55+
</Dialog>
56+
);
57+
}
58+
59+
function Headline() {
60+
return (
61+
<section>
62+
<p className="font-semibold">Here is your new API Token</p>
63+
<p className="text-theme-text-secondary">
64+
This token provides temporary access to your ZenML Server
65+
</p>
66+
</section>
67+
);
68+
}
69+
70+
function ExpiryInfo() {
71+
return (
72+
<InfoBox>
73+
Important: This token expires in 1 hour and cannot be retrieved later. Please, copy it now.
74+
</InfoBox>
75+
);
76+
}
77+
78+
function CopyToken({ token }: { token: string }) {
79+
const [copied, setCopied] = useState(false);
80+
function copyToClipboard(text: string) {
81+
if (navigator.clipboard) {
82+
navigator.clipboard.writeText(text);
83+
setCopied(true);
84+
setTimeout(() => {
85+
setCopied(false);
86+
}, 2000);
87+
}
88+
}
89+
return (
90+
<section className="flex items-center gap-5 py-5">
91+
<code className="block overflow-x-auto whitespace-nowrap font-sans text-display-xs">
92+
{token}
93+
</code>
94+
{copied ? (
95+
<div className="flex h-7 items-center">
96+
<Tick className="h-5 w-5 shrink-0 fill-theme-text-tertiary" />
97+
<p className="sr-only">copied successfully</p>
98+
</div>
99+
) : (
100+
<Button
101+
onClick={() => copyToClipboard(token)}
102+
size="md"
103+
intent="secondary"
104+
className="flex items-center gap-1"
105+
emphasis="subtle"
106+
>
107+
<Copy className="size-4 shrink-0 fill-inherit" />
108+
Copy
109+
</Button>
110+
)}
111+
</section>
112+
);
113+
}
114+
115+
const renderer: CountdownRendererFn = ({ days, hours, minutes, seconds, completed }) => {
116+
if (completed) {
117+
return <span className="font-semibold text-theme-text-error">Expired</span>;
118+
}
119+
const padWithZero = (num: number) => String(num).padStart(2, "0");
120+
// Build the display parts based on remaining time
121+
const parts = [];
122+
123+
if (days > 0) {
124+
parts.push(`${days}`);
125+
}
126+
127+
if (days > 0 || hours > 0) {
128+
parts.push(`${padWithZero(hours)}`);
129+
}
130+
131+
if (days > 0 || hours > 0 || minutes > 0) {
132+
parts.push(`${padWithZero(minutes)}`);
133+
}
134+
135+
parts.push(`${padWithZero(seconds)}`);
136+
137+
return <span className="font-semibold">{parts.join(":")}</span>;
138+
};
139+
function ExpiresIn({ token }: { token: string }) {
140+
if (!token) return null;
141+
const decoded = jwtDecode(token);
142+
if (!decoded.exp) return null;
143+
return (
144+
<div className="flex flex-wrap items-center justify-center gap-3 rounded-sm border border-theme-border-moderate bg-theme-surface-tertiary py-1 text-center">
145+
<Clock className="size-5 shrink-0 fill-theme-text-secondary" />
146+
<div>
147+
Expires in:{" "}
148+
<Countdown
149+
daysInHours
150+
renderer={renderer}
151+
zeroPadTime={2}
152+
date={new Date(decoded.exp * 1000)}
153+
/>
154+
</div>
155+
</div>
156+
);
157+
}
158+
159+
function UsingApiToken({ token }: { token: string }) {
160+
const myUserEndpoint = `${window.location.origin}/api/v1/current-user`;
161+
162+
return (
163+
<section className="space-y-1">
164+
<div className="font-semibold">Using your API Token</div>
165+
<div className="space-y-2">
166+
<p className="text-theme-text-secondary">
167+
To use the API token to run queries against the Server API, you can run the following
168+
commands:
169+
</p>
170+
<Codesnippet code={getCurlCommand(token, myUserEndpoint)} />
171+
<Codesnippet code={getWgetCommand(token, myUserEndpoint)} />
172+
</div>
173+
</section>
174+
);
175+
}
176+
177+
function ApiDocs() {
178+
const docsUrl = `${window.location.origin}/docs`;
179+
return (
180+
<section className="space-y-2">
181+
<p className="font-semibold">API Documentation</p>
182+
<p className="text-theme-text-secondary">
183+
Access our OpenAPI dashboard for comprehensive documentation on all available REST API
184+
endpoints:
185+
</p>
186+
<Button
187+
asChild
188+
size="md"
189+
intent="secondary"
190+
className="flex w-fit items-center gap-1"
191+
emphasis="subtle"
192+
>
193+
<a target="_blank" rel="noopener noreferrer" href={docsUrl}>
194+
<span>Open the documentation</span>
195+
<ExternalLink className="size-5 shrink-0 fill-inherit" />
196+
</a>
197+
</Button>
198+
</section>
199+
);
200+
}
201+
202+
function ServiceAccountLink() {
203+
return (
204+
<p>
205+
For long-term programmatic access, consider{" "}
206+
<Link className="link text-theme-text-brand" to={routes.settings.service_accounts.overview}>
207+
setting up a service account instead.
208+
</Link>
209+
</p>
210+
);
211+
}
212+
213+
function getCurlCommand(token: string, endpoint: string) {
214+
return `curl -H "Authorization: Bearer ${token}" ${endpoint}`;
215+
}
216+
217+
function getWgetCommand(token: string, endpoint: string) {
218+
return `wget --header="Authorization: Bearer ${token}" ${endpoint}`;
219+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useCreateApiToken } from "@/data/auth/create-api-token";
2+
import { useServerInfo } from "@/data/server/info-query";
3+
import { isFetchError } from "@/lib/fetch-error";
4+
import { isNoAuthServer } from "@/lib/server";
5+
import { cn, useToast } from "@zenml-io/react-component-library";
6+
import { Button, Skeleton } from "@zenml-io/react-component-library/components/server";
7+
import { ComponentPropsWithoutRef, ElementRef, forwardRef, useState } from "react";
8+
import { ApiTokenModal } from "./APITokenModal";
9+
10+
export const CreateTokenButton = forwardRef<
11+
ElementRef<typeof Button>,
12+
ComponentPropsWithoutRef<typeof Button>
13+
>(({ className, onClick, ...rest }, ref) => {
14+
const { toast } = useToast();
15+
const [token, setToken] = useState("");
16+
const [open, setOpen] = useState(false);
17+
const serverInfo = useServerInfo();
18+
19+
const { mutate, isPending } = useCreateApiToken({
20+
onError: (e) => {
21+
if (isFetchError(e)) {
22+
toast({
23+
title: "Error",
24+
emphasis: "subtle",
25+
rounded: true,
26+
status: "error",
27+
description: e.message
28+
});
29+
}
30+
console.log(e);
31+
},
32+
onSuccess: (data) => {
33+
setToken(data);
34+
setOpen(true);
35+
}
36+
});
37+
38+
function closeModal(open: boolean) {
39+
setOpen(open);
40+
if (!open) {
41+
setToken("");
42+
}
43+
}
44+
45+
if (serverInfo.isPending) return <Skeleton className="h-7 w-10" />;
46+
if (serverInfo.isError) return null;
47+
48+
return (
49+
<>
50+
<Button
51+
disabled={isPending || isNoAuthServer(serverInfo.data.auth_scheme || "other")}
52+
ref={ref}
53+
{...rest}
54+
onClick={() => mutate({ params: { token_type: "generic" } })}
55+
className={cn(className)}
56+
>
57+
{isPending && (
58+
<div
59+
role="alert"
60+
aria-busy="true"
61+
className="full h-[20px] w-[20px] animate-spin rounded-rounded border-2 border-theme-text-negative border-b-theme-text-brand"
62+
></div>
63+
)}
64+
Create new token
65+
</Button>
66+
<ApiTokenModal open={open} setOpen={closeModal} token={token} />
67+
</>
68+
);
69+
});
70+
71+
CreateTokenButton.displayName = "CreateTokenButton";

0 commit comments

Comments
 (0)