1
0
Fork 0
mirror of https://codeberg.org/MarkusThielker/next-ory.git synced 2025-07-01 20:49:18 +00:00

Initial commit

This commit is contained in:
Markus Thielker 2024-05-03 05:10:11 +02:00
commit a74e7f3ebd
No known key found for this signature in database
84 changed files with 11089 additions and 0 deletions

View file

@ -0,0 +1,199 @@
'use client';
import { AxiosError } from 'axios';
import React, { DependencyList, useEffect, useState } from 'react';
import { kratos } from './sdk/kratos';
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
export const HandleError = (
getFlow:
| ((flowId: string) => Promise<void | AxiosError>)
| undefined = undefined,
setFlow: React.Dispatch<React.SetStateAction<any>> | undefined = undefined,
defaultNav: string | undefined = undefined,
fatalToError = false,
router: AppRouterInstance,
) => {
return async (
error: AxiosError<any, unknown>,
): Promise<AxiosError | void> => {
if (!error.response || error.response?.status === 0) {
window.location.href = `/flow/error?error=${encodeURIComponent(
JSON.stringify(error.response),
)}`;
return Promise.resolve();
}
const responseData = error.response?.data || {};
switch (error.response?.status) {
case 400: {
if (responseData.error?.id == 'session_already_available') {
router.push('/');
return Promise.resolve();
}
// the request could contain invalid parameters which would set error messages in the flow
if (setFlow !== undefined) {
console.warn('sdkError 400: update flow data');
setFlow(responseData);
return Promise.resolve();
}
break;
}
// we have no session or the session is invalid
case 401: {
console.warn('handleError hook 401: Navigate to /login');
router.push('/flow/login');
return Promise.resolve();
}
case 403: {
// the user might have a session, but would require 2FA (Two-Factor Authentication)
if (responseData.error?.id === 'session_aal2_required') {
router.push('/flow/login?aal2=true');
router.refresh();
return Promise.resolve();
}
if (
responseData.error?.id === 'session_refresh_required' &&
responseData.redirect_browser_to
) {
console.warn(
'sdkError 403: Redirect browser to',
responseData.redirect_browser_to,
);
window.location = responseData.redirect_browser_to;
return Promise.resolve();
}
break;
}
case 404: {
console.warn('sdkError 404: Navigate to Error');
const errorMsg = {
data: error.response?.data || error,
status: error.response?.status,
statusText: error.response?.statusText,
url: window.location.href,
};
router.push(
`/flow/error?error=${encodeURIComponent(JSON.stringify(errorMsg))}`,
);
return Promise.resolve();
}
// error.id handling
// "self_service_flow_expired"
case 410: {
if (getFlow !== undefined && responseData.use_flow_id !== undefined) {
console.warn('sdkError 410: Update flow');
return getFlow(responseData.use_flow_id).catch((error) => {
// Something went seriously wrong - log and redirect to defaultNav if possible
console.error(error);
if (defaultNav !== undefined) {
router.push(defaultNav);
} else {
// Rethrow error when can't navigate and let caller handle
throw error;
}
});
} else if (defaultNav !== undefined) {
console.warn('sdkError 410: Navigate to', defaultNav);
router.push(defaultNav);
return Promise.resolve();
}
break;
}
// we need to parse the response and follow the `redirect_browser_to` URL
// this could be when the user needs to perform a 2FA challenge
// or passwordless login
case 422: {
if (responseData.redirect_browser_to !== undefined) {
const currentUrl = new URL(window.location.href);
const redirect = new URL(responseData.redirect_browser_to);
// host name has changed, then change location
if (currentUrl.host !== redirect.host) {
console.warn('sdkError 422: Host changed redirect');
window.location = responseData.redirect_browser_to;
return Promise.resolve();
}
// Path has changed
if (currentUrl.pathname !== redirect.pathname) {
console.warn('sdkError 422: Update path');
router.push(redirect.pathname + redirect.search);
return Promise.resolve();
}
// for webauthn we need to reload the flow
const flowId = redirect.searchParams.get('flow');
if (flowId != null && getFlow !== undefined) {
// get new flow data based on the flow id in the redirect url
console.warn('sdkError 422: Update flow');
return getFlow(flowId).catch((error) => {
// Something went seriously wrong - log and redirect to defaultNav if possible
console.error(error);
if (defaultNav !== undefined) {
router.push(defaultNav);
} else {
// Rethrow error when can't navigate and let caller handle
throw error;
}
});
} else {
console.warn('sdkError 422: Redirect browser to');
window.location = responseData.redirect_browser_to;
return Promise.resolve();
}
}
}
}
console.error(error);
if (fatalToError) {
console.warn('sdkError: fatal error redirect to /error');
router.push('/flow/error?id=' + encodeURI(error.response?.data.error?.id));
return Promise.resolve();
}
throw error;
};
};
// Returns a function which will log the user out
export function LogoutLink(deps?: DependencyList) {
const [logoutToken, setLogoutToken] = useState<string>('');
useEffect(() => {
kratos
.createBrowserLogoutFlow()
.then(({ data }) => {
setLogoutToken(data.logout_token);
})
.catch((err: AxiosError) => {
switch (err.response?.status) {
case 401:
// do nothing, the user is not logged in
return;
}
// Something else happened!
return Promise.reject(err);
});
}, deps);
return () => {
if (logoutToken) {
kratos
.updateLogoutFlow({ token: logoutToken })
.then(() => window.location.href = '/flow/login');
}
};
}

