Skip to content

Commit 965933a

Browse files
committed
Allow generating and re-generating tokens from the new UI
1 parent fef403a commit 965933a

8 files changed

Lines changed: 227 additions & 13 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { graphql, useFragment, useMutation } from "react-relay";
2+
3+
import useSnackbarErrorHandlers from "@/_lib/useSnackbarErrorHandlers";
4+
import SquareButton from "@/_components/_actions/SquareButton";
5+
import { RegenerateTokenButtonMutation } from "./__generated__/RegenerateTokenButtonMutation.graphql";
6+
import { RegenerateTokenButton_viewer$key } from "./__generated__/RegenerateTokenButton_viewer.graphql";
7+
8+
interface RegenerateTokenButtonProps {
9+
viewer: RegenerateTokenButton_viewer$key;
10+
outerClassName?: string;
11+
}
12+
13+
export default function RegenerateTokenButton({
14+
viewer,
15+
outerClassName,
16+
}: RegenerateTokenButtonProps) {
17+
const data = useFragment(
18+
graphql`
19+
fragment RegenerateTokenButton_viewer on Viewer {
20+
apiToken
21+
}
22+
`,
23+
viewer,
24+
);
25+
const hasToken = Boolean(data.apiToken);
26+
27+
const [regenerateToken, regenerating] =
28+
useMutation<RegenerateTokenButtonMutation>(
29+
graphql`
30+
mutation RegenerateTokenButtonMutation {
31+
regenerateApiToken {
32+
viewer {
33+
...TokenReveal_viewer
34+
}
35+
errors {
36+
messages
37+
field
38+
}
39+
}
40+
}
41+
`,
42+
);
43+
44+
const { onCompleted, onError } = useSnackbarErrorHandlers(
45+
"regenerateApiToken",
46+
hasToken
47+
? "API token regenerated successfully!"
48+
: "API token generated successfully!",
49+
);
50+
51+
const handleRegenerate = () => {
52+
if (
53+
hasToken &&
54+
!window.confirm(
55+
"Regenerate your API token? Your current token will stop working immediately.",
56+
)
57+
) {
58+
return;
59+
}
60+
regenerateToken({
61+
variables: {},
62+
onCompleted: (response, errors) => {
63+
onCompleted(response, errors);
64+
},
65+
onError,
66+
});
67+
};
68+
69+
return (
70+
<SquareButton
71+
onClick={handleRegenerate}
72+
isLoading={regenerating}
73+
disabled={regenerating}
74+
text={hasToken ? "Regenerate" : "Generate"}
75+
outerClassName={outerClassName}
76+
/>
77+
);
78+
}

aiarena/frontend-spa/src/_components/_actions/TokenReveal.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,49 @@ import {
55
EyeSlashIcon,
66
} from "@heroicons/react/20/solid";
77
import { useSnackbar } from "notistack";
8+
import { graphql, useFragment } from "react-relay";
9+
10+
import { TokenReveal_viewer$key } from "./__generated__/TokenReveal_viewer.graphql";
811

912
interface TokenRevealProps {
10-
token: string | null | undefined;
13+
viewer: TokenReveal_viewer$key;
1114
className?: string;
1215
}
1316

1417
const MASK = "•••••••••••••••••••••••••••••••••••••••";
1518

