Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions i18n/locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
13 changes: 13 additions & 0 deletions i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
13 changes: 13 additions & 0 deletions i18n/locales/nl/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
7 changes: 3 additions & 4 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ const nextConfig = {
swcMinify: true,
env: {
IS_DEV: process.env.IS_DEV,
}
}
},
};


module.exports = nextConfig
module.exports = nextConfig;
156 changes: 156 additions & 0 deletions pages/SettingsConnectedApplications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { memo, useCallback } from "react"
import { useTranslation } from "react-i18next"

import { combineClassNames } from "@/submodules/javascript-functions/general"
import {
oauth2ScopeLabel,
HydraConnectedApplication,
} from "@/util/hydra-connected-applications.helper"

export enum ConnectedApplicationsLoadStateEnum {
IDLE = "idle",
LOADING = "loading",
SUCCESS = "success",
ERROR = "error"
}

export interface SettingsConnectedApplicationsProps {
readonly applications: HydraConnectedApplication[];
readonly loadState: ConnectedApplicationsLoadStateEnum;
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 (
<div className="settings-connected-app-card">
<div className={combineClassNames("consent-client-badge", "settings-connected-app-badge")}>
<span
className={combineClassNames(
"client-icon",
app.logoUri && "client-icon--logo",
)}
>
{app.logoUri ? (
<img src={app.logoUri} alt={clientName} className="client-logo" />
) : (
clientInitial
)}
</span>
<div className="client-info">
<span className="client-name">{clientName}</span>
</div>
</div>
{app.grantedScope.length > 0 ? (
<div className="settings-connected-app-scopes">
{app.grantedScope.map(scope => (
<span key={scope} className="settings-scope-chip">
{oauth2ScopeLabel(scope)}
</span>
))}
</div>
) : (
<p className="settings-connected-app-scopes-empty">{t("hydraRevoke.noScopes")}</p>
)}
<div className="settings-connected-app-actions">
<button
type="button"
className="link disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isRevoking}
onClick={handleRevokeClick}
>
{isRevoking ? t("hydraRevoke.revoking") : t("hydraRevoke.revoke")}
</button>
</div>
</div>
)
})

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 === ConnectedApplicationsLoadStateEnum.IDLE || loadState === ConnectedApplicationsLoadStateEnum.LOADING

return (
<div className="form-container">
<h3 className="subtitle">{t("hydraRevoke.title")}</h3>
<p className="text-paragraph">{t("hydraRevoke.description")}</p>

{revokeNotice && (
<p
className={combineClassNames(
"message",
revokeNotice.type === "success" ? "success" : "error",
)}
>
{revokeNotice.message}
</p>
)}

{showLoading && (
<p className="text-paragraph settings-connected-apps-muted">{t("hydraRevoke.loading")}</p>
)}

{loadState === "error" && (
<div className="settings-connected-apps-error-block">
<p className="message error">{loadError || t("hydraRevoke.loadError")}</p>
<button type="button" className="link" onClick={handleRetry}>
{t("hydraRevoke.retry")}
</button>
</div>
)}

{loadState === "success" && applications.length === 0 && (
<p className="text-paragraph settings-connected-apps-muted">{t("hydraRevoke.empty")}</p>
)}

{loadState === "success" && applications.length > 0 && (
<div className="settings-connected-apps-list">
{applications.map(app => (
<SettingsConnectedApplicationRow
key={app.clientId}
app={app}
isRevoking={revokingClientId === app.clientId}
onRevoke={onRevoke}
/>
))}
</div>
)}
</div>
)
}