View file

@ -0,0 +1,3 @@
export * from './hooks';
export * from './ui';
export * from './sdk/kratos';

View file

@ -0,0 +1,15 @@
'use server';
import { Configuration, OAuth2Api } from '@ory/client';
// implemented as a function because of 'use server'
export default async function getHydra() {
return new OAuth2Api(new Configuration(
new Configuration({
basePath: process.env.ORY_HYDRA_ADMIN_URL,
baseOptions: {
withCredentials: true,
},
}),
));
}

View file

@ -0,0 +1,14 @@
'use client';
import { Configuration, FrontendApi } from '@ory/client';
const kratos = new FrontendApi(
new Configuration({
basePath: process.env.NEXT_PUBLIC_ORY_KRATOS_URL,
baseOptions: {
withCredentials: true,
},
}),
);
export { kratos };

View file

@ -0,0 +1,224 @@
'use client';
import {
LoginFlow,
RecoveryFlow,
RegistrationFlow,
SettingsFlow,
UiNode,
UpdateLoginFlowBody,
UpdateRecoveryFlowBody,
UpdateRegistrationFlowBody,
UpdateSettingsFlowBody,
UpdateVerificationFlowBody,
VerificationFlow,
} from '@ory/client';
import { getNodeId, isUiNodeInputAttributes } from '@ory/integrations/ui';
import { Component, FormEvent, MouseEvent } from 'react';
import { Messages, Node } from '@/ory';
export type Values = Partial<
| UpdateLoginFlowBody
| UpdateRegistrationFlowBody
| UpdateRecoveryFlowBody
| UpdateSettingsFlowBody
| UpdateVerificationFlowBody
>
export type Methods =
| 'oidc'
| 'password'
| 'profile'
| 'totp'
| 'webauthn'
| 'passkey'
| 'link'
| 'lookup_secret'
export type Props<T> = {
// The flow
flow?:
| LoginFlow
| RegistrationFlow
| SettingsFlow
| VerificationFlow
| RecoveryFlow
// Only show certain nodes. We will always render the default nodes for CSRF tokens.
only?: Methods
// Is triggered on submission
onSubmit: (values: T) => Promise<void>
// Do not show the global messages. Useful when rendering them elsewhere.
hideGlobalMessages?: boolean
}
function emptyState<T>() {
return {} as T;
}
type State<T> = {
values: T
isLoading: boolean
}
export class Flow<T extends Values> extends Component<Props<T>, State<T>> {
constructor(props: Props<T>) {
super(props);
this.state = {
values: emptyState(),
isLoading: false,
};
}
componentDidMount() {
this.initializeValues(this.filterNodes());
}
componentDidUpdate(prevProps: Props<T>) {
if (prevProps.flow !== this.props.flow) {
// Flow has changed, reload the values!
this.initializeValues(this.filterNodes());
}
}
initializeValues = (nodes: Array<UiNode> = []) => {
// Compute the values
const values = emptyState<T>();
nodes.forEach((node) => {
// This only makes sense for text nodes
if (isUiNodeInputAttributes(node.attributes)) {
if (
node.attributes.type === 'button' ||
node.attributes.type === 'submit'
) {
// In order to mimic real HTML forms, we need to skip setting the value
// for buttons as the button value will (in normal HTML forms) only trigger
// if the user clicks it.
return;
}
values[node.attributes.name as keyof Values] = node.attributes.value;
}
});
// Set all the values!
this.setState((state) => ({ ...state, values }));
};
filterNodes = (): Array<UiNode> => {
const { flow, only } = this.props;
if (!flow) {
return [];
}
return flow.ui.nodes.filter(({ group }) => {
if (!only) {
return true;
}
return group === 'default' || group === only;
});
};
// Handles form submission
handleSubmit = (event: FormEvent<HTMLFormElement> | MouseEvent) => {
// Prevent all native handlers
event.stopPropagation();
event.preventDefault();
// Prevent double submission!
if (this.state.isLoading) {
return Promise.resolve();
}
const form = event.currentTarget;
let body: T | undefined;
if (form && form instanceof HTMLFormElement) {
const formData = new FormData(form);
// map the entire form data to JSON for the request body
body = Object.fromEntries(formData) as T;
const hasSubmitter = (evt: any): evt is { submitter: HTMLInputElement } =>
'submitter' in evt;
// We need the method specified from the name and value of the submit button.
// when multiple submit buttons are present, the clicked one's value is used.
if (hasSubmitter(event.nativeEvent)) {
const method = event.nativeEvent.submitter;
body = {
...body,
...{ [method.name]: method.value },
};
}
}
this.setState((state) => ({
...state,
isLoading: true,
}));
return this.props
.onSubmit({ ...body, ...this.state.values })
.finally(() => {
// We wait for reconciliation and update the state after 50ms
// Done submitting - update loading status
this.setState((state) => ({
...state,
isLoading: false,
}));
});
};
render() {
const { hideGlobalMessages, flow } = this.props;
const { values, isLoading } = this.state;
// Filter the nodes - only show the ones we want
const nodes = this.filterNodes();
if (!flow) {
// No flow was set yet? It's probably still loading...
//
// Nodes have only one element? It is probably just the CSRF Token
// and the filter did not match any elements!
return null;
}
return (
<form
action={flow.ui.action}
method={flow.ui.method}
onSubmit={this.handleSubmit}
className="flex flex-col w-full space-y-2"
>
{!hideGlobalMessages ? <Messages classNames="space-y-2" messages={flow.ui.messages}/> : null}
{nodes.map((node, k) => {
const id = getNodeId(node) as keyof Values;
return (
<Node
key={`${id}-${k}`}
disabled={isLoading}
node={node}
value={values[id]}
dispatchSubmit={this.handleSubmit}
setValue={(value) =>
new Promise((resolve) => {
this.setState(
(state) => ({
...state,
values: {
...state.values,
[getNodeId(node)]: value,
},
}),
resolve,
);
})
}
/>
);
})}
</form>
);
}
}

