Skip to content
Open
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
2 changes: 1 addition & 1 deletion docker/kratos/kratos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ selfservice:
login:
ui_url: http://localhost:4455/ui/login
# TODO: Uncomment when the pattern is implemented in FE
# style: identifier_first
style: identifier_first
recovery:
enabled: True
ui_url: http://localhost:4455/ui/reset_email
Expand Down
32 changes: 31 additions & 1 deletion ui/api/kratos.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Configuration, FrontendApi } from "@ory/client";
import { Configuration, FrontendApi, UpdateLoginFlowBody, LoginFlow } from "@ory/client";

export const kratos = new FrontendApi(
new Configuration({
Expand All @@ -9,3 +9,33 @@ export const kratos = new FrontendApi(
},
})
);

type IdentifierFirstResponse = { redirect_to: string } | LoginFlow;

export async function loginIdentifierFirst(
flowId: string,
values: UpdateLoginFlowBody,
method: string,
flow?: { id?: string; return_to?: string }
) {
const res = await fetch(
`/api/kratos/self-service/login/id-first?flow=${encodeURIComponent(flowId)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...values,
method,
flow: String(flow?.id),
}),
},
);

if (!res.ok) {
throw new Error(await res.text());
}

return (await res.json()) as IdentifierFirstResponse;
}
1 change: 1 addition & 0 deletions ui/components/Flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type Values = Partial<

export type Methods =
| "oidc"
| "identifier_first"
| "password"
| "code"
| "totp"
Expand Down
90 changes: 57 additions & 33 deletions ui/components/NodeInputPassword.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { getNodeLabel } from "@ory/integrations/ui";
import { Input } from "@canonical/react-components";
import React, { FC } from "react";
import React, { FC, useState, ChangeEvent, KeyboardEvent } from "react";
import { NodeInputProps } from "./helpers";
import PasswordToggle from "./PasswordToggle";

function getLoginStartUrl(): string {
const url = new URL(window.location.href);
url.pathname = "/ui/login";
url.searchParams.delete("flow");
return url.pathname + url.search;
}

export const NodeInputPassword: FC<NodeInputProps> = ({
node,
Expand All @@ -10,37 +17,54 @@ export const NodeInputPassword: FC<NodeInputProps> = ({
dispatchSubmit,
error,
}) => {
const [password, setPassword] = useState("");

const getError = () => {
const errorMessage = error?.toLowerCase() ?? "";
if (errorMessage.includes("invalid password")) {
return "Please enter your password.";
}

if (errorMessage.includes("incorrect username or password")) {
return "Incorrect password. Please try again.";
}
};

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
void setValue(e.target.value);
};

const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
void dispatchSubmit(e, "password");
}
};

return (
<>
<Input
type="password"
tabIndex={2}
label={
<>
<span>{getNodeLabel(node)}</span>
<a
href={`./reset_email?return_to=${window.location.href}`}
style={{ float: "right" }}
tabIndex={3}
>
Reset password
</a>
</>
}
labelClassName="password-label"
disabled={disabled}
defaultValue={node.messages.map(({ text }) => text).join(" ")}
error={error}
onChange={(e) => void setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
void dispatchSubmit(e, "password");
}
}}
/>
<hr />
</>
<PasswordToggle
tabIndex={2}
label={
<>
<span>{getNodeLabel(node)}</span>
<a
href={`./reset_email?return_to=${encodeURIComponent(getLoginStartUrl())}`}
style={{ float: "right" }}
tabIndex={3}
>
Reset password
</a>
</>
}
labelClassName="password-label"
value={password}
disabled={disabled}
placeholder="Your Password"
error={getError()}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
);
};
21 changes: 20 additions & 1 deletion ui/components/NodeInputSubmit.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { getNodeLabel } from "@ory/integrations/ui";
import { Button } from "@canonical/react-components";
import { Button, Link } from "@canonical/react-components";
import { NodeInputProps } from "./helpers";
import React, { Component, FC } from "react";
import { getProviderImage } from "../util/logos";
import { ORY_LABEL_CONTINUE_IDENTIFIER_FIRST_LOGIN } from "../util/constants";

export const NodeInputSubmit: FC<NodeInputProps> = ({
node,
Expand All @@ -24,6 +25,7 @@ export const NodeInputSubmit: FC<NodeInputProps> = ({
}
return node.group === "password" ||
node.group === "code" ||
node.group === "identifier_first" ||
node.group === "totp" ||
node.group === "webauthn" ||
node.group === "lookup_secret"
Expand All @@ -37,6 +39,22 @@ export const NodeInputSubmit: FC<NodeInputProps> = ({
}
)?.onClick;

const renderRegistrationCta = () => {
const isIdentifierFirstSubmit =
node.group === "identifier_first" &&
attributes.type === "submit" &&
node.meta.label?.id === ORY_LABEL_CONTINUE_IDENTIFIER_FIRST_LOGIN;

if (!isIdentifierFirstSubmit) return null;

return (
<p className="registration-cta">
Don&apos;t have an account?{" "}
<Link href="/ui/register_email">Register</Link>
</p>
);
};

const beforeComponent = (
node.meta.label?.context as {
beforeComponent: Component;
Expand Down Expand Up @@ -85,6 +103,7 @@ export const NodeInputSubmit: FC<NodeInputProps> = ({
)}
</Button>
{afterComponent}
{renderRegistrationCta()}
</>
);
};
54 changes: 50 additions & 4 deletions ui/components/NodeInputText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getNodeLabel } from "@ory/integrations/ui";
import { Input } from "@canonical/react-components";
import React, { FC } from "react";
import { NodeInputProps } from "./helpers";
import { ORY_ERR_ACCOUNT_NOT_FOUND_OR_NO_LOGIN_METHOD } from "../util/constants";

export const NodeInputText: FC<NodeInputProps> = ({
attributes,
Expand All @@ -14,6 +15,9 @@ export const NodeInputText: FC<NodeInputProps> = ({
}) => {
const urlParams = new URLSearchParams(window.location.search);
const isWebauthn = urlParams.get("webauthn") === "true";
const isIdentifierFirstGroup = node.group === "identifier_first";
const isEmailInput = node.meta?.label?.text?.toLowerCase?.() === "email";

const ucFirst = (s?: string) =>
s ? String(s[0]).toUpperCase() + String(s).slice(1) : s;

Expand All @@ -26,11 +30,42 @@ export const NodeInputText: FC<NodeInputProps> = ({
const isDuplicate = deduplicateValues.includes(value as string);

const message = node.messages.map(({ text }) => text).join(" ");
const defaultValue = message.includes("Invalid login method")
? (value as string)
: message;
const defaultValue =
message.includes("Invalid login method") || isIdentifierFirstGroup
? (value as string)
: message;

const getError = () => {
const currentValue = (typeof value === "string" && value) || "";
if (
isIdentifierFirstGroup &&
isEmailInput &&
attributes.value === currentValue
) {
const serverErrorNode = node.messages.find((m) => m.type === "error");

if (!serverErrorNode) return undefined;

const serverErrorId = serverErrorNode?.id;
const serverErrorText = serverErrorNode?.text ?? "";

if (currentValue.length === 0) {
return "Please enter your email address.";
}

if (!/^\S+@\S+\.\S+$/.test(currentValue)) {
return "Enter a valid email address.";
}

if (serverErrorId === ORY_ERR_ACCOUNT_NOT_FOUND_OR_NO_LOGIN_METHOD) {
return "No account found for this email. Verify or create a new account.";
}

if (serverErrorText) {
return serverErrorText;
}
}

if (message.startsWith("Invalid login method")) {
return "Invalid login method";
}
Expand All @@ -52,6 +87,13 @@ export const NodeInputText: FC<NodeInputProps> = ({
return undefined;
};

const getPlaceholderText = () => {
if (isIdentifierFirstGroup && isEmailInput) {
return "Your Email";
}
return "";
};

return (
<Input
type="text"
Expand All @@ -60,14 +102,18 @@ export const NodeInputText: FC<NodeInputProps> = ({
name={attributes.name}
label={getNodeLabel(node)}
disabled={disabled}
placeholder={getPlaceholderText()}
defaultValue={defaultValue}
error={getError()}
onChange={(e) => void setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
void dispatchSubmit(e, "password");
void dispatchSubmit(
e,
isIdentifierFirstGroup ? "identifier_first" : "password",
);
}
}}
/>
Expand Down
Loading
Loading