From 74d6f38f377aa68b939b7ecffac336ec8ecd0ee3 Mon Sep 17 00:00:00 2001 From: andhreljaKern Date: Thu, 30 Apr 2026 14:04:05 +0200 Subject: [PATCH 1/4] perf: add hydra revoke --- i18n/locales/de/settings.json | 13 ++ i18n/locales/en/settings.json | 13 ++ i18n/locales/nl/settings.json | 13 ++ next.config.js | 7 +- pages/SettingsConnectedApplications.tsx | 151 ++++++++++++++++++++ pages/settings.tsx | 76 +++++++++- pkg/sdk/index.ts | 16 ++- styles/tailwind.css | 60 ++++++++ util/data-fetch.ts | 34 +++++ util/hydra-connected-applications.helper.ts | 66 +++++++++ 10 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 pages/SettingsConnectedApplications.tsx create mode 100644 util/hydra-connected-applications.helper.ts diff --git a/i18n/locales/de/settings.json b/i18n/locales/de/settings.json index 94d92f8..f1dd946 100644 --- a/i18n/locales/de/settings.json +++ b/i18n/locales/de/settings.json @@ -18,5 +18,18 @@ "save": "Speichern", "overview": { "remainingTime": "Verbleibende Zeit vor der automatischen Abmeldung" + }, + "hydraRevoke": { + "title": "Verbundene Anwendungen", + "description": "Apps, die Sie per OAuth2 autorisiert haben. Sie können den Zugriff pro App widerrufen; eine erneute Anmeldung kann nötig sein.", + "loading": "Verbundene Anwendungen werden geladen…", + "empty": "Sie haben keine verbundenen Anwendungen.", + "loadError": "Verbundene Anwendungen konnten nicht geladen werden.", + "noScopes": "Für diese Verbindung sind keine Berechtigungen (Scopes) hinterlegt.", + "revoke": "Zugriff widerrufen", + "revoking": "Wird widerrufen…", + "revokeSuccess": "Der Zugriff für „{{name}}“ wurde widerrufen.", + "revokeError": "Der Zugriff konnte nicht widerrufen werden. Bitte versuchen Sie es erneut.", + "retry": "Erneut versuchen" } } \ No newline at end of file diff --git a/i18n/locales/en/settings.json b/i18n/locales/en/settings.json index d727d67..2c30089 100644 --- a/i18n/locales/en/settings.json +++ b/i18n/locales/en/settings.json @@ -18,5 +18,18 @@ "save": "Save", "overview": { "remainingTime": "Remaining time before auto logout" + }, + "hydraRevoke": { + "title": "Connected applications", + "description": "Apps you authorized with OAuth2. You can revoke access for each app; you may need to sign in again if you use it later.", + "loading": "Loading connected applications…", + "empty": "You have no connected applications.", + "loadError": "Could not load connected applications.", + "noScopes": "No permissions (scopes) recorded for this connection.", + "revoke": "Revoke access", + "revoking": "Revoking…", + "revokeSuccess": "Access for “{{name}}” has been revoked.", + "revokeError": "Could not revoke access. Please try again.", + "retry": "Retry" } } \ No newline at end of file diff --git a/i18n/locales/nl/settings.json b/i18n/locales/nl/settings.json index 0baf998..6945e22 100644 --- a/i18n/locales/nl/settings.json +++ b/i18n/locales/nl/settings.json @@ -18,5 +18,18 @@ "save": "Redden", "overview": { "remainingTime": "Verbleibende tijd voor automatische afmelding" + }, + "hydraRevoke": { + "title": "Gekoppelde applicaties", + "description": "Apps die u met OAuth2 hebt gemachtigd. U kunt de toegang per app intrekken; u moet zich mogelijk later opnieuw aanmelden.", + "loading": "Gekoppelde applicaties laden…", + "empty": "U hebt geen gekoppelde applicaties.", + "loadError": "Gekoppelde applicaties konden niet worden geladen.", + "noScopes": "Geen rechten (scopes) vastgelegd voor deze verbinding.", + "revoke": "Toegang intrekken", + "revoking": "Bezig met intrekken…", + "revokeSuccess": "Toegang voor „{{name}}“ is ingetrokken.", + "revokeError": "Toegang kon niet worden ingetrokken. Probeer het opnieuw.", + "retry": "Opnieuw proberen" } } \ No newline at end of file diff --git a/next.config.js b/next.config.js index 951a8da..22dd8c1 100644 --- a/next.config.js +++ b/next.config.js @@ -4,8 +4,7 @@ const nextConfig = { swcMinify: true, env: { IS_DEV: process.env.IS_DEV, - } -} + }, +}; - -module.exports = nextConfig +module.exports = nextConfig; diff --git a/pages/SettingsConnectedApplications.tsx b/pages/SettingsConnectedApplications.tsx new file mode 100644 index 0000000..9983988 --- /dev/null +++ b/pages/SettingsConnectedApplications.tsx @@ -0,0 +1,151 @@ +import { memo, useCallback } from "react" +import { useTranslation } from "react-i18next" + +import { combineClassNames } from "@/submodules/javascript-functions/general" +import { + oauth2ScopeLabel, + type HydraConnectedApplication, +} from "@/util/hydra-connected-applications.helper" + +export type ConnectedApplicationsLoadState = "idle" | "loading" | "success" | "error" + +export interface SettingsConnectedApplicationsProps { + readonly applications: HydraConnectedApplication[]; + readonly loadState: ConnectedApplicationsLoadState; + readonly loadError: string; + readonly revokingClientId: string | null; + readonly revokeNotice: { type: "success" | "error"; message: string } | null; + readonly onRevoke: (clientId: string, clientName: string) => void; + readonly onRetryLoad: () => void; +} + +interface RowProps { + readonly app: HydraConnectedApplication; + readonly isRevoking: boolean; + readonly onRevoke: (clientId: string, clientName: string) => void; +} + +const SettingsConnectedApplicationRow = memo(function SettingsConnectedApplicationRow({ + app, + isRevoking, + onRevoke, +}: RowProps) { + const { t } = useTranslation("settings") + const clientName = app.clientName + const clientInitial = clientName.charAt(0).toUpperCase() + const handleRevokeClick = useCallback(() => { + onRevoke(app.clientId, clientName) + }, [app.clientId, clientName, onRevoke]) + + return ( +
+
+ + {app.logoUri ? ( + {clientName} + ) : ( + clientInitial + )} + +
+ {clientName} +
+
+ {app.grantedScope.length > 0 ? ( +
+ {app.grantedScope.map(scope => ( + + {oauth2ScopeLabel(scope)} + + ))} +
+ ) : ( +

{t("hydraRevoke.noScopes")}

+ )} +
+ +
+
+ ) +}) + +function SettingsConnectedApplications(props: SettingsConnectedApplicationsProps) { + const { t } = useTranslation("settings") + const { + applications, + loadState, + loadError, + revokingClientId, + revokeNotice, + onRevoke, + onRetryLoad, + } = props + + const handleRetry = useCallback(() => { + onRetryLoad() + }, [onRetryLoad]) + + const showLoading = loadState === "idle" || loadState === "loading" + + return ( +
+

{t("hydraRevoke.title")}

+

{t("hydraRevoke.description")}

+ + {revokeNotice && ( +

+ {revokeNotice.message} +

+ )} + + {showLoading && ( +

{t("hydraRevoke.loading")}

+ )} + + {loadState === "error" && ( +
+

{loadError || t("hydraRevoke.loadError")}

+ +
+ )} + + {loadState === "success" && applications.length === 0 && ( +

{t("hydraRevoke.empty")}

+ )} + + {loadState === "success" && applications.length > 0 && ( +
+ {applications.map(app => ( + + ))} +
+ )} +
+ ) +} + +export default memo(SettingsConnectedApplications) diff --git a/pages/settings.tsx b/pages/settings.tsx index bb2143f..714d72c 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -3,7 +3,7 @@ import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" import { useRouter } from "next/router" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { Flow, Messages } from "../pkg" import { handleFlowError } from "../pkg/errors" @@ -11,7 +11,16 @@ import ory from "../pkg/sdk" import { KernLogo } from "@/pkg/ui/Icons" import { prepareNodes } from "@/util/helper-functions" import { WebSocketsService } from "@/submodules/react-components/hooks/web-socket/WebSocketsService" -import { getAllActiveAdminMessages, getUserInfoExtended } from "@/util/data-fetch" +import { + fetchHydraConnectedApplicationsForCurrentUser, + getAllActiveAdminMessages, + getUserInfoExtended, + revokeHydraOAuth2GrantsForClient, +} from "@/util/data-fetch" +import type { HydraConnectedApplication } from "@/util/hydra-connected-applications.helper" +import SettingsConnectedApplications, { + type ConnectedApplicationsLoadState, +} from "@/pages/SettingsConnectedApplications" import { useWebsocket } from "@/submodules/react-components/hooks/web-socket/useWebsocket" import { Application, CurrentPage } from "@/submodules/react-components/hooks/web-socket/constants" import { AdminMessage } from "@/submodules/react-components/types/admin-messages" @@ -40,6 +49,11 @@ const Settings: NextPage = () => { const [showAuthenticator, setShowAuthenticator] = useState(false); const [loadPage, setLoadPage] = useState(false); const [canShow, setCanShow] = useState(false); + const [connectedApps, setConnectedApps] = useState([]); + const [connectedAppsLoadState, setConnectedAppsLoadState] = useState("idle"); + const [connectedAppsLoadError, setConnectedAppsLoadError] = useState(""); + const [revokingClientId, setRevokingClientId] = useState(null); + const [revokeNotice, setRevokeNotice] = useState<{ type: "success" | "error"; message: string } | null>(null); useEffect(() => { if (loadPage) return; @@ -216,6 +230,52 @@ const Settings: NextPage = () => { } }, [changedFlow, isOidc, isOidcInvitation, showPassword, flowId, isOidc]) + const loadConnectedApplications = useCallback( + async (opts?: { silent?: boolean }) => { + const silent = opts?.silent === true; + if (!silent) { + setConnectedAppsLoadState("loading"); + setConnectedAppsLoadError(""); + } + try { + const list = await fetchHydraConnectedApplicationsForCurrentUser(); + setConnectedApps(list); + setConnectedAppsLoadState("success"); + } catch (e) { + setConnectedAppsLoadState("error"); + setConnectedAppsLoadError( + e instanceof Error && e.message ? e.message : t("hydraRevoke.loadError"), + ); + } + }, + [t], + ); + + useEffect(() => { + if (!canShow) return; + void loadConnectedApplications(); + }, [canShow, loadConnectedApplications]); + + const handleRevokeOAuth2ForClient = useCallback( + async (clientId: string, clientName: string) => { + setRevokingClientId(clientId); + setRevokeNotice(null); + try { + await revokeHydraOAuth2GrantsForClient(clientId); + setRevokeNotice({ type: "success", message: t("hydraRevoke.revokeSuccess", { name: clientName }) }); + await loadConnectedApplications({ silent: true }); + } catch (e) { + setRevokeNotice({ + type: "error", + message: e instanceof Error && e.message ? e.message : t("hydraRevoke.revokeError"), + }); + } finally { + setRevokingClientId(null); + } + }, + [t, loadConnectedApplications], + ); + const onSubmit = (values: UpdateSettingsFlowBody) => ory .updateSettingsFlow({ @@ -328,6 +388,18 @@ const Settings: NextPage = () => { /> ) : (<> )} + {canShow && ( + void loadConnectedApplications()} + /> + )} +