View file

@ -0,0 +1,54 @@
import { UiText } from '@ory/client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { JSX } from 'react';
import { AlertCircle, AlertOctagon, Check } from 'lucide-react';
interface MessageProps {
message: UiText,
}
export const Message = ({ message }: MessageProps) => {
let icon: JSX.Element = <></>;
switch (message.type) {
case 'error':
icon = <AlertOctagon className="h-4 w-4"/>;
break;
case 'success':
icon = <Check className="h-4 w-4"/>;
break;
case 'info':
icon = <AlertCircle className="h-4 w-4"/>;
break;
}
return (
<Alert>
{icon}
<AlertTitle>{message.type.charAt(0).toUpperCase() + message.type.substring(1)}</AlertTitle>
<AlertDescription>
{message.text}
</AlertDescription>
</Alert>
);
};
interface MessagesProps {
messages?: Array<UiText>
classNames?: string,
}
export const Messages = ({ messages, classNames }: MessagesProps) => {
if (!messages) {
// No messages? Do nothing.
return null;
}
return (
<div className={classNames}>
{messages.map((message) => (
<Message key={message.id} message={message}/>
))}
</div>
);
};

View file

@ -0,0 +1,60 @@
import { UiNode } from '@ory/client';
import {
isUiNodeAnchorAttributes,
isUiNodeImageAttributes,
isUiNodeInputAttributes,
isUiNodeScriptAttributes,
isUiNodeTextAttributes,
} from '@ory/integrations/ui';
import { NodeAnchor } from './NodeAnchor';
import { NodeImage, NodeInput, NodeText } from '@/ory';
import { NodeScript } from './NodeScript';
import { FormDispatcher, ValueSetter } from './helpers';
interface Props {
node: UiNode;
disabled: boolean;
value: any;
setValue: ValueSetter;
dispatchSubmit: FormDispatcher;
}
export const Node = ({
node,
value,
setValue,
disabled,
dispatchSubmit,
}: Props) => {
if (isUiNodeImageAttributes(node.attributes)) {
return <NodeImage node={node} attributes={node.attributes}/>;
}
if (isUiNodeScriptAttributes(node.attributes)) {
return <NodeScript node={node} attributes={node.attributes}/>;
}
if (isUiNodeTextAttributes(node.attributes)) {
return <NodeText node={node} attributes={node.attributes}/>;
}
if (isUiNodeAnchorAttributes(node.attributes)) {
return <NodeAnchor node={node} attributes={node.attributes}/>;
}
if (isUiNodeInputAttributes(node.attributes)) {
return (
<NodeInput
dispatchSubmit={dispatchSubmit}
value={value}
setValue={setValue}
node={node}
disabled={disabled}
attributes={node.attributes}
/>
);
}
return null;
};

