diff --git a/Dockerfile b/Dockerfile index 7df8d3c..5370d18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine as build +FROM node:18-alpine AS build WORKDIR /app COPY package*.json /app/ @@ -6,4 +6,4 @@ RUN npm install COPY . /app RUN npm run build -ENTRYPOINT /usr/local/bin/npm run start \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/npm", "run", "start"] \ No newline at end of file diff --git a/pages/consent.tsx b/pages/consent.tsx new file mode 100644 index 0000000..ad19eb4 --- /dev/null +++ b/pages/consent.tsx @@ -0,0 +1,314 @@ +import { OAuth2ConsentRequest, AcceptOAuth2ConsentRequestSession } from "@ory/client" +import type { NextPage } from "next" +import Head from "next/head" +import { useRouter } from "next/router" +import { useCallback, useEffect, useMemo, useState } from "react" +import { combineClassNames } from "@/submodules/javascript-functions/general" +import { KernLogo } from "@/pkg/ui/Icons" +import ory from "@/pkg/sdk" + +const REMEMBER_FOR_SECONDS = 3600 + +const SCOPE_META: Record = { + openid: { label: "OpenID", description: "Verify your identity" }, + email: { label: "Email", description: "View your email address" }, + profile: { label: "Profile", description: "View your basic profile info" }, + offline_access: { label: "Offline access", description: "Stay signed in on your behalf" }, +} + +const LOADING_CONTAINER_STYLE = { textAlign: "center" as const } +const FORM_SCOPE_CONTAINER_STYLE = { marginTop: 20 } +const SUBTITLE_STYLE = { marginBottom: 4 } +const TEXT_DESCRIPTION_BOTTOM_STYLE = { marginBottom: 0 } + +function getScopeMeta(scope: string) { + return SCOPE_META[scope] ?? { label: scope, description: "Access to " + scope } +} + +const Consent: NextPage = () => { + const router = useRouter() + const { consent_challenge: consentChallenge } = router.query + + const [consentRequest, setConsentRequest] = useState() + const [selectedScopes, setSelectedScopes] = useState([]) + const [remember, setRemember] = useState(true) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorMessage, setErrorMessage] = useState("") + const [isLoading, setIsLoading] = useState(true) + + const challenge = useMemo( + () => (consentChallenge ? String(consentChallenge) : ""), + [consentChallenge], + ) + + const buildSession = useCallback(async (grantScope: string[]): Promise => { + const session: AcceptOAuth2ConsentRequestSession = { + access_token: {}, + id_token: {}, + } + try { + const { data } = await ory.toSession() + const identity = data.identity + if (!identity) return session + + if (grantScope.includes("email")) { + const address = identity.verifiable_addresses?.find(a => a.via === "email") + if (address) { + session.id_token!.email = address.value + session.id_token!.email_verified = address.verified + } + } + + if (grantScope.includes("profile")) { + if (identity.traits?.username) session.id_token!.preferred_username = identity.traits.username + if (identity.traits?.website) session.id_token!.website = identity.traits.website + if (typeof identity.traits?.name === "object") { + const name = identity.traits.name as { first?: string; last?: string } + if (name.first) session.id_token!.given_name = name.first + if (name.last) session.id_token!.family_name = name.last + } else if (typeof identity.traits?.name === "string") { + session.id_token!.name = identity.traits.name + } + if (identity.updated_at) { + session.id_token!.updated_at = parseInt((Date.parse(identity.updated_at) / 1000).toFixed(0), 10) + } + } + } catch { + // continue without enrichment + } + return session + }, []) + + useEffect(() => { + if (!router.isReady || !challenge) return + + fetch(`/refinery-authorizer/hydra/consent?challenge=${challenge}`) + .then(res => { + if (!res.ok) throw new Error("Failed to fetch consent request") + return res.json() + }) + .then(async (data: OAuth2ConsentRequest) => { + const requestedScope = data.requested_scope || [] + + if (data.skip) { + const session = await buildSession(requestedScope) + return fetch('/refinery-authorizer/hydra/consent/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge, + grant_scope: requestedScope, + grant_access_token_audience: data.requested_access_token_audience || [], + remember, + remember_for: REMEMBER_FOR_SECONDS, + session, + }), + }) + .then(res => { + if (!res.ok) throw new Error('Failed to accept consent') + return res.json() + }) + .then(({ redirect_to }) => { + if (redirect_to && typeof redirect_to === "string") window.location.href = redirect_to + }) + } + + setConsentRequest(data) + setSelectedScopes(requestedScope) + setIsLoading(false) + }) + .catch((err: unknown) => { + setErrorMessage(err instanceof Error ? err.message : "Unable to load the consent request.") + setIsLoading(false) + }) + }, [router.isReady, challenge, buildSession, remember]) + + const handleScopeChange = useCallback((scope: string, checked: boolean) => { + setSelectedScopes(prev => + checked ? [...prev, scope] : prev.filter(s => s !== scope) + ) + }, []) + + const handleAccept = useCallback(async () => { + if (!consentRequest || !challenge || isSubmitting) return + setIsSubmitting(true) + setErrorMessage("") + try { + const session = await buildSession(selectedScopes) + const res = await fetch('/refinery-authorizer/hydra/consent/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge, + grant_scope: selectedScopes, + grant_access_token_audience: consentRequest.requested_access_token_audience || [], + remember, + remember_for: REMEMBER_FOR_SECONDS, + session, + }), + }) + if (!res.ok) throw new Error('Failed to accept consent') + const { redirect_to } = await res.json() + if (redirect_to && typeof redirect_to === "string") window.location.href = redirect_to + } catch (err: unknown) { + setErrorMessage(err instanceof Error ? err.message : "Unable to accept consent.") + } finally { + setIsSubmitting(false) + } + }, [buildSession, challenge, consentRequest, isSubmitting, remember, selectedScopes]) + + const handleRememberChange = useCallback((e: React.ChangeEvent) => { + setRemember(e.target.checked) + }, []) + + const handleReject = useCallback(async () => { + if (!challenge || isSubmitting) return + setIsSubmitting(true) + setErrorMessage("") + try { + const res = await fetch('/refinery-authorizer/hydra/consent/reject', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challenge }), + }) + if (!res.ok) throw new Error('Failed to reject consent') + const { redirect_to } = await res.json() + if (redirect_to && typeof redirect_to === "string") window.location.href = redirect_to + } catch (err: unknown) { + setErrorMessage(err instanceof Error ? err.message : "Unable to reject consent.") + } finally { + setIsSubmitting(false) + } + }, [challenge, isSubmitting]) + + const clientName = + consentRequest?.client?.client_name || + consentRequest?.client?.client_id || + "Unknown Client" + + const clientInitial = clientName.charAt(0).toUpperCase() + const clientLogoUri = + (consentRequest?.client as { logo_uri?: string } | undefined)?.logo_uri || + (consentRequest?.client as { logo_url?: string } | undefined)?.logo_url || + "" + + + return ( + <> + + Consent + + +
+ + +
+
+ + ) +} + +export default Consent diff --git a/pages/login.tsx b/pages/login.tsx index 7dea621..3a4cee2 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -3,14 +3,12 @@ import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" import { useRouter } from "next/router" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { Flow } from "../pkg" import { handleGetFlowError, handleFlowError } from "../pkg/errors" import { KernLogo } from "@/pkg/ui/Icons" -import { DemoFlow } from "@/pkg/ui/DemoFlow" -import { getValueIdentifier, getValuePassword } from "@/util/helper-functions" import ory from "@/pkg/sdk" const Login: NextPage = () => { @@ -26,6 +24,7 @@ const Login: NextPage = () => { const { return_to: returnTo, flow: flowId, + login_challenge: loginChallenge, // Refresh means we want to refresh the session. This is needed, for example, when we want to update the password // of a user. refresh, @@ -39,6 +38,48 @@ const Login: NextPage = () => { if (!router.isReady || initialFlow) { return } + // If there is a challenge, check for existing session first + if (loginChallenge) { + + ory.toSession() + .then(({ data }) => { + // Active session exists — let the server accept the challenge + // without forcing re-auth + return fetch(`/refinery-authorizer/hydra/login/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: String(loginChallenge), + subject: data.identity.id, + }), + }) + }) + .then(res => { + if (!res.ok) throw new Error("Failed to accept login") + return res.json() + }) + .then(({ redirect_to }) => { + if (!redirect_to || typeof redirect_to !== "string") return + const target = new URL(redirect_to, window.location.origin) + if (target.pathname === window.location.pathname && target.search === window.location.search) { + window.location.reload() + } else { + window.location.href = redirect_to + } + }) + .catch(() => { + // No session — proceed with normal flow creation + ory.createBrowserLoginFlow({ + refresh: Boolean(refresh), + aal: aal ? String(aal) : undefined, + returnTo: returnTo ? String(returnTo) : undefined, + loginChallenge: String(loginChallenge), + }) + .then(({ data }) => setInitialFlow(data)) + .catch(handleFlowError(router, 'login', setInitialFlow)) + }) + return + } // If ?flow=.. was in the URL, we fetch it if (flowId) { ory @@ -55,12 +96,13 @@ const Login: NextPage = () => { refresh: Boolean(refresh), aal: aal ? String(aal) : undefined, returnTo: returnTo ? String(returnTo) : undefined, + loginChallenge: loginChallenge ? String(loginChallenge) : undefined, }) .then(({ data }) => { setInitialFlow(data) }) .catch(handleFlowError(router, "login", setInitialFlow)) - }, [flowId, router, router.isReady, aal, refresh, returnTo, initialFlow]) + }, [flowId, router, router.isReady, aal, refresh, returnTo, loginChallenge, initialFlow]) useEffect(() => { if (!initialFlow) return; @@ -121,6 +163,27 @@ const Login: NextPage = () => { return Promise.reject(err) }) + + const backToLoginQuery = useMemo( + () => + new URLSearchParams({ + ...(returnTo ? { return_to: String(returnTo) } : {}), + ...(loginChallenge ? { login_challenge: String(loginChallenge) } : {}), + }).toString(), + [returnTo, loginChallenge], + ) + const backToLoginHref = `/auth/login${backToLoginQuery ? `?${backToLoginQuery}` : ""}` + + const registrationQuery = useMemo( + () => + new URLSearchParams({ + ...(returnTo ? { return_to: String(returnTo) } : {}), + ...(loginChallenge ? { login_challenge: String(loginChallenge) } : {}), + }).toString(), + [returnTo, loginChallenge], + ) + const registrationHref = `/auth/registration${registrationQuery ? `?${registrationQuery}` : ""}` + return ( <> @@ -132,7 +195,7 @@ const Login: NextPage = () => {