16-
export default function TokenReveal({ token, className }: TokenRevealProps) {
19+
export default function TokenReveal({ viewer, className }: TokenRevealProps) {
1720
const { enqueueSnackbar } = useSnackbar();
1821
const [visible, setVisible] = useState(false);
1922

23+
const data = useFragment(
24+
graphql`
25+
fragment TokenReveal_viewer on Viewer {
26+
apiToken
27+
}
28+
`,
29+
viewer,
30+
);
31+
const token = data.apiToken;
32+
2033
const handleCopy = () => {
2134
navigator.clipboard.writeText(token ?? "");
2235
enqueueSnackbar("API token copied to clipboard!");
2336
};
2437

38+
if (!token) {
39+
return (
40+
<div
41+
className={
42+
"flex items-center bg-black text-gray-400 px-2 py-1 rounded font-mono text-xs italic " +
43+
(className ?? "")
44+
}
45+
>
46+
No API token yet.
47+
</div>
48+
);
49+
}
50+
2551
return (
2652
<div
2753
className={

aiarena/frontend-spa/src/_pages/Developers/Developers.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default function Developers() {
88
graphql`
99
query DevelopersQuery {
1010
viewer {
11-
apiToken
11+
...DevelopersContent_viewer
1212
user {
1313
username
1414
}
@@ -29,7 +29,7 @@ export default function Developers() {
2929

3030
return (
3131
<DevelopersContent
32-
apiToken={data.viewer?.apiToken ?? null}
32+
viewer={data.viewer ?? null}
3333
isLoggedIn={Boolean(data.viewer?.user)}
3434
sampleRoundId={sampleRoundId}
3535
/>

aiarena/frontend-spa/src/_pages/Developers/DevelopersContent.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { useMemo, useState } from "react";
2+
import { graphql, useFragment } from "react-relay";
23

4+
import { DevelopersContent_viewer$key } from "./__generated__/DevelopersContent_viewer.graphql";
35
import WrappedTitle from "@/_components/_display/WrappedTitle";
46
import CodeBlock from "@/_components/_display/CodeBlock";
57
import TokenReveal from "@/_components/_actions/TokenReveal";
8+
import RegenerateTokenButton from "@/_components/_actions/RegenerateTokenButton";
69
import TabNav from "@/_components/_nav/TabNav";
710
import SquareButton from "@/_components/_actions/SquareButton";
811
import LanguagePicker from "@/_components/_actions/LanguagePicker";
@@ -251,21 +254,32 @@ function playgroundUrl(example: Example): string {
251254
}
252255

253256
interface DevelopersContentProps {
254-
apiToken: string | null;
257+
viewer: DevelopersContent_viewer$key | null;
255258
isLoggedIn: boolean;
256259
sampleRoundId: string | null;
257260
}
258261

259262
export default function DevelopersContent({
260-
apiToken,
263+
viewer,
261264
isLoggedIn,
262265
sampleRoundId,
263266
}: DevelopersContentProps) {
267+
const data = useFragment(
268+
graphql`
269+
fragment DevelopersContent_viewer on Viewer {
270+
apiToken
271+
...TokenReveal_viewer
272+
...RegenerateTokenButton_viewer
273+
}
274+
`,
275+
viewer,
276+
);
277+
264278
const examples = useMemo(() => buildExamples(sampleRoundId), [sampleRoundId]);
265279
const [activeExample, setActiveExample] = useState(examples[0].name);
266280

267281
const example = examples.find((e) => e.name === activeExample) ?? examples[0];
268-
const tokenForSnippet = apiToken ?? TOKEN_PLACEHOLDER;
282+
const tokenForSnippet = data?.apiToken ?? TOKEN_PLACEHOLDER;
269283

270284
return (
271285
<div className="max-w-5xl mx-auto">
@@ -299,9 +313,10 @@ export default function DevelopersContent({
299313

300314
<div className="bg-darken-2 border border-neutral-600 rounded-md p-4 mb-8">
301315
<h3 className="text-base font-semibold mb-2">Your API token</h3>
302-
{isLoggedIn ? (
316+
{isLoggedIn && data ? (
303317
<>
304-
<TokenReveal token={apiToken} />
318+
<TokenReveal viewer={data} />
319+
<RegenerateTokenButton viewer={data} outerClassName="mt-2" />
305320
<p className="text-xs text-gray-400 mt-2">
306321
For non-browser clients (scripts, servers, bots), send it as the{" "}
307322
<span className="font-mono">Authorization: Token &lt;key&gt;</span>{" "}

aiarena/frontend-spa/src/_pages/UserSettings/UserSettingsSection.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import LoadingSpinner from "@/_components/_display/LoadingSpinnerGray";
1212
import useStateWithLocalStorage from "@/_components/_hooks/useStateWithLocalStorage";
1313
import SimpleToggle from "@/_components/_actions/_toggle/SimpleToggle";
1414
import TokenReveal from "@/_components/_actions/TokenReveal";
15+
import RegenerateTokenButton from "@/_components/_actions/RegenerateTokenButton";
1516
import SquareButton from "@/_components/_actions/SquareButton";
1617
interface UserSettingsSectionProps {
1718
viewer: UserSettingsSection_viewer$key;
@@ -21,7 +22,8 @@ export default function UserSettingsSection(props: UserSettingsSectionProps) {
2122
const viewer = useFragment(
2223
graphql`
2324
fragment UserSettingsSection_viewer on Viewer {
24-
apiToken
25+
...TokenReveal_viewer
26+
...RegenerateTokenButton_viewer
2527
receiveEmailComms
2628
lastLogin
2729
dateJoined
@@ -222,7 +224,9 @@ export default function UserSettingsSection(props: UserSettingsSectionProps) {
222224
<div className="bg-darken-2 border border-neutral-600 shadow-lg shadow-black rounded-md backdrop-blur-sm">
223225
<h3 className="mt-1 ml-2 text-base font-semibold ">API Token</h3>
224226
<div className=" p-4">
225-
<TokenReveal token={viewer.apiToken} />
227+
<TokenReveal viewer={viewer} />
228+
229+
<RegenerateTokenButton viewer={viewer} outerClassName="mt-3" />
226230

227231
<p className="text-sm text-gray-300 mt-3">
228232
For deeper queries, fewer requests, and an interactive

aiarena/graphql/mutations.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from graphene_django.types import ErrorType
1717
from graphene_file_upload.scalars import Upload
1818
from graphql import GraphQLError
19+
from rest_framework.authtoken.models import Token
1920

2021
from aiarena.api.arenaclient.common.ac_coordinator import ACCoordinator
2122
from aiarena.api.arenaclient.common.exceptions import LadderDisabled
@@ -46,6 +47,7 @@
4647
ResultType,
4748
TemporaryUploadID,
4849
TemporaryUploadType,
50+
Viewer,
4951
)
5052

5153

@@ -334,6 +336,28 @@ def mutate(self, info: graphene.ResolveInfo) -> "SignOut":
334336
return SignOut(errors=[])
335337

336338

339+
class RegenerateApiToken(graphene.Mutation):
340+
errors = graphene.List(ErrorType)
341+
viewer = graphene.Field(Viewer)
342+
343+
def mutate(self, info: graphene.ResolveInfo) -> "RegenerateApiToken":
344+
if not info.context.user.is_authenticated:
345+
return RegenerateApiToken(
346+
errors=[
347+
ErrorType(
348+
field="__all__",
349+
messages=["You are not signed in"],
350+
)
351+
]
352+
)
353+
354+
with transaction.atomic():
355+
Token.objects.filter(user=info.context.user).delete()
356+
Token.objects.create(user=info.context.user)
357+
358+
return RegenerateApiToken(errors=[], viewer=info.context.user)
359+
360+
337361
# =============================================================================
338362
# Arena Client Mutations
339363
# =============================================================================
@@ -558,6 +582,7 @@ class Mutation(graphene.ObjectType):
558582
update_competition_participation = UpdateCompetitionParticipation.Field()
559583
password_sign_in = PasswordSignIn.Field()
560584
sign_out = SignOut.Field()
585+
regenerate_api_token = RegenerateApiToken.Field()
561586

562587
# Arena Client mutations
563588
request_upload_urls = RequestUploadUrls.Field()

aiarena/graphql/tests/test_mutations.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.conf import settings
22

33
import pytest
4+
from rest_framework.authtoken.models import Token
45

56
from aiarena.core.models import Bot, CompetitionParticipation, Match, MatchParticipation
67
from aiarena.core.tests.base import GraphQLTest
@@ -895,3 +896,68 @@ def test_cannot_specify_attributes_not_in_input(self, user, bot):
895896
# Verify bot was not updated
896897
bot.refresh_from_db()
897898
assert bot.bot_zip_publicly_downloadable != "new name pls"
899+
900+
901+
class TestRegenerateApiToken(GraphQLTest):
902+
mutation_name = "regenerateApiToken"
903+
mutation = """
904+
mutation {
905+
regenerateApiToken {
906+
viewer {
907+
apiToken
908+
}
909+
errors {
910+
messages
911+
field
912+
}
913+
}
914+
}
915+
"""
916+
917+
def test_generates_token_when_missing(self, user):
918+
"""A user with no token gets one created, and it's returned to them."""
919+
assert not Token.objects.filter(user=user).exists()
920+
921+
response = self.mutate(login_user=user, expected_status=200)
922+
923+
token = Token.objects.get(user=user)
924+
returned_token = response["regenerateApiToken"]["viewer"]["apiToken"]
925+
assert returned_token == token.key
926+
927+
def test_regenerates_existing_token(self, user):
928+
"""Regenerating replaces the existing token: old key gone, new key differs, still exactly one."""
929+
old_token = Token.objects.create(user=user)
930+
old_key = old_token.key
931+
932+
response = self.mutate(login_user=user, expected_status=200)
933+
934+
assert Token.objects.filter(user=user).count() == 1
935+
new_token = Token.objects.get(user=user)
936+
assert new_token.key != old_key
937+
assert not Token.objects.filter(key=old_key).exists()
938+
assert response["regenerateApiToken"]["viewer"]["apiToken"] == new_token.key
939+
940+
def test_not_logged_in_does_not_create_token(self, user):
941+
"""An unauthenticated caller cannot mint a token."""
942+
assert not Token.objects.filter(user=user).exists()
943+
944+
self.mutate(
945+
expected_status=200,
946+
expected_validation_errors={"__all__": ["You are not signed in"]},
947+
)
948+
949+
assert not Token.objects.exists()
950+
951+
def test_only_affects_own_token(self, user, other_user):
952+
"""Regenerating one user's token must not touch another user's token."""
953+
other_token = Token.objects.create(user=other_user)
954+
other_key = other_token.key
955+
956+
self.mutate(login_user=user, expected_status=200)
957+
958+
# The caller got a fresh token...
959+
assert Token.objects.filter(user=user).exists()
960+
# ...and the other user's token is completely untouched.
961+
other_token.refresh_from_db()
962+
assert other_token.key == other_key
963+
assert Token.objects.filter(user=other_user).count() == 1

aiarena/graphql/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,9 +1389,9 @@ def resolve_api_token(root: models.User, info):
13891389
try:
13901390
token = Token.objects.get(user=info.context.user)
13911391
except Token.DoesNotExist:
1392-
token = "no token - contact us"
1392+
return None
13931393

1394-
return token
1394+
return token.key
13951395

13961396
@staticmethod
13971397
def resolve_email(root: models.User, info):

0 commit comments

Comments
 (0)