@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.
Files changed (109) hide show
  1. package/README.md +15 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/web-oauth/SKILL.md +131 -0
  4. package/build/@/components/ui/button.d.ts +11 -0
  5. package/build/@/components/ui/button.d.ts.map +1 -0
  6. package/build/@/components/ui/button.js +32 -0
  7. package/build/@/components/ui/button.js.map +1 -0
  8. package/build/@/components/ui/card.d.ts +10 -0
  9. package/build/@/components/ui/card.d.ts.map +1 -0
  10. package/build/@/components/ui/card.js +25 -0
  11. package/build/@/components/ui/card.js.map +1 -0
  12. package/build/@/components/ui/input.d.ts +4 -0
  13. package/build/@/components/ui/input.d.ts.map +1 -0
  14. package/build/@/components/ui/input.js +7 -0
  15. package/build/@/components/ui/input.js.map +1 -0
  16. package/build/@/components/ui/label.d.ts +5 -0
  17. package/build/@/components/ui/label.d.ts.map +1 -0
  18. package/build/@/components/ui/label.js +8 -0
  19. package/build/@/components/ui/label.js.map +1 -0
  20. package/build/@/lib/utils.d.ts +3 -0
  21. package/build/@/lib/utils.d.ts.map +1 -0
  22. package/build/@/lib/utils.js +6 -0
  23. package/build/@/lib/utils.js.map +1 -0
  24. package/build/append.d.ts +14 -0
  25. package/build/append.d.ts.map +1 -0
  26. package/build/append.js +19 -0
  27. package/build/append.js.map +1 -0
  28. package/build/components/consent-screen.d.ts +12 -0
  29. package/build/components/consent-screen.d.ts.map +1 -0
  30. package/build/components/consent-screen.js +30 -0
  31. package/build/components/consent-screen.js.map +1 -0
  32. package/build/components/device-screen.d.ts +10 -0
  33. package/build/components/device-screen.d.ts.map +1 -0
  34. package/build/components/device-screen.js +48 -0
  35. package/build/components/device-screen.js.map +1 -0
  36. package/build/components/done-screen.d.ts +8 -0
  37. package/build/components/done-screen.d.ts.map +1 -0
  38. package/build/components/done-screen.js +18 -0
  39. package/build/components/done-screen.js.map +1 -0
  40. package/build/components/index.d.ts +4 -0
  41. package/build/components/index.d.ts.map +1 -0
  42. package/build/components/index.js +4 -0
  43. package/build/components/index.js.map +1 -0
  44. package/build/consts.d.ts +6 -0
  45. package/build/consts.d.ts.map +1 -0
  46. package/build/consts.js +6 -0
  47. package/build/consts.js.map +1 -0
  48. package/build/entrypoints.d.ts +8 -0
  49. package/build/entrypoints.d.ts.map +1 -0
  50. package/build/entrypoints.js +20 -0
  51. package/build/entrypoints.js.map +1 -0
  52. package/build/hooks/use-oauth-consent.d.ts +27 -0
  53. package/build/hooks/use-oauth-consent.d.ts.map +1 -0
  54. package/build/hooks/use-oauth-consent.js +128 -0
  55. package/build/hooks/use-oauth-consent.js.map +1 -0
  56. package/build/i18n/be.json +35 -0
  57. package/build/i18n/de.json +35 -0
  58. package/build/i18n/en.json +35 -0
  59. package/build/i18n/es.json +35 -0
  60. package/build/i18n/pl.json +35 -0
  61. package/build/i18n/ru.json +35 -0
  62. package/build/i18n/uk.json +35 -0
  63. package/build/i18n.d.ts +2 -0
  64. package/build/i18n.d.ts.map +1 -0
  65. package/build/i18n.js +17 -0
  66. package/build/i18n.js.map +1 -0
  67. package/build/index.d.ts +7 -0
  68. package/build/index.d.ts.map +1 -0
  69. package/build/index.js +7 -0
  70. package/build/index.js.map +1 -0
  71. package/components.json +21 -0
  72. package/package.json +77 -0
  73. package/src/@/components/ui/button.tsx +60 -0
  74. package/src/@/components/ui/card.tsx +93 -0
  75. package/src/@/components/ui/input.tsx +22 -0
  76. package/src/@/components/ui/label.tsx +23 -0
  77. package/src/@/globals.css +57 -0
  78. package/src/@/lib/utils.ts +6 -0
  79. package/src/append.ts +24 -0
  80. package/src/components/consent-screen.tsx +95 -0
  81. package/src/components/device-screen.tsx +81 -0
  82. package/src/components/done-screen.tsx +30 -0
  83. package/src/components/index.ts +3 -0
  84. package/src/consts.ts +6 -0
  85. package/src/entrypoints.ts +22 -0
  86. package/src/hooks/use-oauth-consent.ts +163 -0
  87. package/src/i18n/be.json +35 -0
  88. package/src/i18n/de.json +35 -0
  89. package/src/i18n/en.json +35 -0
  90. package/src/i18n/es.json +35 -0
  91. package/src/i18n/pl.json +35 -0
  92. package/src/i18n/ru.json +35 -0
  93. package/src/i18n/uk.json +35 -0
  94. package/src/i18n.ts +18 -0
  95. package/src/index.ts +6 -0
  96. package/tests/consent.spec.ts +260 -0
  97. package/tests/context.ts +48 -0
  98. package/tests/device.spec.ts +64 -0
  99. package/tests/done.spec.ts +41 -0
  100. package/tests/harness/css.d.ts +1 -0
  101. package/tests/harness/frame.html +15 -0
  102. package/tests/harness/index.html +11 -0
  103. package/tests/harness/mount.tsx +89 -0
  104. package/tests/helpers.ts +97 -0
  105. package/tests/i18n.spec.ts +41 -0
  106. package/tests/languages.spec.ts +38 -0
  107. package/tests/package-boundary.spec.ts +19 -0
  108. package/tests/tsconfig.json +12 -0
  109. 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
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
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
+ }
@@ -0,0 +1,3 @@
1
+ export * from './consent-screen.js'
2
+ export * from './device-screen.js'
3
+ export * from './done-screen.js'
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
+ }