View file

@ -0,0 +1,23 @@
'use client';
import { UiNode, UiNodeAnchorAttributes } from '@ory/client';
import { Button } from '@/components/ui/button';
interface Props {
node: UiNode;
attributes: UiNodeAnchorAttributes;
}
export const NodeAnchor = ({ node, attributes }: Props) => {
return (
<Button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
window.location.href = attributes.href;
}}
>
{attributes.title.text}
</Button>
);
};

View file

@ -0,0 +1,16 @@
import { UiNode, UiNodeImageAttributes } from '@ory/client';
interface Props {
node: UiNode;
attributes: UiNodeImageAttributes;
}
export const NodeImage = ({ node, attributes }: Props) => {
return (
<img
src={attributes.src}
width={200}
alt={node.meta.label?.text}
/>
);
};

View file

@ -0,0 +1,29 @@
import { NodeInputButton } from './NodeInputButton';
import { NodeInputCheckbox } from './NodeInputCheckbox';
import { NodeInputDefault } from './NodeInputDefault';
import { NodeInputHidden } from './NodeInputHidden';
import { NodeInputSubmit } from './NodeInputSubmit';
import { NodeInputProps } from './helpers';
export function NodeInput<T>(props: NodeInputProps) {
const { attributes } = props;
switch (attributes.type) {
case 'hidden':
// Render a hidden input field
return <NodeInputHidden {...props} />;
case 'checkbox':
// Render a checkbox. We have one hidden element which is the real value (true/false), and one
// display element which is the toggle value (true)!
return <NodeInputCheckbox {...props} />;
case 'button':
// Render a button
return <NodeInputButton {...props} />;
case 'submit':
// Render the submit button
return <NodeInputSubmit {...props} />;
}
// Render a generic text input field.
return <NodeInputDefault {...props} />;
}

