@owlmeans/web-oauth 0.1.18-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/web-oauth/SKILL.md +131 -0
- package/build/@/components/ui/button.d.ts +11 -0
- package/build/@/components/ui/button.d.ts.map +1 -0
- package/build/@/components/ui/button.js +32 -0
- package/build/@/components/ui/button.js.map +1 -0
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/input.d.ts +4 -0
- package/build/@/components/ui/input.d.ts.map +1 -0
- package/build/@/components/ui/input.js +7 -0
- package/build/@/components/ui/input.js.map +1 -0
- package/build/@/components/ui/label.d.ts +5 -0
- package/build/@/components/ui/label.d.ts.map +1 -0
- package/build/@/components/ui/label.js +8 -0
- package/build/@/components/ui/label.js.map +1 -0
- package/build/@/lib/utils.d.ts +3 -0
- package/build/@/lib/utils.d.ts.map +1 -0
- package/build/@/lib/utils.js +6 -0
- package/build/@/lib/utils.js.map +1 -0
- package/build/append.d.ts +14 -0
- package/build/append.d.ts.map +1 -0
- package/build/append.js +19 -0
- package/build/append.js.map +1 -0
- package/build/components/consent-screen.d.ts +12 -0
- package/build/components/consent-screen.d.ts.map +1 -0
- package/build/components/consent-screen.js +30 -0
- package/build/components/consent-screen.js.map +1 -0
- package/build/components/device-screen.d.ts +10 -0
- package/build/components/device-screen.d.ts.map +1 -0
- package/build/components/device-screen.js +48 -0
- package/build/components/device-screen.js.map +1 -0
- package/build/components/done-screen.d.ts +8 -0
- package/build/components/done-screen.d.ts.map +1 -0
- package/build/components/done-screen.js +18 -0
- package/build/components/done-screen.js.map +1 -0
- package/build/components/index.d.ts +4 -0
- package/build/components/index.d.ts.map +1 -0
- package/build/components/index.js +4 -0
- package/build/components/index.js.map +1 -0
- package/build/consts.d.ts +6 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +6 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +8 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +20 -0
- package/build/entrypoints.js.map +1 -0
- package/build/hooks/use-oauth-consent.d.ts +27 -0
- package/build/hooks/use-oauth-consent.d.ts.map +1 -0
- package/build/hooks/use-oauth-consent.js +128 -0
- package/build/hooks/use-oauth-consent.js.map +1 -0
- package/build/i18n/be.json +35 -0
- package/build/i18n/de.json +35 -0
- package/build/i18n/en.json +35 -0
- package/build/i18n/es.json +35 -0
- package/build/i18n/pl.json +35 -0
- package/build/i18n/ru.json +35 -0
- package/build/i18n/uk.json +35 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +17 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/components.json +21 -0
- package/package.json +77 -0
- package/src/@/components/ui/button.tsx +60 -0
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/input.tsx +22 -0
- package/src/@/components/ui/label.tsx +23 -0
- package/src/@/globals.css +57 -0
- package/src/@/lib/utils.ts +6 -0
- package/src/append.ts +24 -0
- package/src/components/consent-screen.tsx +95 -0
- package/src/components/device-screen.tsx +81 -0
- package/src/components/done-screen.tsx +30 -0
- package/src/components/index.ts +3 -0
- package/src/consts.ts +6 -0
- package/src/entrypoints.ts +22 -0
- package/src/hooks/use-oauth-consent.ts +163 -0
- package/src/i18n/be.json +35 -0
- package/src/i18n/de.json +35 -0
- package/src/i18n/en.json +35 -0
- package/src/i18n/es.json +35 -0
- package/src/i18n/pl.json +35 -0
- package/src/i18n/ru.json +35 -0
- package/src/i18n/uk.json +35 -0
- package/src/i18n.ts +18 -0
- package/src/index.ts +6 -0
- package/tests/consent.spec.ts +260 -0
- package/tests/context.ts +48 -0
- package/tests/device.spec.ts +64 -0
- package/tests/done.spec.ts +41 -0
- package/tests/harness/css.d.ts +1 -0
- package/tests/harness/frame.html +15 -0
- package/tests/harness/index.html +11 -0
- package/tests/harness/mount.tsx +89 -0
- package/tests/helpers.ts +97 -0
- package/tests/i18n.spec.ts +41 -0
- package/tests/languages.spec.ts +38 -0
- package/tests/package-boundary.spec.ts +19 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// shadcn button — sourced from shadcn (new-york) 2026-05-23
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../lib/utils.js'
|
|
7
|
+
|
|
8
|
+
const buttonVariants = cva(
|
|
9
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default:
|
|
14
|
+
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
15
|
+
destructive:
|
|
16
|
+
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
17
|
+
outline:
|
|
18
|
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
19
|
+
secondary:
|
|
20
|
+
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
|
21
|
+
ghost:
|
|
22
|
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
23
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
24
|
+
},
|
|
25
|
+
size: {
|
|
26
|
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
27
|
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
|
28
|
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
29
|
+
icon: "size-9",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
variant: "default",
|
|
34
|
+
size: "default",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
function Button({
|
|
40
|
+
className,
|
|
41
|
+
variant,
|
|
42
|
+
size,
|
|
43
|
+
asChild = false,
|
|
44
|
+
...props
|
|
45
|
+
}: React.ComponentProps<"button"> &
|
|
46
|
+
VariantProps<typeof buttonVariants> & {
|
|
47
|
+
asChild?: boolean
|
|
48
|
+
}) {
|
|
49
|
+
const Comp = asChild ? Slot : "button"
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Comp
|
|
53
|
+
data-slot="button"
|
|
54
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// shadcn card — sourced from shadcn (new-york) 2026-05-23
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/utils.js'
|
|
5
|
+
|
|
6
|
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
data-slot="card"
|
|
10
|
+
className={cn(
|
|
11
|
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
data-slot="card-header"
|
|
23
|
+
className={cn(
|
|
24
|
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-slot="card-title"
|
|
36
|
+
className={cn("leading-none font-semibold", className)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
data-slot="card-description"
|
|
46
|
+
className={cn("text-muted-foreground text-sm", className)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="card-action"
|
|
56
|
+
className={cn(
|
|
57
|
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
58
|
+
className
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
data-slot="card-content"
|
|
69
|
+
className={cn("px-6", className)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
data-slot="card-footer"
|
|
79
|
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
80
|
+
{...props}
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
Card,
|
|
87
|
+
CardHeader,
|
|
88
|
+
CardFooter,
|
|
89
|
+
CardTitle,
|
|
90
|
+
CardAction,
|
|
91
|
+
CardDescription,
|
|
92
|
+
CardContent,
|
|
93
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// shadcn input — sourced from shadcn (new-york) 2026-05-23
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/utils.js'
|
|
5
|
+
|
|
6
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
7
|
+
return (
|
|
8
|
+
<input
|
|
9
|
+
type={type}
|
|
10
|
+
data-slot="input"
|
|
11
|
+
className={cn(
|
|
12
|
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
13
|
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
14
|
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
15
|
+
className
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { Input }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// shadcn label — sourced from shadcn (new-york) 2026-05-23
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../lib/utils.js'
|
|
6
|
+
|
|
7
|
+
function Label({
|
|
8
|
+
className,
|
|
9
|
+
...props
|
|
10
|
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
11
|
+
return (
|
|
12
|
+
<LabelPrimitive.Root
|
|
13
|
+
data-slot="label"
|
|
14
|
+
className={cn(
|
|
15
|
+
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { Label }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@source "../..";
|
|
4
|
+
|
|
5
|
+
@theme {
|
|
6
|
+
--color-background: oklch(1 0 0);
|
|
7
|
+
--color-foreground: oklch(0.145 0 0);
|
|
8
|
+
|
|
9
|
+
--color-card: oklch(1 0 0);
|
|
10
|
+
--color-card-foreground: oklch(0.145 0 0);
|
|
11
|
+
|
|
12
|
+
--color-popover: oklch(1 0 0);
|
|
13
|
+
--color-popover-foreground: oklch(0.145 0 0);
|
|
14
|
+
|
|
15
|
+
--color-primary: oklch(0.205 0 0);
|
|
16
|
+
--color-primary-foreground: oklch(0.985 0 0);
|
|
17
|
+
|
|
18
|
+
--color-secondary: oklch(0.97 0 0);
|
|
19
|
+
--color-secondary-foreground: oklch(0.205 0 0);
|
|
20
|
+
|
|
21
|
+
--color-muted: oklch(0.97 0 0);
|
|
22
|
+
--color-muted-foreground: oklch(0.556 0 0);
|
|
23
|
+
|
|
24
|
+
--color-accent: oklch(0.97 0 0);
|
|
25
|
+
--color-accent-foreground: oklch(0.205 0 0);
|
|
26
|
+
|
|
27
|
+
--color-destructive: oklch(0.577 0.245 27.325);
|
|
28
|
+
--color-success: oklch(0.62 0.18 145);
|
|
29
|
+
|
|
30
|
+
--color-border: oklch(0.922 0 0);
|
|
31
|
+
--color-input: oklch(0.922 0 0);
|
|
32
|
+
--color-ring: oklch(0.708 0 0);
|
|
33
|
+
|
|
34
|
+
--radius: 0.625rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.dark {
|
|
38
|
+
--color-background: oklch(0.145 0 0);
|
|
39
|
+
--color-foreground: oklch(0.985 0 0);
|
|
40
|
+
--color-card: oklch(0.205 0 0);
|
|
41
|
+
--color-card-foreground: oklch(0.985 0 0);
|
|
42
|
+
--color-popover: oklch(0.205 0 0);
|
|
43
|
+
--color-popover-foreground: oklch(0.985 0 0);
|
|
44
|
+
--color-primary: oklch(0.922 0 0);
|
|
45
|
+
--color-primary-foreground: oklch(0.205 0 0);
|
|
46
|
+
--color-secondary: oklch(0.269 0 0);
|
|
47
|
+
--color-secondary-foreground: oklch(0.985 0 0);
|
|
48
|
+
--color-muted: oklch(0.269 0 0);
|
|
49
|
+
--color-muted-foreground: oklch(0.708 0 0);
|
|
50
|
+
--color-accent: oklch(0.269 0 0);
|
|
51
|
+
--color-accent-foreground: oklch(0.985 0 0);
|
|
52
|
+
--color-destructive: oklch(0.704 0.191 22.216);
|
|
53
|
+
--color-success: oklch(0.7 0.18 145);
|
|
54
|
+
--color-border: oklch(1 0 0 / 10%);
|
|
55
|
+
--color-input: oklch(1 0 0 / 15%);
|
|
56
|
+
--color-ring: oklch(0.556 0 0);
|
|
57
|
+
}
|
package/src/append.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { FLOW_STATE } from '@owlmeans/client-flow'
|
|
2
|
+
import type { ClientContext } from '@owlmeans/client-context'
|
|
3
|
+
import type { ClientConfig } from '@owlmeans/client-context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fail fast when the one precondition these screens actually depend on is missing, rather than
|
|
7
|
+
* deep inside `suspendFlow` the first time somebody who is not signed in opens a consent screen.
|
|
8
|
+
*
|
|
9
|
+
* There is nothing else to register here: the three screens are bound through `oauthEntrypoints`
|
|
10
|
+
* (spread into the application's own entrypoint list, exactly like every other screen package in
|
|
11
|
+
* this framework), and the translations register themselves as a side effect of importing this
|
|
12
|
+
* package's barrel — `appendOAuthScreens` is the documented call an application makes, but the
|
|
13
|
+
* strings are already there whether it is called or not.
|
|
14
|
+
*/
|
|
15
|
+
export const appendOAuthScreens = <C extends ClientConfig, T extends ClientContext<C>>(context: T): T => {
|
|
16
|
+
if (!context.hasResource(FLOW_STATE)) {
|
|
17
|
+
throw new SyntaxError(
|
|
18
|
+
'web-oauth: no FLOW_STATE resource registered. Call appendFlowService (@owlmeans/web-flow) — '
|
|
19
|
+
+ 'or whatever registers it in this app\'s makeContext — before appendOAuthScreens.'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return context
|
|
24
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useContext } from '@owlmeans/client'
|
|
2
|
+
import type { RoutedComponent } from '@owlmeans/client'
|
|
3
|
+
import { useI18nLib } from '@owlmeans/client-i18n'
|
|
4
|
+
import { OAUTH_I18N } from '../consts.js'
|
|
5
|
+
import { useOAuthConsent } from '../hooks/use-oauth-consent.js'
|
|
6
|
+
import { Button } from '../@/components/ui/button.js'
|
|
7
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../@/components/ui/card.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The device/authorization-code consent screen. Full-page, no application chrome — the same
|
|
11
|
+
* shape `/dispatcher` and `/iam/interact/:uid` already render in, because a sign-in round trip is
|
|
12
|
+
* never a panel inside the application's own navigation.
|
|
13
|
+
*
|
|
14
|
+
* The screen refuses to render when framed: a device or code approval is exactly the click a
|
|
15
|
+
* clickjacking overlay would want to steal, and nothing about this decision needs to happen
|
|
16
|
+
* inside a frame.
|
|
17
|
+
*/
|
|
18
|
+
export const OAuthConsentScreen: RoutedComponent = () => {
|
|
19
|
+
const context = useContext()
|
|
20
|
+
const [query] = context.router().useSearchParams()
|
|
21
|
+
const ref = query.get('ref')
|
|
22
|
+
const t = useI18nLib(OAUTH_I18N, 'consent')
|
|
23
|
+
const { stage, view, errorKind, approve, deny, switchAccount } = useOAuthConsent(ref)
|
|
24
|
+
|
|
25
|
+
const framed = typeof window !== 'undefined' && window.top !== window.self
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
|
29
|
+
<Card style={{ width: '100%', maxWidth: 440 }} data-testid="oauth-consent-card">
|
|
30
|
+
{framed ? (
|
|
31
|
+
<CardContent>
|
|
32
|
+
<p role="alert">{t('framed')}</p>
|
|
33
|
+
</CardContent>
|
|
34
|
+
) : stage === 'checking' || stage === 'signing-in' ? (
|
|
35
|
+
<CardContent>
|
|
36
|
+
<p>{t('checking')}</p>
|
|
37
|
+
</CardContent>
|
|
38
|
+
) : stage === 'loading' ? (
|
|
39
|
+
<CardContent>
|
|
40
|
+
<p>{t('loading')}</p>
|
|
41
|
+
</CardContent>
|
|
42
|
+
) : stage === 'error' ? (
|
|
43
|
+
<CardContent>
|
|
44
|
+
<p role="alert" data-testid="oauth-consent-error">{t(errorKind == null || errorKind === 'failed' ? 'error' : `error-${errorKind}`)}</p>
|
|
45
|
+
</CardContent>
|
|
46
|
+
) : view != null ? (
|
|
47
|
+
<>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle data-testid="oauth-consent-client">{t('title', { client: view.client.name })}</CardTitle>
|
|
50
|
+
<CardDescription>
|
|
51
|
+
{view.client.origin === 'cimd' && view.client.host != null
|
|
52
|
+
? t('known-by-host', { host: view.client.host })
|
|
53
|
+
: view.client.origin === 'dcr'
|
|
54
|
+
? t('unverified')
|
|
55
|
+
: t('known')}
|
|
56
|
+
</CardDescription>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
59
|
+
{view.userCode != null && (
|
|
60
|
+
<p data-testid="oauth-consent-code">{t('device-code', { code: view.userCode })}</p>
|
|
61
|
+
)}
|
|
62
|
+
{view.deviceName != null && <p>{t('device-name', { name: view.deviceName })}</p>}
|
|
63
|
+
{view.redirectHost != null && <p>{t('redirect-host', { host: view.redirectHost })}</p>}
|
|
64
|
+
{view.localhostOnly === true && (
|
|
65
|
+
<p role="alert">{t('localhost-warning')}</p>
|
|
66
|
+
)}
|
|
67
|
+
<p>{t('scope', { scopes: view.scopes.join(', ') })}</p>
|
|
68
|
+
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
69
|
+
<Button
|
|
70
|
+
type="button" variant="outline" disabled={stage === 'deciding'}
|
|
71
|
+
data-testid="oauth-consent-deny" onClick={() => void deny()}
|
|
72
|
+
>
|
|
73
|
+
{t('deny')}
|
|
74
|
+
</Button>
|
|
75
|
+
<Button
|
|
76
|
+
type="button" disabled={stage === 'deciding'}
|
|
77
|
+
data-testid="oauth-consent-approve" onClick={() => void approve()}
|
|
78
|
+
autoFocus
|
|
79
|
+
>
|
|
80
|
+
{t('approve')}
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
<Button
|
|
84
|
+
type="button" variant="link" size="sm" disabled={stage === 'deciding'}
|
|
85
|
+
data-testid="oauth-consent-switch" onClick={() => void switchAccount()}
|
|
86
|
+
>
|
|
87
|
+
{t('switch-account')}
|
|
88
|
+
</Button>
|
|
89
|
+
</CardContent>
|
|
90
|
+
</>
|
|
91
|
+
) : null}
|
|
92
|
+
</Card>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import type { FormEvent } from 'react'
|
|
3
|
+
import { useContext, useNavigate } from '@owlmeans/client'
|
|
4
|
+
import type { RoutedComponent } from '@owlmeans/client'
|
|
5
|
+
import { useI18nLib } from '@owlmeans/client-i18n'
|
|
6
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
7
|
+
import { makeFlowModel } from '@owlmeans/flow'
|
|
8
|
+
import { normalizeUserCode, oauthFlow, OAuthFlowStep, OAUTH_PAYLOAD_REF } from '@owlmeans/oauth'
|
|
9
|
+
import { OAUTH_I18N } from '../consts.js'
|
|
10
|
+
import { Button } from '../@/components/ui/button.js'
|
|
11
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../@/components/ui/card.js'
|
|
12
|
+
import { Input } from '../@/components/ui/input.js'
|
|
13
|
+
import { Label } from '../@/components/ui/label.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The device-verification screen (RFC 8628's `verification_uri`). Opened by the MCP, either bare
|
|
17
|
+
* (a person types the code they were shown) or complete (`?user_code=…`, already carrying it).
|
|
18
|
+
*
|
|
19
|
+
* Either way it goes through the SAME flow transition — `verify` → `next` → `consent` — so the
|
|
20
|
+
* two entry shapes converge onto one path before anything else runs.
|
|
21
|
+
*/
|
|
22
|
+
export const OAuthDeviceScreen: RoutedComponent = () => {
|
|
23
|
+
const context = useContext()
|
|
24
|
+
const nav = useNavigate()
|
|
25
|
+
const [query] = context.router().useSearchParams()
|
|
26
|
+
const t = useI18nLib(OAUTH_I18N, 'device')
|
|
27
|
+
const [code, setCode] = useState(query.get('user_code') ?? '')
|
|
28
|
+
|
|
29
|
+
const proceed = useCallback(async (typed: string) => {
|
|
30
|
+
const model = await makeFlowModel(oauthFlow)
|
|
31
|
+
model.enter(OAuthFlowStep.Verify)
|
|
32
|
+
model.updatePayload({ [OAUTH_PAYLOAD_REF]: normalizeUserCode(typed) })
|
|
33
|
+
model.transit('next', true)
|
|
34
|
+
const destination = model.step()
|
|
35
|
+
await nav.navigate(
|
|
36
|
+
context.entrypoint<ClientEntrypoint<string>>(destination.module!), { query: model.payload() }
|
|
37
|
+
)
|
|
38
|
+
}, [nav])
|
|
39
|
+
|
|
40
|
+
const initial = query.get('user_code')
|
|
41
|
+
const complete = initial != null && initial !== ''
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (complete) void proceed(initial)
|
|
45
|
+
}, [complete, initial])
|
|
46
|
+
|
|
47
|
+
const onSubmit = (event: FormEvent) => {
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
void proceed(code)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (complete) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
|
58
|
+
<Card style={{ width: '100%', maxWidth: 380 }} data-testid="oauth-device-card">
|
|
59
|
+
<CardHeader>
|
|
60
|
+
<CardTitle>{t('title')}</CardTitle>
|
|
61
|
+
<CardDescription>{t('description')}</CardDescription>
|
|
62
|
+
</CardHeader>
|
|
63
|
+
<CardContent>
|
|
64
|
+
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
65
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
66
|
+
<Label htmlFor="oauth-device-code">{t('code-label')}</Label>
|
|
67
|
+
<Input
|
|
68
|
+
id="oauth-device-code" data-testid="oauth-device-input" autoComplete="off"
|
|
69
|
+
placeholder="XXXX-XXXX" value={code}
|
|
70
|
+
onChange={event => setCode(event.target.value)}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
<Button type="submit" data-testid="oauth-device-submit" disabled={code.trim() === ''}>
|
|
74
|
+
{t('continue')}
|
|
75
|
+
</Button>
|
|
76
|
+
</form>
|
|
77
|
+
</CardContent>
|
|
78
|
+
</Card>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useContext } from '@owlmeans/client'
|
|
2
|
+
import type { RoutedComponent } from '@owlmeans/client'
|
|
3
|
+
import { useI18nLib } from '@owlmeans/client-i18n'
|
|
4
|
+
import { OAUTH_I18N } from '../consts.js'
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../@/components/ui/card.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The final screen of the device grant — the moment the person's part is finished and the MCP's
|
|
9
|
+
* poll is expected to catch up within the device-authorization's own poll interval. There is
|
|
10
|
+
* nothing here to click and nowhere further to go; the tab's only remaining job is to be closed.
|
|
11
|
+
*/
|
|
12
|
+
export const OAuthDoneScreen: RoutedComponent = () => {
|
|
13
|
+
const context = useContext()
|
|
14
|
+
const [query] = context.router().useSearchParams()
|
|
15
|
+
const t = useI18nLib(OAUTH_I18N, 'done')
|
|
16
|
+
const kind = query.get('kind')
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
|
20
|
+
<Card style={{ width: '100%', maxWidth: 380, textAlign: 'center' }} data-testid="oauth-done-card">
|
|
21
|
+
<CardHeader>
|
|
22
|
+
<CardTitle>{t('title')}</CardTitle>
|
|
23
|
+
</CardHeader>
|
|
24
|
+
<CardContent>
|
|
25
|
+
<p>{kind === 'device' ? t('device-message') : t('message')}</p>
|
|
26
|
+
</CardContent>
|
|
27
|
+
</Card>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The lib-tier i18n resource this package registers its strings under. */
|
|
2
|
+
export const OAUTH_I18N = 'oauth'
|
|
3
|
+
|
|
4
|
+
/** How long a consent screen's suspended sign-in stays valid — generous enough for a person to
|
|
5
|
+
* pick a provider and come back, short enough that a stale suspension is not a lingering hazard. */
|
|
6
|
+
export const OAUTH_SUSPEND_TTL_MS = 10 * 60 * 1000
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { handler } from '@owlmeans/client'
|
|
2
|
+
import { bindScreen } from '@owlmeans/client-entrypoint'
|
|
3
|
+
import { makeOAuthProtocols } from '@owlmeans/oauth'
|
|
4
|
+
import type { OAuthEntrypointOptions } from '@owlmeans/oauth'
|
|
5
|
+
import { OAuthConsentScreen } from './components/consent-screen.js'
|
|
6
|
+
import { OAuthDeviceScreen } from './components/device-screen.js'
|
|
7
|
+
import { OAuthDoneScreen } from './components/done-screen.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The three screens, bound to their components and ready to spread into an application's
|
|
11
|
+
* entrypoint list — the whole of what a web app has to do beyond calling `appendOAuthScreens`
|
|
12
|
+
* and configuring the server package with matching aliases.
|
|
13
|
+
*/
|
|
14
|
+
export const oauthEntrypoints = (opts?: OAuthEntrypointOptions) => {
|
|
15
|
+
const protocols = makeOAuthProtocols(opts)
|
|
16
|
+
|
|
17
|
+
return [
|
|
18
|
+
bindScreen(protocols.consentScreen, handler(OAuthConsentScreen)),
|
|
19
|
+
bindScreen(protocols.deviceScreen, handler(OAuthDeviceScreen)),
|
|
20
|
+
bindScreen(protocols.doneScreen, handler(OAuthDoneScreen)),
|
|
21
|
+
]
|
|
22
|
+
}
|