Skip to content

Commit f9ab970

Browse files
committed
feat(frontend): implement verification flow
1 parent 74ba8ea commit f9ab970

File tree

5 files changed

+157
-8
lines changed

5 files changed

+157
-8
lines changed

ui/.eslintrc.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,9 @@ module.exports = {
3636
semi: ["error", "always"],
3737
"object-curly-spacing": ["error", "always"],
3838
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
39+
"@typescript-eslint/no-explicit-any": "off",
40+
"@typescript-eslint/no-unsafe-assignment": "off",
41+
"@typescript-eslint/no-unsafe-member-access": "off",
42+
"@typescript-eslint/no-redundant-type-constituents": "off"
3943
},
4044
};

ui/components/Flow.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
UpdateRegistrationFlowBody,
99
UpdateSettingsFlowBody,
1010
UpdateVerificationFlowBody,
11+
VerificationFlow,
1112
} from "@ory/client";
1213
import { getNodeId, isUiNodeInputAttributes } from "@ory/integrations/ui";
1314
import React, { Component, FormEvent } from "react";
@@ -25,13 +26,14 @@ export type Methods =
2526
| "oidc"
2627
| "password"
2728
| "code"
29+
| "profile"
2830
| "totp"
2931
| "webauthn"
3032
| "lookup_secret";
3133

