From a55f69ccb2551e9f5fdd6e08d0f6c7bf010b1abd Mon Sep 17 00:00:00 2001 From: Jeff Davidson Date: Wed, 1 Jan 2025 22:36:18 -0800 Subject: [PATCH] Expose API key on profile page. Allows users to (re)generate and copy-paste a key for external use. --- imports/client/components/OwnProfilePage.tsx | 80 ++++++++++++++++++- imports/client/components/ProfilePage.tsx | 14 +++- imports/{server => lib}/models/APIKeys.ts | 8 +- imports/lib/publications/apiKeysForSelf.ts | 3 + imports/server/api/authenticator.ts | 2 +- imports/server/ensureAPIKey.ts | 2 +- imports/server/methods/rollAPIKey.ts | 2 +- .../migrations/51-backfill-updated-at.ts | 2 +- imports/server/publications/apiKeysForSelf.ts | 13 +++ imports/server/publications/index.ts | 1 + 10 files changed, 116 insertions(+), 11 deletions(-) rename imports/{server => lib}/models/APIKeys.ts (57%) create mode 100644 imports/lib/publications/apiKeysForSelf.ts create mode 100644 imports/server/publications/apiKeysForSelf.ts diff --git a/imports/client/components/OwnProfilePage.tsx b/imports/client/components/OwnProfilePage.tsx index 1c1790cb4..790973d6b 100644 --- a/imports/client/components/OwnProfilePage.tsx +++ b/imports/client/components/OwnProfilePage.tsx @@ -12,10 +12,13 @@ import FormControl from "react-bootstrap/FormControl"; import FormGroup from "react-bootstrap/FormGroup"; import FormLabel from "react-bootstrap/FormLabel"; import FormText from "react-bootstrap/FormText"; +import InputGroup from "react-bootstrap/InputGroup"; +import CopyToClipboard from "react-copy-to-clipboard"; import Flags from "../../Flags"; import { formatDiscordName } from "../../lib/discord"; import linkUserDiscordAccount from "../../methods/linkUserDiscordAccount"; import linkUserGoogleAccount from "../../methods/linkUserGoogleAccount"; +import rollAPIKey from "../../methods/rollAPIKey"; import unlinkUserDiscordAccount from "../../methods/unlinkUserDiscordAccount"; import unlinkUserGoogleAccount from "../../methods/unlinkUserGoogleAccount"; import updateProfile from "../../methods/updateProfile"; @@ -292,7 +295,13 @@ enum OwnProfilePageSubmitState { ERROR = "error", } -const OwnProfilePage = ({ initialUser }: { initialUser: Meteor.User }) => { +const OwnProfilePage = ({ + initialUser, + initialAPIKey, +}: { + initialUser: Meteor.User; + initialAPIKey?: string; +}) => { const [displayName, setDisplayName] = useState( initialUser.displayName ?? "", ); @@ -306,6 +315,9 @@ const OwnProfilePage = ({ initialUser }: { initialUser: Meteor.User }) => { OwnProfilePageSubmitState.IDLE, ); const [submitError, setSubmitError] = useState(""); + const [showAPIKey, setShowAPIKey] = useState(false); + const [regeneratingAPIKey, setRegeneratingAPIKey] = useState(false); + const [APIKeyError, setAPIKeyError] = useState(); const handleDisplayNameFieldChange: NonNullable< FormControlProps["onChange"] @@ -358,6 +370,27 @@ const OwnProfilePage = ({ initialUser }: { initialUser: Meteor.User }) => { setSubmitState(OwnProfilePageSubmitState.IDLE); }, []); + const toggleShowAPIKey = useCallback(() => { + setShowAPIKey(!showAPIKey); + }, [showAPIKey]); + + const regenerateAPIKey = useCallback(() => { + setRegeneratingAPIKey(true); + setAPIKeyError(""); + rollAPIKey.call({}, (error) => { + if (error) { + setAPIKeyError(error.message); + } else { + setAPIKeyError(""); + } + setRegeneratingAPIKey(false); + }); + }, []); + + const dismissAPIKeyAlert = useCallback(() => { + setAPIKeyError(""); + }, []); + const shouldDisableForm = submitState === "submitting"; return ( @@ -452,6 +485,51 @@ const OwnProfilePage = ({ initialUser }: { initialUser: Meteor.User }) => { + +
+

Advanced

+ + API key + + + + + + + + + + Authorization credential used to make API calls. Keep this secret! + + {APIKeyError ? ( + + Key generation failed: {APIKeyError} + + ) : null} + +
); }; diff --git a/imports/client/components/ProfilePage.tsx b/imports/client/components/ProfilePage.tsx index d207989bf..895966198 100644 --- a/imports/client/components/ProfilePage.tsx +++ b/imports/client/components/ProfilePage.tsx @@ -2,8 +2,11 @@ import { Meteor } from "meteor/meteor"; import { useSubscribe, useTracker } from "meteor/react-meteor-data"; import React from "react"; import { Navigate, useParams } from "react-router-dom"; +import APIKeys from "../../lib/models/APIKeys"; import MeteorUsers from "../../lib/models/MeteorUsers"; +import apiKeysForSelf from "../../lib/publications/apiKeysForSelf"; import { useBreadcrumb } from "../hooks/breadcrumb"; +import useTypedSubscribe from "../hooks/useTypedSubscribe"; import OthersProfilePage from "./OthersProfilePage"; import OwnProfilePage from "./OwnProfilePage"; @@ -17,7 +20,14 @@ const ResolvedProfilePage = ({ const huntId = useParams<"huntId">().huntId; const profileLoading = useSubscribe("profile", userId); - const loading = profileLoading(); + + const apiKeyLoading = useTypedSubscribe(apiKeysForSelf); + + const apiKey = useTracker(() => { + return isSelf ? APIKeys.findOne()?.key : undefined; + }, [isSelf]); + + const loading = profileLoading() || apiKeyLoading(); const user = useTracker(() => { return loading ? undefined : MeteorUsers.findOne(userId); @@ -33,7 +43,7 @@ const ResolvedProfilePage = ({ } else if (!user) { return
{`No user ${userId} found.`}
; } else if (isSelf) { - return ; + return ; } return ; diff --git a/imports/server/models/APIKeys.ts b/imports/lib/models/APIKeys.ts similarity index 57% rename from imports/server/models/APIKeys.ts rename to imports/lib/models/APIKeys.ts index a200f9239..7d74cdc4b 100644 --- a/imports/server/models/APIKeys.ts +++ b/imports/lib/models/APIKeys.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import type { ModelType } from "../../lib/models/Model"; -import SoftDeletedModel from "../../lib/models/SoftDeletedModel"; -import { foreignKey } from "../../lib/models/customTypes"; -import withCommon from "../../lib/models/withCommon"; +import type { ModelType } from "./Model"; +import SoftDeletedModel from "./SoftDeletedModel"; +import { foreignKey } from "./customTypes"; +import withCommon from "./withCommon"; const APIKey = withCommon( z.object({ diff --git a/imports/lib/publications/apiKeysForSelf.ts b/imports/lib/publications/apiKeysForSelf.ts new file mode 100644 index 000000000..a62aede95 --- /dev/null +++ b/imports/lib/publications/apiKeysForSelf.ts @@ -0,0 +1,3 @@ +import TypedPublication from "./TypedPublication"; + +export default new TypedPublication("APIKeys.publications.forSelf"); diff --git a/imports/server/api/authenticator.ts b/imports/server/api/authenticator.ts index 337d0c01a..65254bea6 100644 --- a/imports/server/api/authenticator.ts +++ b/imports/server/api/authenticator.ts @@ -1,6 +1,6 @@ import type express from "express"; +import APIKeys from "../../lib/models/APIKeys"; import expressAsyncWrapper from "../expressAsyncWrapper"; -import APIKeys from "../models/APIKeys"; const authenticator: express.Handler = expressAsyncWrapper( async (req, res, next) => { diff --git a/imports/server/ensureAPIKey.ts b/imports/server/ensureAPIKey.ts index afd399231..674441231 100644 --- a/imports/server/ensureAPIKey.ts +++ b/imports/server/ensureAPIKey.ts @@ -1,6 +1,6 @@ import { Random } from "meteor/random"; import Logger from "../Logger"; -import APIKeys from "./models/APIKeys"; +import APIKeys from "../lib/models/APIKeys"; import userForKeyOperation from "./userForKeyOperation"; import withLock from "./withLock"; diff --git a/imports/server/methods/rollAPIKey.ts b/imports/server/methods/rollAPIKey.ts index 9493b4123..9e9e259e0 100644 --- a/imports/server/methods/rollAPIKey.ts +++ b/imports/server/methods/rollAPIKey.ts @@ -1,8 +1,8 @@ import { check, Match } from "meteor/check"; import Logger from "../../Logger"; +import APIKeys from "../../lib/models/APIKeys"; import rollAPIKey from "../../methods/rollAPIKey"; import ensureAPIKey from "../ensureAPIKey"; -import APIKeys from "../models/APIKeys"; import userForKeyOperation from "../userForKeyOperation"; import defineMethod from "./defineMethod"; diff --git a/imports/server/migrations/51-backfill-updated-at.ts b/imports/server/migrations/51-backfill-updated-at.ts index de9281a4b..a4ca3f3d1 100644 --- a/imports/server/migrations/51-backfill-updated-at.ts +++ b/imports/server/migrations/51-backfill-updated-at.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import APIKeys from "../../lib/models/APIKeys"; import Announcements from "../../lib/models/Announcements"; import ChatMessages from "../../lib/models/ChatMessages"; import ChatNotifications from "../../lib/models/ChatNotifications"; @@ -31,7 +32,6 @@ import Routers from "../../lib/models/mediasoup/Routers"; import TransportRequests from "../../lib/models/mediasoup/TransportRequests"; import TransportStates from "../../lib/models/mediasoup/TransportStates"; import Transports from "../../lib/models/mediasoup/Transports"; -import APIKeys from "../models/APIKeys"; import LatestDeploymentTimestamps from "../models/LatestDeploymentTimestamps"; import Subscribers from "../models/Subscribers"; import UploadTokens from "../models/UploadTokens"; diff --git a/imports/server/publications/apiKeysForSelf.ts b/imports/server/publications/apiKeysForSelf.ts new file mode 100644 index 000000000..b6da3b69f --- /dev/null +++ b/imports/server/publications/apiKeysForSelf.ts @@ -0,0 +1,13 @@ +import APIKeys from "../../lib/models/APIKeys"; +import apiKeysForSelf from "../../lib/publications/apiKeysForSelf"; +import definePublication from "./definePublication"; + +definePublication(apiKeysForSelf, { + run() { + if (!this.userId) { + return []; + } + + return APIKeys.find({ user: this.userId }); + }, +}); diff --git a/imports/server/publications/index.ts b/imports/server/publications/index.ts index 64e3aaa65..7a9aacd96 100644 --- a/imports/server/publications/index.ts +++ b/imports/server/publications/index.ts @@ -1,4 +1,5 @@ import "./announcementsForAnnouncementsPage"; +import "./apiKeysForSelf"; import "./blobMappingsAll"; import "./bookmarkNotificationsForSelf"; import "./chatMessagesForFirehose";