export default memo(SettingsConnectedApplications)
5 changes: 4 additions & 1 deletion pages/consent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,18 @@ const Consent: NextPage = () => {
{consentRequest.requested_scope.map(scope => {
const meta = getScopeMeta(scope)
const isChecked = selectedScopes.includes(scope)
const isDisabled = scope.toLowerCase().includes('offline') || scope.toLowerCase().includes('openid')
return (
<label
key={scope}
className={combineClassNames("scope-item", isChecked && "selected")}
className={combineClassNames("scope-item", isChecked && "selected", isDisabled ? "cursor-not-allowed" : "cursor-pointer")}
>
<input
type="checkbox"
className="disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
checked={isChecked}
onChange={e => handleScopeChange(scope, e.target.checked)}
disabled={isDisabled}
/>
<div>
<div className="scope-label">{meta.label}</div>
Expand Down
83 changes: 75 additions & 8 deletions pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ 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"
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 { HydraConnectedApplication } from "@/util/hydra-connected-applications.helper"
import SettingsConnectedApplications, { ConnectedApplicationsLoadStateEnum } 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"
Expand Down Expand Up @@ -40,6 +47,11 @@ const Settings: NextPage = () => {
const [showAuthenticator, setShowAuthenticator] = useState<boolean>(false);
const [loadPage, setLoadPage] = useState<boolean>(false);
const [canShow, setCanShow] = useState<boolean>(false);
const [connectedApps, setConnectedApps] = useState<HydraConnectedApplication[]>([]);
const [connectedAppsLoadState, setConnectedAppsLoadState] = useState<ConnectedApplicationsLoadStateEnum>(ConnectedApplicationsLoadStateEnum.IDLE);
const [connectedAppsLoadError, setConnectedAppsLoadError] = useState("");
const [revokingClientId, setRevokingClientId] = useState<string | null>(null);
const [revokeNotice, setRevokeNotice] = useState<{ type: "success" | "error"; message: string } | null>(null);

useEffect(() => {
if (loadPage) return;
Expand Down Expand Up @@ -216,6 +228,49 @@ const Settings: NextPage = () => {
}
}, [changedFlow, isOidc, isOidcInvitation, showPassword, flowId, isOidc])

const loadConnectedApplications = useCallback(
async () => {
setConnectedAppsLoadState(ConnectedApplicationsLoadStateEnum.LOADING);
setConnectedAppsLoadError("");
try {
const list = await fetchHydraConnectedApplicationsForCurrentUser();
setConnectedApps(list);
setConnectedAppsLoadState(ConnectedApplicationsLoadStateEnum.SUCCESS);
} catch (e) {
setConnectedAppsLoadState(ConnectedApplicationsLoadStateEnum.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();
} 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({
Expand Down Expand Up @@ -290,7 +345,7 @@ const Settings: NextPage = () => {
</>}

{showAuthenticator && <>
{containsBackupCodes ? (<div className="form-container">
{containsBackupCodes ? <div className="form-container">
<h3 className="subtitle">{t('backUpCodes')}</h3>
<p>{t('backUpCodesDescription')}</p>
<Flow
Expand All @@ -299,9 +354,9 @@ const Settings: NextPage = () => {
only="lookup_secret"
flow={changedFlow}
/>
</div>) : (<> </>)}
</div> : <> </>}

{containsTotp ? (<div className="form-container">
{containsTotp ? <div className="form-container">
<h3 className="subtitle">{t('totpAuthenticator')}</h3>
<p>{t('addTotpAuthenticator')}
{t('popularAuthenticatorApps')} <a href="https://www.lastpass.com" target="_blank">LastPass</a>{t('and')} Google
Expand All @@ -316,17 +371,29 @@ const Settings: NextPage = () => {
only="totp"
flow={changedFlow}
/>
</div>) : (<> </>)}
</div> : <> </>}
</>}

{(isOidc && isOidcInvitation) ? (<div className="form-container">
{(isOidc && isOidcInvitation) ? <div className="form-container">
<Flow
hideGlobalMessages
onSubmit={onSubmit}
only="oidc"
flow={changedFlow}
/>
</div>) : (<> </>)}
</div> : <> </>}

{canShow &&
<SettingsConnectedApplications
applications={connectedApps}
loadState={connectedAppsLoadState}
loadError={connectedAppsLoadError}
revokingClientId={revokingClientId}
revokeNotice={revokeNotice}
onRevoke={handleRevokeOAuth2ForClient}
onRetryLoad={loadConnectedApplications}
/>
}

<div className="link-container">
<button className="link disabled:opacity-50 disabled:cursor-not-allowed" disabled={backButtonDisabled} onClick={() => {
Expand Down
Loading
Loading