Sign in to your account

Or - Register account - + Register account - no credit card required!

@@ -161,7 +224,7 @@ const Login: NextPage = () => { <> {displayMailForm ? Forgot your password? : null} - : Go back to login + : Go back to login }
diff --git a/pages/registration.tsx b/pages/registration.tsx index 1760444..1d549d6 100644 --- a/pages/registration.tsx +++ b/pages/registration.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 { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { KernLogo } from "@/pkg/ui/Icons" import ory from "@/pkg/sdk" import { handleFlowError } from "@/pkg/errors" @@ -22,7 +22,11 @@ const Registration: NextPage = () => { // Get ?flow=... from the URL - const { flow: flowId, return_to: returnTo } = router.query; + const { + flow: flowId, + return_to: returnTo, + login_challenge: loginChallenge, + } = router.query; // In this effect we either initiate a new registration flow, or we fetch an existing registration flow. useEffect(() => { @@ -47,12 +51,13 @@ const Registration: NextPage = () => { ory .createBrowserRegistrationFlow({ returnTo: returnTo ? String(returnTo) : undefined, + loginChallenge: loginChallenge ? String(loginChallenge) : undefined, }) .then(({ data }) => { setInitialFlow(data) }) .catch(handleFlowError(router, "registration", setInitialFlow)) - }, [flowId, router, router.isReady, returnTo, initialFlow]) + }, [flowId, router, router.isReady, returnTo, loginChallenge, initialFlow]) useEffect(() => { if (!initialFlow) return; @@ -97,6 +102,17 @@ const Registration: NextPage = () => { }) } + const backToLoginQuery = useMemo( + () => + new URLSearchParams({ + ...(returnTo ? { return_to: String(returnTo) } : {}), + ...(loginChallenge ? { login_challenge: String(loginChallenge) } : {}), + }).toString(), + [returnTo, loginChallenge], + ) + const backToLoginHref = `/auth/login${backToLoginQuery ? `?${backToLoginQuery}` : ""}` + + return ( <> @@ -117,7 +133,7 @@ const Registration: NextPage = () => {
diff --git a/pkg/sdk/index.ts b/pkg/sdk/index.ts index 0139ead..b9ac0b7 100644 --- a/pkg/sdk/index.ts +++ b/pkg/sdk/index.ts @@ -5,12 +5,7 @@ const ory = new FrontendApi( new Configuration({ basePath: '/.ory/kratos/public/', baseOptions: { - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - mode: 'no-cors', + // mode: 'no-cors', withCredentials: true, } }) diff --git a/styles/tailwind.css b/styles/tailwind.css index e0a5442..47ca24d 100644 --- a/styles/tailwind.css +++ b/styles/tailwind.css @@ -262,6 +262,214 @@ button.button-sign-in:focus { display: none !important; } + +.consent-client-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: #F9FAFB; + border: 1px solid #E5E7EB; + border-radius: 8px; + width: 100%; + margin-bottom: 8px; +} + +.consent-client-badge .client-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + background: linear-gradient(135deg, #5DE079, #75EA8E); + color: white; + font-weight: 700; + font-size: 16px; + flex-shrink: 0; +} + +.consent-client-badge .client-icon.client-icon--logo { + background: #fff; + border: 1px solid #E5E7EB; +} + +.consent-client-badge .client-icon .client-logo { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 8px; +} + +.consent-client-badge .client-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.consent-client-badge .client-name { + font-weight: 600; + font-size: 14px; + color: #111827; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.consent-client-badge .client-label { + font-size: 12px; + color: #6B7280; + line-height: 16px; +} + +.scope-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +.scope-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1px solid #E5E7EB; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease-in-out; + background-color: white; +} + +.scope-item:hover { + border-color: #5AB370; + background-color: #F0FDF4; +} + +.scope-item.selected { + border-color: #5AB370; + background-color: #F0FDF4; +} + +.scope-item input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #5AB370; + cursor: pointer; + flex-shrink: 0; +} + +.scope-item .scope-label { + font-size: 14px; + font-weight: 500; + color: #374151; +} + +.scope-item .scope-description { + font-size: 12px; + color: #6B7280; + margin-top: 1px; +} + +.remember-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 20px; + padding: 10px 14px; + border-radius: 8px; + background-color: #F9FAFB; +} + +.remember-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #5AB370; + cursor: pointer; + flex-shrink: 0; +} + +.remember-row .remember-text { + font-size: 13px; + color: #6B7280; + cursor: pointer; +} + +.consent-btn-primary { + width: 100%; + display: flex; + justify-content: center; + padding: 10px 0; + border: 1px solid transparent; + border-radius: 6px; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + font-size: 14px; + line-height: 20px; + font-weight: 500; + color: white; + background-color: #5AB370; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.consent-btn-primary:hover:not(:disabled) { + background-color: #2f7241; +} + +.consent-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.consent-btn-secondary { + width: 100%; + display: flex; + justify-content: center; + padding: 10px 0; + border: 1px solid #D1D5DB; + border-radius: 6px; + font-size: 14px; + line-height: 20px; + font-weight: 500; + color: #374151; + background-color: white; + cursor: pointer; + transition: all 0.15s ease-in-out; +} + +.consent-btn-secondary:hover:not(:disabled) { + border-color: #9CA3AF; + background-color: #F9FAFB; +} + +.consent-btn-secondary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.consent-btn-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 24px; +} + +.consent-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: consent-spin 0.6s linear infinite; + margin-right: 8px; + vertical-align: middle; +} + +@keyframes consent-spin { + to { transform: rotate(360deg); } +} + .top-2 { top: 24px !important; } diff --git a/submodules/javascript-functions b/submodules/javascript-functions index 3fcec61..4850e39 160000 --- a/submodules/javascript-functions +++ b/submodules/javascript-functions @@ -1 +1 @@ -Subproject commit 3fcec610b5212e0a26942b289699259e47475088 +Subproject commit 4850e39785215aa8cabcb1cd7df0cd47681be0d5 diff --git a/submodules/react-components b/submodules/react-components index 38ed96a..99e54d6 160000 --- a/submodules/react-components +++ b/submodules/react-components @@ -1 +1 @@ -Subproject commit 38ed96a946fb3cadaa4ac35eaf8260d028fe48b4 +Subproject commit 99e54d67a477fa59655f93a21b8568659903e6c0