cortena-ui 1.4.2 → 1.6.0
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/CHANGELOG.md +107 -0
- package/LICENSE +7 -0
- package/README.md +235 -3
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +353 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +79 -0
- package/dist/agent-chat/session.js +391 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +102 -0
- package/dist/agent-chat/store.js +876 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +277 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +11 -0
- package/dist/agent-chat.js +11 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +163 -0
- package/dist/components/agent-chat.js +673 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +18 -5
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +445 -0
- package/src/agent-chat/session.ts +549 -0
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +1234 -0
- package/src/agent-chat/types.ts +308 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +1006 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +137 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- package/src/index.ts +6 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Alert, AlertDescription, AlertIcon, AlertTitle } from "@/components/alert";
|
|
5
|
+
import { BrandMark } from "@/components/app-shell";
|
|
6
|
+
import { Button } from "@/components/button";
|
|
7
|
+
import { Input } from "@/components/input";
|
|
8
|
+
import { Label } from "@/components/label";
|
|
9
|
+
import { Spinner } from "@/components/spinner";
|
|
10
|
+
import { cn } from "@/lib/cn";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* LoginScreen — the one sign-in composition for Cortena
|
|
14
|
+
* (how-to-create-a-cortena-extension §7.4, audit rule P-08, decision DESIGN-D1).
|
|
15
|
+
*
|
|
16
|
+
* Every surface that asks a user to sign in — cortenaweb, cortena-auth's hosted
|
|
17
|
+
* page, every extension, and the MCP consent page through `ConsentScreen` —
|
|
18
|
+
* renders this. The failure it removes is a fleet of near-identical login pages
|
|
19
|
+
* that differ in wording, spacing and which methods they offer, so a user
|
|
20
|
+
* cannot tell whether the page in front of them is Cortena's.
|
|
21
|
+
*
|
|
22
|
+
* It is props-only and fetches nothing. The org's `auth-info` call, the
|
|
23
|
+
* Firebase REST sign-in and the popup all stay in the app: this package cannot
|
|
24
|
+
* take a dependency on firebase, and a component that fetches cannot be
|
|
25
|
+
* rendered in the guide, in a test, or in a story. The caller passes
|
|
26
|
+
* `methods` and handles `onPassword` / `onProvider`.
|
|
27
|
+
*
|
|
28
|
+
* Two rules it encodes, because both were shipped wrong:
|
|
29
|
+
*
|
|
30
|
+
* Offer what the org advertises, and nothing else. An extension once shipped
|
|
31
|
+
* with email/password only; every Google-registered user in the org was
|
|
32
|
+
* locked out and the screen said nothing about why. `methods` is the gate,
|
|
33
|
+
* and it defaults to `["password"]` rather than to an empty screen.
|
|
34
|
+
*
|
|
35
|
+
* A session that ends lands here, not on a broken screen (§9.4, P-37). Pass
|
|
36
|
+
* `signedOut` for "You were signed out" and `returnTo` for where the user
|
|
37
|
+
* was, so signing in again is a return rather than a restart.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/* ── the frame ───────────────────────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
export interface AuthScreenBrand {
|
|
43
|
+
/** Registered mark id — what the extension puts in `x-cortena.icon`. */
|
|
44
|
+
id?: string;
|
|
45
|
+
/** Display name, beside the mark. */
|
|
46
|
+
name: string;
|
|
47
|
+
/**
|
|
48
|
+
* An arbitrary image, for a brand with no registered mark (an MCP client).
|
|
49
|
+
* **https only** — see `httpsLogoUrl`. Anything else falls back to the mark.
|
|
50
|
+
*/
|
|
51
|
+
logoUrl?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The logo URL to render, or `undefined`.
|
|
56
|
+
*
|
|
57
|
+
* A logo on a sign-in or consent screen is the one image on the page that
|
|
58
|
+
* comes from outside Cortena: an MCP client registers it, and the screen it
|
|
59
|
+
* appears on is where a user decides whether to hand that client their
|
|
60
|
+
* credentials. So the scheme is checked rather than trusted.
|
|
61
|
+
*
|
|
62
|
+
* Only `https:` passes. `data:` and `blob:` let a registration inline
|
|
63
|
+
* arbitrary bytes — an SVG among them, which the browser will render with its
|
|
64
|
+
* own scripts if it is ever loaded as a document — and `http:` is a mixed
|
|
65
|
+
* -content image that any network can replace with the attacker's logo of
|
|
66
|
+
* choice. A relative URL resolves against the current page and so passes only
|
|
67
|
+
* on an https origin, which is where sign-in belongs.
|
|
68
|
+
*
|
|
69
|
+
* `javascript:` never reaches an `<img src>` as code, but it has no business
|
|
70
|
+
* being one either; it falls out of the same rule rather than needing its own.
|
|
71
|
+
*/
|
|
72
|
+
export function httpsLogoUrl(logoUrl: string | undefined): string | undefined {
|
|
73
|
+
if (!logoUrl) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
// Relative URLs are resolved, not rejected: the check is about the scheme
|
|
78
|
+
// the browser will actually fetch, and `new URL` is what decides that.
|
|
79
|
+
const base = typeof location === "undefined" ? undefined : location.href;
|
|
80
|
+
const url = base === undefined ? new URL(logoUrl) : new URL(logoUrl, base);
|
|
81
|
+
return url.protocol === "https:" ? logoUrl : undefined;
|
|
82
|
+
} catch {
|
|
83
|
+
// Not a URL at all. A broken image is a worse answer than the mark.
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface AuthScreenProps extends Omit<React.ComponentProps<"div">, "title"> {
|
|
89
|
+
brand: AuthScreenBrand;
|
|
90
|
+
/** The `<h1>`. */
|
|
91
|
+
title: React.ReactNode;
|
|
92
|
+
/** One line under the title. */
|
|
93
|
+
subtitle?: React.ReactNode;
|
|
94
|
+
/** Rendered above the title, for the signed-out notice and the error. */
|
|
95
|
+
notices?: React.ReactNode;
|
|
96
|
+
/** Rendered under the card, for `returnTo` and small print. */
|
|
97
|
+
footer?: React.ReactNode;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The chrome both screens wear: one centred card on the canvas.
|
|
102
|
+
*
|
|
103
|
+
* Centring is explicit (`place-items-center`, and `mx-auto` on the column)
|
|
104
|
+
* because a max-width alone leaves the card against the left edge on a wide
|
|
105
|
+
* monitor — which shipped once already. The column is `w-full` under a
|
|
106
|
+
* `max-w-sm`, so it fits a 360px viewport with the page padding intact.
|
|
107
|
+
*/
|
|
108
|
+
function AuthScreen({
|
|
109
|
+
className,
|
|
110
|
+
brand,
|
|
111
|
+
title,
|
|
112
|
+
subtitle,
|
|
113
|
+
notices,
|
|
114
|
+
footer,
|
|
115
|
+
children,
|
|
116
|
+
...props
|
|
117
|
+
}: AuthScreenProps) {
|
|
118
|
+
const logoUrl = httpsLogoUrl(brand.logoUrl);
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
data-slot="auth-screen"
|
|
122
|
+
className={cn(
|
|
123
|
+
"flex min-h-screen w-full flex-col items-center justify-center gap-6 px-4 py-10",
|
|
124
|
+
"bg-[var(--ds-background)] text-[color:var(--ds-foreground)]",
|
|
125
|
+
className,
|
|
126
|
+
)}
|
|
127
|
+
{...props}
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
data-slot="auth-screen-column"
|
|
131
|
+
className="mx-auto flex w-full max-w-sm flex-col items-stretch gap-4"
|
|
132
|
+
>
|
|
133
|
+
<div data-slot="auth-screen-brand" className="flex items-center justify-center gap-2.5">
|
|
134
|
+
<span
|
|
135
|
+
data-slot="auth-screen-brand-square"
|
|
136
|
+
className={cn(
|
|
137
|
+
"flex size-8 shrink-0 items-center justify-center overflow-hidden",
|
|
138
|
+
"rounded-[var(--ds-radius-md)] bg-[var(--ds-primary-soft)]",
|
|
139
|
+
"text-[color:var(--ds-primary)]",
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{logoUrl ? (
|
|
143
|
+
<img
|
|
144
|
+
src={logoUrl}
|
|
145
|
+
alt=""
|
|
146
|
+
// The URL is the client's, so the request must not tell it
|
|
147
|
+
// which Cortena screen the user is standing on.
|
|
148
|
+
referrerPolicy="no-referrer"
|
|
149
|
+
className="size-5 object-contain"
|
|
150
|
+
/>
|
|
151
|
+
) : (
|
|
152
|
+
<BrandMark id={brand.id ?? ""} name={brand.name} size={20} decorative />
|
|
153
|
+
)}
|
|
154
|
+
</span>
|
|
155
|
+
<span
|
|
156
|
+
data-slot="auth-screen-brand-name"
|
|
157
|
+
className={cn(
|
|
158
|
+
"truncate font-display text-[length:var(--ds-text-body-md)] font-bold",
|
|
159
|
+
"leading-[var(--ds-text-body-md--line-height)]",
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
{brand.name}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{notices ? (
|
|
167
|
+
<div data-slot="auth-screen-notices" className="flex flex-col gap-3">
|
|
168
|
+
{notices}
|
|
169
|
+
</div>
|
|
170
|
+
) : null}
|
|
171
|
+
|
|
172
|
+
<div
|
|
173
|
+
data-slot="auth-screen-card"
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex flex-col gap-5 rounded-[var(--ds-radius-lg)] border border-[var(--ds-border-subtle)]",
|
|
176
|
+
"bg-[var(--ds-card)] p-6 shadow-[var(--ds-shadow-md)]",
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<div data-slot="auth-screen-heading" className="flex flex-col gap-1.5">
|
|
180
|
+
<h1
|
|
181
|
+
data-slot="auth-screen-title"
|
|
182
|
+
className={cn(
|
|
183
|
+
"font-display text-[length:var(--ds-text-h3)] font-bold",
|
|
184
|
+
"leading-[var(--ds-text-h3--line-height)] text-[color:var(--ds-foreground)]",
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
{title}
|
|
188
|
+
</h1>
|
|
189
|
+
{subtitle ? (
|
|
190
|
+
<p
|
|
191
|
+
data-slot="auth-screen-subtitle"
|
|
192
|
+
className={cn(
|
|
193
|
+
"text-[length:var(--ds-text-caption-lg)]",
|
|
194
|
+
"leading-[var(--ds-text-caption-lg--line-height)]",
|
|
195
|
+
"text-[color:var(--ds-muted-foreground)]",
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
{subtitle}
|
|
199
|
+
</p>
|
|
200
|
+
) : null}
|
|
201
|
+
</div>
|
|
202
|
+
{children}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{footer ? (
|
|
206
|
+
<div
|
|
207
|
+
data-slot="auth-screen-footer"
|
|
208
|
+
className={cn(
|
|
209
|
+
"text-center text-[length:var(--ds-text-caption)]",
|
|
210
|
+
"leading-[var(--ds-text-caption--line-height)]",
|
|
211
|
+
"text-[color:var(--ds-muted-foreground)]",
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{footer}
|
|
215
|
+
</div>
|
|
216
|
+
) : null}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ── provider marks ──────────────────────────────────────────────────────── */
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The identity providers' own marks. These are the one justified colour waiver
|
|
226
|
+
* in the whole design system (§7.4): a provider's brand mark is prescribed by
|
|
227
|
+
* that provider and may not be recoloured, so it cannot come from a token. The
|
|
228
|
+
* waiver lives here so no consumer of cortena-ui ever needs one — which is the
|
|
229
|
+
* point of moving these buttons into the package.
|
|
230
|
+
*/
|
|
231
|
+
function GoogleMark() {
|
|
232
|
+
return (
|
|
233
|
+
<svg viewBox="0 0 18 18" aria-hidden focusable="false" className="size-4">
|
|
234
|
+
{/* cortena-design-lint-disable-next-line -- Google brand blue; a provider mark may not be recoloured (§7.4) */}
|
|
235
|
+
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z" />
|
|
236
|
+
{/* cortena-design-lint-disable-next-line -- Google brand green; a provider mark may not be recoloured (§7.4) */}
|
|
237
|
+
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z" />
|
|
238
|
+
{/* cortena-design-lint-disable-next-line -- Google brand yellow; a provider mark may not be recoloured (§7.4) */}
|
|
239
|
+
<path fill="#FBBC05" d="M3.97 10.72a5.41 5.41 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33Z" />
|
|
240
|
+
{/* cortena-design-lint-disable-next-line -- Google brand red; a provider mark may not be recoloured (§7.4) */}
|
|
241
|
+
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z" />
|
|
242
|
+
</svg>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function MicrosoftMark() {
|
|
247
|
+
return (
|
|
248
|
+
<svg viewBox="0 0 18 18" aria-hidden focusable="false" className="size-4">
|
|
249
|
+
{/* cortena-design-lint-disable-next-line -- Microsoft brand orange; a provider mark may not be recoloured (§7.4) */}
|
|
250
|
+
<path fill="#F25022" d="M0 0h8.5v8.5H0z" />
|
|
251
|
+
{/* cortena-design-lint-disable-next-line -- Microsoft brand green; a provider mark may not be recoloured (§7.4) */}
|
|
252
|
+
<path fill="#7FBA00" d="M9.5 0H18v8.5H9.5z" />
|
|
253
|
+
{/* cortena-design-lint-disable-next-line -- Microsoft brand blue; a provider mark may not be recoloured (§7.4) */}
|
|
254
|
+
<path fill="#00A4EF" d="M0 9.5h8.5V18H0z" />
|
|
255
|
+
{/* cortena-design-lint-disable-next-line -- Microsoft brand yellow; a provider mark may not be recoloured (§7.4) */}
|
|
256
|
+
<path fill="#FFB900" d="M9.5 9.5H18V18H9.5z" />
|
|
257
|
+
</svg>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ── the login screen ────────────────────────────────────────────────────── */
|
|
262
|
+
|
|
263
|
+
/** The sign-in methods an org can advertise through `/api/orgs/:orgId/auth-info`. */
|
|
264
|
+
export type LoginMethod = "password" | "google" | "microsoft";
|
|
265
|
+
|
|
266
|
+
export interface LoginExtension {
|
|
267
|
+
/** Registered mark id, from `x-cortena.icon` in the OpenAPI document (§9.1). */
|
|
268
|
+
id: string;
|
|
269
|
+
/** Display name. "Sign in to <name>". */
|
|
270
|
+
name: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface PasswordCredentials {
|
|
274
|
+
email: string;
|
|
275
|
+
password: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const PROVIDERS: Record<
|
|
279
|
+
Exclude<LoginMethod, "password">,
|
|
280
|
+
{ label: string; Mark: () => React.ReactElement }
|
|
281
|
+
> = {
|
|
282
|
+
google: { label: "Continue with Google", Mark: GoogleMark },
|
|
283
|
+
microsoft: { label: "Continue with Microsoft", Mark: MicrosoftMark },
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** The order providers appear in, so two orgs never show them in different orders. */
|
|
287
|
+
const PROVIDER_ORDER = ["google", "microsoft"] as const;
|
|
288
|
+
|
|
289
|
+
export interface LoginScreenProps extends Omit<React.ComponentProps<"div">, "onSubmit"> {
|
|
290
|
+
/** The extension (or product) being signed into. Supplies the mark and the name. */
|
|
291
|
+
extension: LoginExtension;
|
|
292
|
+
/**
|
|
293
|
+
* What the org advertises. An empty or omitted list means password — never an
|
|
294
|
+
* empty screen, which is what a failed `auth-info` call would otherwise draw.
|
|
295
|
+
*/
|
|
296
|
+
methods?: readonly LoginMethod[];
|
|
297
|
+
onPassword?: (credentials: PasswordCredentials) => void;
|
|
298
|
+
onProvider?: (provider: Exclude<LoginMethod, "password">) => void;
|
|
299
|
+
/** Shown above the card. A failed sign-in, in the user's words, not the API's. */
|
|
300
|
+
error?: string;
|
|
301
|
+
/** Disables every control and marks the submit button busy. */
|
|
302
|
+
loading?: boolean;
|
|
303
|
+
/**
|
|
304
|
+
* Where the user was when the session ended (§9.4). Shown, so the promise is
|
|
305
|
+
* visible before signing in; honouring it after sign-in is the caller's job.
|
|
306
|
+
*/
|
|
307
|
+
returnTo?: string;
|
|
308
|
+
/** The signed-out state a failed refresh or an idle timeout lands on (P-37). */
|
|
309
|
+
signedOut?: boolean;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function LoginScreen({
|
|
313
|
+
className,
|
|
314
|
+
extension,
|
|
315
|
+
methods,
|
|
316
|
+
onPassword,
|
|
317
|
+
onProvider,
|
|
318
|
+
error,
|
|
319
|
+
loading,
|
|
320
|
+
returnTo,
|
|
321
|
+
signedOut,
|
|
322
|
+
...props
|
|
323
|
+
}: LoginScreenProps) {
|
|
324
|
+
const fieldId = React.useId();
|
|
325
|
+
const [email, setEmail] = React.useState("");
|
|
326
|
+
const [password, setPassword] = React.useState("");
|
|
327
|
+
|
|
328
|
+
// An org that advertises nothing still has password: an empty methods list is
|
|
329
|
+
// a missing or failed auth-info call, and a screen with no way in is worse
|
|
330
|
+
// than one that offers the method every org has.
|
|
331
|
+
const active = methods && methods.length > 0 ? methods : (["password"] as const);
|
|
332
|
+
const showPassword = active.includes("password");
|
|
333
|
+
const providers = PROVIDER_ORDER.filter((p) => active.includes(p));
|
|
334
|
+
|
|
335
|
+
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
336
|
+
event.preventDefault();
|
|
337
|
+
if (loading) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
onPassword?.({ email, password });
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<AuthScreen
|
|
345
|
+
data-slot="login-screen"
|
|
346
|
+
data-loading={loading ? "" : undefined}
|
|
347
|
+
className={className}
|
|
348
|
+
brand={{ id: extension.id, name: extension.name }}
|
|
349
|
+
title={`Sign in to ${extension.name}`}
|
|
350
|
+
subtitle={signedOut ? undefined : "Use your Cortena account."}
|
|
351
|
+
notices={
|
|
352
|
+
<>
|
|
353
|
+
{signedOut ? (
|
|
354
|
+
<Alert variant="warn" data-slot="login-screen-signed-out">
|
|
355
|
+
<AlertIcon>
|
|
356
|
+
<SignedOutGlyph />
|
|
357
|
+
</AlertIcon>
|
|
358
|
+
<div>
|
|
359
|
+
<AlertTitle>You were signed out</AlertTitle>
|
|
360
|
+
<AlertDescription>
|
|
361
|
+
Sign in again to pick up where you left off.
|
|
362
|
+
</AlertDescription>
|
|
363
|
+
</div>
|
|
364
|
+
</Alert>
|
|
365
|
+
) : null}
|
|
366
|
+
{error ? (
|
|
367
|
+
<Alert variant="destructive" data-slot="login-screen-error">
|
|
368
|
+
<AlertDescription className="text-[color:var(--ds-foreground)]">
|
|
369
|
+
{error}
|
|
370
|
+
</AlertDescription>
|
|
371
|
+
</Alert>
|
|
372
|
+
) : null}
|
|
373
|
+
</>
|
|
374
|
+
}
|
|
375
|
+
footer={
|
|
376
|
+
returnTo ? (
|
|
377
|
+
<span data-slot="login-screen-return-to">
|
|
378
|
+
You will return to <span className="font-mono">{returnTo}</span>
|
|
379
|
+
</span>
|
|
380
|
+
) : null
|
|
381
|
+
}
|
|
382
|
+
{...props}
|
|
383
|
+
>
|
|
384
|
+
{showPassword ? (
|
|
385
|
+
<form
|
|
386
|
+
data-slot="login-screen-form"
|
|
387
|
+
className="flex flex-col gap-4"
|
|
388
|
+
onSubmit={submit}
|
|
389
|
+
noValidate
|
|
390
|
+
>
|
|
391
|
+
<div className="flex flex-col gap-1.5">
|
|
392
|
+
<Label htmlFor={`${fieldId}-email`}>Email</Label>
|
|
393
|
+
<Input
|
|
394
|
+
id={`${fieldId}-email`}
|
|
395
|
+
name="email"
|
|
396
|
+
type="email"
|
|
397
|
+
autoComplete="username"
|
|
398
|
+
autoFocus
|
|
399
|
+
required
|
|
400
|
+
disabled={loading}
|
|
401
|
+
aria-invalid={error ? true : undefined}
|
|
402
|
+
value={email}
|
|
403
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
<div className="flex flex-col gap-1.5">
|
|
407
|
+
<Label htmlFor={`${fieldId}-password`}>Password</Label>
|
|
408
|
+
<Input
|
|
409
|
+
id={`${fieldId}-password`}
|
|
410
|
+
name="password"
|
|
411
|
+
type="password"
|
|
412
|
+
autoComplete="current-password"
|
|
413
|
+
required
|
|
414
|
+
disabled={loading}
|
|
415
|
+
aria-invalid={error ? true : undefined}
|
|
416
|
+
value={password}
|
|
417
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
{/* A real submit button, so Enter in either field signs in. */}
|
|
421
|
+
<Button type="submit" size="lg" disabled={loading} data-slot="login-screen-submit">
|
|
422
|
+
{loading ? <Spinner tone="inverse" /> : null}
|
|
423
|
+
{loading ? "Signing in…" : "Sign in"}
|
|
424
|
+
</Button>
|
|
425
|
+
</form>
|
|
426
|
+
) : null}
|
|
427
|
+
|
|
428
|
+
{showPassword && providers.length > 0 ? (
|
|
429
|
+
<div data-slot="login-screen-divider" className="flex items-center gap-3" aria-hidden>
|
|
430
|
+
<span className="h-px flex-1 bg-[var(--ds-border)]" />
|
|
431
|
+
<span
|
|
432
|
+
className={cn(
|
|
433
|
+
"text-[length:var(--ds-text-caption)]",
|
|
434
|
+
"text-[color:var(--ds-muted-foreground)]",
|
|
435
|
+
)}
|
|
436
|
+
>
|
|
437
|
+
or
|
|
438
|
+
</span>
|
|
439
|
+
<span className="h-px flex-1 bg-[var(--ds-border)]" />
|
|
440
|
+
</div>
|
|
441
|
+
) : null}
|
|
442
|
+
|
|
443
|
+
{providers.length > 0 ? (
|
|
444
|
+
<div data-slot="login-screen-providers" className="flex flex-col gap-2.5">
|
|
445
|
+
{providers.map((provider) => {
|
|
446
|
+
const { label, Mark } = PROVIDERS[provider];
|
|
447
|
+
return (
|
|
448
|
+
<Button
|
|
449
|
+
key={provider}
|
|
450
|
+
type="button"
|
|
451
|
+
variant="secondary"
|
|
452
|
+
size="lg"
|
|
453
|
+
disabled={loading}
|
|
454
|
+
data-provider={provider}
|
|
455
|
+
onClick={() => onProvider?.(provider)}
|
|
456
|
+
>
|
|
457
|
+
<Mark />
|
|
458
|
+
{label}
|
|
459
|
+
</Button>
|
|
460
|
+
);
|
|
461
|
+
})}
|
|
462
|
+
</div>
|
|
463
|
+
) : null}
|
|
464
|
+
</AuthScreen>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The signed-out notice's glyph. Inline rather than a lucide import: one icon. */
|
|
469
|
+
function SignedOutGlyph() {
|
|
470
|
+
return (
|
|
471
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
472
|
+
<circle cx="12" cy="12" r="9" />
|
|
473
|
+
<path d="M12 8v5" strokeLinecap="round" />
|
|
474
|
+
<path d="M12 16h.01" strokeLinecap="round" />
|
|
475
|
+
</svg>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export { AuthScreen, GoogleMark, LoginScreen, MicrosoftMark };
|