View file

@ -0,0 +1,47 @@
'use client';
import { getNodeLabel } from '@ory/integrations/ui';
import { callWebauthnFunction, NodeInputProps } from './helpers';
import { Button } from '@/components/ui/button';
import React from 'react';
export function NodeInputButton<T>({
node,
attributes,
setValue,
disabled,
dispatchSubmit,
}: NodeInputProps) {
// Some attributes have dynamic JavaScript - this is for example required for WebAuthn.
const onClick = (e: React.MouseEvent | React.FormEvent<HTMLFormElement>) => {
// This section is only used for WebAuthn. The script is loaded via a <script> node
// and the functions are available on the global window level. Unfortunately, there
// is currently no better way than executing eval / function here at this moment.
//
// Please note that we also need to prevent the default action from happening.
if (attributes.onclick) {
e.stopPropagation();
e.preventDefault();
callWebauthnFunction(attributes.onclick);
return;
}
setValue(attributes.value).then(() => dispatchSubmit(e));
};
return (
<>
<Button
name={attributes.name}
onClick={(e) => {
onClick(e);
}}
value={attributes.value || ''}
disabled={attributes.disabled || disabled}
>
{getNodeLabel(node)}
</Button>
</>
);
}

View file

@ -0,0 +1,36 @@
'use client';
import { NodeInputProps } from './helpers';
import { Checkbox } from '@/components/ui/checkbox';
export function NodeInputCheckbox<T>(
{
node,
attributes,
setValue,
disabled,
}: NodeInputProps,
) {
const state =
node.messages.find(({ type }) => type === 'error') ? 'error' : undefined;
// Render a checkbox.
return (
<div className={`inline-flex space-x-2 items-center ${state ? 'text-yellow-500' : undefined}`}>
<Checkbox
id={attributes.name}
name={attributes.name}
defaultChecked={attributes.value}
onCheckedChange={(e) => setValue(e)}
disabled={attributes.disabled || disabled}
className={`my-2 ${state ? 'border-yellow-500' : undefined}`}
/>
<label
htmlFor={attributes.name}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
dangerouslySetInnerHTML={{ __html: node.meta.label?.text || '' }}
/>
</div>
);
}

View file

@ -0,0 +1,48 @@
'use client';
import { NodeInputProps } from './helpers';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertTriangle } from 'lucide-react';
export function NodeInputDefault<T>(props: NodeInputProps) {
const { node, attributes, value = '', setValue, disabled } = props;
// Some attributes have dynamic JavaScript - this is for example required for WebAuthn.
const onClick = () => {
// This section is only used for WebAuthn. The script is loaded via a <script> node
// and the functions are available on the global window level. Unfortunately, there
// is currently no better way than executing eval / function here at this moment.
if (attributes.onclick) {
const run = new Function(attributes.onclick);
run();
}
};
const state =
node.messages.find(({ type }) => type === 'error') ? 'error' : undefined;
// Render a generic text input field.
return (
<div>
<Label className={state ? 'text-yellow-500' : undefined}>{node.meta.label?.text}</Label>
<Input
title={node.meta.label?.text}
onClick={onClick}
onChange={(e) => setValue(e.target.value)}
placeholder={node.meta.label?.text}
type={attributes.type}
name={attributes.name}
value={value}
autoComplete={attributes.autocomplete}
disabled={attributes.disabled || disabled}
/>
{node.messages.map(({ text, id }, k) => (
<Label className="text-yellow-500 inline-flex space-x-2 items-center mt-1.5" key={`${id}-${k}`}>
<AlertTriangle className="h-4 w-4"/>
<span>{text}</span>
</Label>
))}
</div>
);
}

