Skip to content

Commit

Permalink
feat: add OAuth2 consent card
Browse files Browse the repository at this point in the history
  • Loading branch information
mszekiel authored Oct 25, 2022
1 parent 1ab86cb commit a65af2f
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const globalTypes = {
{ value: "light", icon: "circlehollow", title: "light" },
{ value: "dark", icon: "circle", title: "dark" },
],
showName: true,
name: true,
},
},
}
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
},
"devDependencies": {
"@babel/core": "^7.18.10",
"@ory/client": "^0.2.0-alpha.48",
"@ory/client": "^0.2.0-alpha.60",
"@ory/elements": "*",
"@ory/elements-test": "*",
"@ory/integrations": "^0.2.7",
Expand Down
6 changes: 6 additions & 0 deletions src/markup-components/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ import {
UserSettingsCardProps,
WebAuthnSettingsProps,
WebAuthnSettingsSection as webAuthnSettingsSection,
UserConsentCard as userConsentCard,
} from "../react-components"
import { CodeBoxProps } from "../react-components/codebox"
import { ComponentProps } from "react"
import { ComponentWrapper } from "./component-wrapper"

export const ButtonLink = (props: ButtonLinkProps) => {
Expand Down Expand Up @@ -154,6 +156,10 @@ export const LookupSecretSettingsSection = (
return ComponentWrapper(lookupSecretSettingsSection(props))
}

export const UserConsentCard = (
props: ComponentProps<typeof userConsentCard>,
) => ComponentWrapper(userConsentCard(props))

export type {
ButtonLinkProps,
ButtonProps,
Expand Down
1 change: 1 addition & 0 deletions src/react-components/ory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from "./sections/webauthn-settings-section"
export * from "./user-auth-card"
export * from "./user-error-card"
export * from "./user-settings-card"
export * from "./user-consent-card"
38 changes: 38 additions & 0 deletions src/react-components/ory/user-consent-card.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { OAuth2ConsentRequest } from "@ory/client"
import {
AuthPage,
loginFixture,
recoveryFixture,
registrationFixture,
twoFactorLoginFixture,
verificationFixture,
} from "@ory/elements-test"
import { expect, test } from "@playwright/experimental-ct-react"
import { ComponentProps } from "react"
import { ConsentPage } from "../../test/ConsentPage"
import { UserConsentCard } from "./user-consent-card"

test("ory consent card login flow", async ({ mount }) => {
const defaults = {
csrfToken: "csrfToken_example",
action: "consent",
client_name: "Best App",
client: {
tos_uri: "https://test_tos_uri/",
policy_uri: "https://test_policy_uri/",
},
consent: {} as OAuth2ConsentRequest,
requested_scope: ["test_scope1", "test_scope2", "test_scope3"],
}

const component = await mount(<UserConsentCard {...defaults} />)

const consentComponent = new ConsentPage(component)

await consentComponent.expectScopeFields(defaults.requested_scope)
await consentComponent.expectUris([
"https://test_tos_uri/",
"https://test_policy_uri/",
])
await consentComponent.expectAllowSubmit()
})
112 changes: 112 additions & 0 deletions src/react-components/ory/user-consent-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { gridStyle, typographyStyle } from "../../theme"
import { Button } from "../button"
import { ButtonLink } from "../button-link"
import { Card } from "../card"
import { Typography } from "../typography"

import "../../assets/fontawesome.min.css"
import "../../assets/fa-solid.min.css"
import { Checkbox } from "../checkbox"
import { OAuth2Client, OAuth2ConsentRequest } from "@ory/client"
import { style } from "@vanilla-extract/css"
import { recipe } from "@vanilla-extract/recipes"
import { Divider } from "../divider"

export type UserConsentCardProps = {
csrfToken: string
consent: OAuth2ConsentRequest
cardImage?: string | React.ReactElement
client_name: string
requested_scope?: string[]
client?: OAuth2Client
action: string
}

export const UserConsentCard = ({
csrfToken,
consent,
cardImage,
client_name = "Unnamed Client",
requested_scope = [],
client,
action,
}: UserConsentCardProps) => {
return (
<Card
heading={
<div style={{ textAlign: "center" }}>
<Typography type="bold">{client_name}</Typography>
</div>
}
image={cardImage}
>
<form action={action} method="post">
<input type="hidden" name="_csrf" value={csrfToken} />
<input
type="hidden"
name="consent_challenge"
value={consent?.challenge}
/>
<div className={gridStyle({ gap: 16 })}>
<div className={gridStyle({ gap: 4 })} style={{ marginBottom: 16 }}>
<Typography>
The application requests access to the following permissions:
</Typography>
</div>
<div className={gridStyle({ gap: 4 })}>
{requested_scope.map((scope) => (
<Checkbox label={scope} value={scope} name="grant_scope" />
))}
</div>
<div className={gridStyle({ gap: 4 })}>
<Typography size="xsmall">
Only grant permissions if you trust this site or app. You do not
need to accept all permissions.
</Typography>
</div>
<div className={gridStyle({ direction: "row" })}>
{client?.policy_uri && (
<a href={client.policy_uri} target="_blank">
<Typography size="xsmall">Privacy Policy</Typography>
</a>
)}
{client?.tos_uri && (
<a href={client.tos_uri} target="_blank">
<Typography size="xsmall">Terms of Service</Typography>
</a>
)}
</div>
<Divider />
<div className={gridStyle({ gap: 8 })}>
<Checkbox label="Remember my decision" />
<Typography size="xsmall">
Remember this decision for next time. The application will not be
able to ask for additional permissions without your consent.
</Typography>
</div>
<div
className={gridStyle({ direction: "row" })}
style={{ justifyContent: "space-between", alignItems: "center" }}
>
<Button
type="submit"
id="reject"
name="consent_action"
value="reject"
variant="error"
header="Deny"
/>
<Button
type="submit"
id="accept"
name="consent_action"
value="accept"
variant="semibold"
header="Allow"
/>
</div>
</div>
</form>
</Card>
)
}
26 changes: 26 additions & 0 deletions src/stories/Ory/Consent.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ComponentProps } from "react"
import { ComponentMeta, Story } from "@storybook/react"

import { Container } from "../storyhelper"
import { UserConsentCard } from "../../react-components"
import logo from "../assets/logo.svg"

export default {
title: "Ory/UserConsentCard",
component: UserConsentCard,
} as ComponentMeta<typeof UserConsentCard>

const Template: Story<ComponentProps<typeof UserConsentCard>> = (
args: ComponentProps<typeof UserConsentCard>,
) => (
<Container>
<UserConsentCard {...args} />
</Container>
)

export const ConsentCard = Template.bind({})
ConsentCard.args = {
cardImage: logo,
requested_scope: ["openid", "test_scope"],
client: { tos_uri: "example.com/", policy_uri: "example.com/" },
}
37 changes: 37 additions & 0 deletions src/test/ConsentPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { UiNode } from "@ory/client"
import { expect, Locator } from "@playwright/test"
import { Traits } from "./types"
import { inputNodesToRecord, isUiNode } from "./Utils"

export class ConsentPage {
readonly locator: Locator
readonly formFields: Record<string, Locator> = {}

constructor(locator: Locator) {
this.locator = locator
}

async expectScopeFields(scopes: string[]) {
for (const scope of scopes) {
await expect(
this.locator.locator(`input[type="checkbox"][value="${scope}"]`),
).toBeVisible()
}
}

async expectUris(uris: string[]) {
for (const uri of uris) {
await expect(this.locator.locator(`a[href="${uri}"]`)).toBeVisible()
}
}

async expectAllowSubmit() {
await expect(
this.locator.locator('button[value="accept"][type="submit"]'),
).toBeVisible()
}

async submitForm() {
await this.locator.locator('[type="submit"]').click()
}
}
23 changes: 23 additions & 0 deletions src/theme/button.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ export const buttonStyle = recipe({
fontWeight: 600,
fontStyle: "normal",
},
outline: {
background: "none",
color: oryTheme.text.disabled,
":hover": {
color: oryTheme.text.def,
},
},
error: {
background: "none",
color: oryTheme.error.muted,
":hover": {
background: oryTheme.error.subtle,
},
":active": {
backgroundColor: oryTheme.error.emphasis,
color: oryTheme.error.def,
outline: "none",
},
":focus": {
background: "none",
color: oryTheme.error.def,
},
},
},
},
})
Expand Down
9 changes: 6 additions & 3 deletions src/theme/checkbox.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,18 @@ export const checkboxInputStyle = style({
margin: pxToRem(3),
color: oryTheme.accent.def,
selectors: {
"&:checked": {
background: oryTheme.accent.def,
},
"&:checked::before": {
fontFamily: "'Font Awesome 6 Free'", // this is required for the fontawesome icon to work
fontSize: pxToRem(10),
fontSize: pxToRem(13),
display: "block",
textAlign: "center",
position: "relative",
content: "\\f00c", // this is a fontawesome unicode character to switch back to a basic html checkmark use \\2713
color: oryTheme.accent.def,
top: pxToRem(2.5),
color: "white",
fontWeight: "900",
},
},
":disabled": {
Expand Down

0 comments on commit a65af2f

Please sign in to comment.