3234
export interface Props<T> {
3335
// The flow
34-
flow?: LoginFlow | RecoveryFlow | SettingsFlow;
36+
flow?: LoginFlow | RecoveryFlow | SettingsFlow | VerificationFlow;
3537
// Only show certain nodes. We will always render the default nodes for CSRF tokens.
3638
only?: Methods;
3739
// Is triggered on submission

ui/components/NodeInputSubmit.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const NodeInputSubmit: FC<NodeInputProps> = ({
2525
return node.group === "password" ||
2626
node.group === "code" ||
2727
node.group === "totp" ||
28+
node.group === "profile" ||
2829
node.group === "webauthn" ||
2930
node.group === "lookup_secret"
3031
? "positive"

ui/components/NodeInputText.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getNodeLabel } from "@ory/integrations/ui";
22
import { Input } from "@canonical/react-components";
3-
import React, { FC } from "react";
3+
import React, { FC, useEffect, useMemo } from "react";
44
import { NodeInputProps } from "./helpers";
55

66
export const NodeInputText: FC<NodeInputProps> = ({
@@ -12,7 +12,9 @@ export const NodeInputText: FC<NodeInputProps> = ({
1212
dispatchSubmit,
1313
error,
1414
}) => {
15-
const [inputValue, setInputValue] = React.useState(attributes.value as string);
15+
const [inputValue, setInputValue] = React.useState(
16+
attributes.value as string,
17+
);
1618

1719
const urlParams = new URLSearchParams(window.location.search);
1820
const isWebauthn = urlParams.get("webauthn") === "true";
@@ -27,10 +29,17 @@ export const NodeInputText: FC<NodeInputProps> = ({
2729
}
2830
const isDuplicate = deduplicateValues.includes(value as string);
2931

30-
const message = node.messages.map(({ text }) => text).join(" ");
31-
if (message) {
32-
setInputValue(message);
33-
}
32+
const message = useMemo(
33+
() => node.messages.map(({ text }) => text).join(" "),
34+
[node.messages],
35+
);
36+
37+
useEffect(() => {
38+
if (message) {
39+
setInputValue(message);
40+
}
41+
}, [message, setInputValue]);
42+
3443
const getError = () => {
3544
if (message.startsWith("Invalid login method")) {
3645
return "Invalid login method";
@@ -63,7 +72,11 @@ export const NodeInputText: FC<NodeInputProps> = ({
6372
disabled={disabled}
6473
value={inputValue}
6574
error={getError()}
66-
onChange={(e) => void setValue(e.target.value)}
75+
onChange={(e) => {
76+
const newValue = e.target.value;
77+
setInputValue(newValue);
78+
void setValue(newValue);
79+
}}
6780
onKeyDown={(e) => {
6881
if (e.key === "Enter") {
6982
e.preventDefault();

ui/pages/verification.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { NextPage } from "next";
2+
import React, { useCallback, useEffect, useState } from "react";
3+
import {
4+
UiNode,
5+
UiNodeInputAttributes,
6+
UpdateVerificationFlowBody,
7+
UpdateVerificationFlowWithCodeMethod,
8+
VerificationFlow,
9+
} from "@ory/client";
10+
import { NextRouter, useRouter } from "next/router";
11+
import { handleFlowError } from "../util/handleFlowError";
12+
import { kratos } from "../api/kratos";
13+
import { Flow } from "../components/Flow";
14+
import PageLayout from "../components/PageLayout";
15+
import { Spinner } from "@canonical/react-components";
16+
import { AxiosError } from "axios";
17+
18+
function setFlowIDQueryParam(router: NextRouter, flowId: string) {
19+
void router.push(
20+
{
21+
pathname: router.pathname,
22+
query: { ...router.query, flow: flowId },
23+
},
24+
undefined,
25+
{ shallow: true },
26+
);
27+
}
28+
29+
const Verification: NextPage = () => {
30+
const [flow, setFlow] = useState<VerificationFlow>();
31+
const router = useRouter();
32+
const {
33+
return_to: returnTo,
34+
flow: flowId,
35+
code: verificationCode,
36+
} = router.query;
37+
38+
const redirectToErrorPage = () => {
39+
const idParam = flowId ? `?id=${flowId.toString()}` : "";
40+
window.location.href = `./error${idParam}`;
41+
};
42+
43+
useEffect(() => {
44+
if (!router.isReady || flow) {
45+
return;
46+
}
47+
48+
if (flowId) {
49+
kratos
50+
.getVerificationFlow({ id: String(flowId) })
51+
.then(({ data }) => {
52+
if (verificationCode) {
53+
const predicate = (node: UiNode) =>
54+
node.group === "code" &&
55+
node.type === "input" &&
56+
(node.attributes as UiNodeInputAttributes).name === "code";
57+
const codeUiNode = data.ui.nodes.find(predicate);
58+
if (codeUiNode) {
59+
(codeUiNode.attributes as UiNodeInputAttributes).value =
60+
String(verificationCode);
61+
}
62+
}
63+
setFlowIDQueryParam(router, data.id);
64+
setFlow(data);
65+
})
66+
.catch(handleFlowError("verification", setFlow))
67+
.catch(redirectToErrorPage);
68+
69+
return;
70+
}
71+
72+
kratos
73+
.createBrowserVerificationFlow({
74+
returnTo: returnTo ? String(returnTo) : undefined,
75+
})
76+
.then(({ data }) => {
77+
setFlow(data);
78+
setFlowIDQueryParam(router, String(data.id));
79+
})
80+
.catch(handleFlowError("verification", setFlow))
81+
.catch(redirectToErrorPage);
82+
}, [flowId, router, router.isReady, returnTo]);
83+
84+
const handleSubmit = useCallback(
85+
(values: UpdateVerificationFlowBody) => {
86+
return kratos
87+
.updateVerificationFlow({
88+
flow: String(flow?.id),
89+
updateVerificationFlowBody: {
90+
...(values as UpdateVerificationFlowWithCodeMethod),
91+
method: "code",
92+
},
93+
})
94+
.then(({ data }) => {
95+
if ("continue_with" in data) {
96+
const continue_with: any = (
97+
data as { continue_with: Array<{ action: string } & any> }
98+
).continue_with[0];
99+
if (continue_with.action === "redirect_browser_to") {
100+
window.location.href = continue_with.redirect_browser_to;
101+
}
102+
return;
103+
}
104+
setFlow(data);
105+
})
106+
.catch(handleFlowError("verification", setFlow))
107+
.catch((err: AxiosError<VerificationFlow>) => {
108+
if (err.response?.status === 400) {
109+
setFlow(err.response.data);
110+
return;
111+
}
112+
return Promise.reject(err);
113+
});
114+
},
115+
[flow],
116+
);
117+
118+
if (!flow) {
119+
return null;
120+
}
121+
122+
return (
123+
<PageLayout title="Verify your email">
124+
{flow ? <Flow onSubmit={handleSubmit} flow={flow} /> : <Spinner />}
125+
</PageLayout>
126+
);
127+
};
128+
129+
export default Verification;

0 commit comments

Comments
 (0)