View file

@ -0,0 +1,15 @@
'use client';
import { NodeInputProps, useOnload } from './helpers';
export function NodeInputHidden<T>({ attributes }: NodeInputProps) {
useOnload(attributes as any);
return (
<input
type={attributes.type}
name={attributes.name}
value={attributes.value || 'true'}
/>
);
}

View file

@ -0,0 +1,24 @@
'use client';
import { getNodeLabel } from '@ory/integrations/ui';
import { NodeInputProps } from './helpers';
import { Button } from '@/components/ui/button';
export function NodeInputSubmit<T>(
{
node,
attributes,
disabled,
}: NodeInputProps,
) {
return (
<Button
name={attributes.name}
value={attributes.value || ''}
disabled={attributes.disabled || disabled}
>
{getNodeLabel(node)}
</Button>
);
}

View file

@ -0,0 +1,32 @@
'use client';
import { UiNode, UiNodeScriptAttributes } from '@ory/client';
import { HTMLAttributeReferrerPolicy, useEffect } from 'react';
interface Props {
node: UiNode;
attributes: UiNodeScriptAttributes;
}
export const NodeScript = ({ attributes }: Props) => {
useEffect(() => {
const script = document.createElement('script');
script.async = true;
script.src = attributes.src;
script.async = attributes.async;
script.crossOrigin = attributes.crossorigin;
script.integrity = attributes.integrity;
script.referrerPolicy =
attributes.referrerpolicy as HTMLAttributeReferrerPolicy;
script.type = attributes.type;
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, [attributes]);
return null;
};

View file

@ -0,0 +1,42 @@
import { UiNode, UiNodeTextAttributes, UiText } from '@ory/client';
interface Props {
node: UiNode;
attributes: UiNodeTextAttributes;
}
const Content = ({ node, attributes }: Props) => {
switch (attributes.text.id) {
case 1050015:
// This text node contains lookup secrets. Let's make them a bit more beautiful!
const secrets = (attributes.text.context as any).secrets.map(
(text: UiText, k: number) => (
<div key={k} className="text-sm">
<code>{text.id === 1050014 ? 'Used' : text.text}</code>
</div>
),
);
return (
<div className="container-fluid">
<div className="row">{secrets}</div>
</div>
);
}
return (
<div className="w-full p-4 rounded-md border flex-wrap text-sm">
{attributes.text.text}
</div>
);
};
export const NodeText = ({ node, attributes }: Props) => {
return (
<>
<p className="text-lg">
{node.meta?.label?.text}
</p>
<Content node={node} attributes={attributes}/>
</>
);
};

View file

@ -0,0 +1,44 @@
import { UiNode, UiNodeInputAttributes } from '@ory/client';
import { FormEvent, MouseEvent, useEffect } from 'react';
export type ValueSetter = (
value: string | number | boolean | undefined,
) => Promise<void>
export type FormDispatcher = (
e: FormEvent<HTMLFormElement> | MouseEvent,
) => Promise<void>
export interface NodeInputProps {
node: UiNode;
attributes: UiNodeInputAttributes;
value: any;
disabled: boolean;
dispatchSubmit: FormDispatcher;
setValue: ValueSetter;
}
export const useOnload = (attributes: { onload?: string }) => {
useEffect(() => {
if (attributes.onload) {
const intervalHandle = callWebauthnFunction(attributes.onload);
return () => {
window.clearInterval(intervalHandle);
};
}
}, [attributes]);
};
export const callWebauthnFunction = (functionBody: string) => {
const run = new Function(functionBody);
const intervalHandle = window.setInterval(() => {
if ((window as any).__oryWebAuthnInitialized) {
run();
window.clearInterval(intervalHandle);
}
}, 100);
return intervalHandle;
};

View file

@ -0,0 +1,6 @@
export * from './Flow';
export * from './Messages';
export * from './Node';
export * from './NodeImage';
export * from './NodeInput';
export * from './NodeText';