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.
Files changed (138) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/LICENSE +7 -0
  3. package/README.md +235 -3
  4. package/dist/a2ui/views.js +2 -2
  5. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  6. package/dist/agent-chat/a2ui-block.js +69 -0
  7. package/dist/agent-chat/a2ui-block.js.map +1 -0
  8. package/dist/agent-chat/agui-client.d.ts +40 -0
  9. package/dist/agent-chat/agui-client.js +251 -0
  10. package/dist/agent-chat/agui-client.js.map +1 -0
  11. package/dist/agent-chat/bridge.d.ts +109 -0
  12. package/dist/agent-chat/bridge.js +353 -0
  13. package/dist/agent-chat/bridge.js.map +1 -0
  14. package/dist/agent-chat/session.d.ts +79 -0
  15. package/dist/agent-chat/session.js +391 -0
  16. package/dist/agent-chat/session.js.map +1 -0
  17. package/dist/agent-chat/step-label.d.ts +99 -0
  18. package/dist/agent-chat/step-label.js +116 -0
  19. package/dist/agent-chat/step-label.js.map +1 -0
  20. package/dist/agent-chat/store.d.ts +102 -0
  21. package/dist/agent-chat/store.js +876 -0
  22. package/dist/agent-chat/store.js.map +1 -0
  23. package/dist/agent-chat/types.d.ts +277 -0
  24. package/dist/agent-chat/types.js +17 -0
  25. package/dist/agent-chat/types.js.map +1 -0
  26. package/dist/agent-chat.d.ts +11 -0
  27. package/dist/agent-chat.js +11 -0
  28. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  29. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  30. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  31. package/dist/components/admin-permissions/context.d.ts +70 -0
  32. package/dist/components/admin-permissions/context.js +258 -0
  33. package/dist/components/admin-permissions/context.js.map +1 -0
  34. package/dist/components/admin-permissions/index.d.ts +10 -0
  35. package/dist/components/admin-permissions/licence.d.ts +15 -0
  36. package/dist/components/admin-permissions/licence.js +78 -0
  37. package/dist/components/admin-permissions/licence.js.map +1 -0
  38. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  39. package/dist/components/admin-permissions/matrix.js +191 -0
  40. package/dist/components/admin-permissions/matrix.js.map +1 -0
  41. package/dist/components/admin-permissions/members.d.ts +18 -0
  42. package/dist/components/admin-permissions/members.js +185 -0
  43. package/dist/components/admin-permissions/members.js.map +1 -0
  44. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  45. package/dist/components/admin-permissions/role-assignment.js +174 -0
  46. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  47. package/dist/components/admin-permissions/roles.d.ts +25 -0
  48. package/dist/components/admin-permissions/roles.js +168 -0
  49. package/dist/components/admin-permissions/roles.js.map +1 -0
  50. package/dist/components/admin-permissions/types.d.ts +152 -0
  51. package/dist/components/admin-permissions/types.js +63 -0
  52. package/dist/components/admin-permissions/types.js.map +1 -0
  53. package/dist/components/agent-chat-popup.d.ts +29 -0
  54. package/dist/components/agent-chat-popup.js +188 -0
  55. package/dist/components/agent-chat-popup.js.map +1 -0
  56. package/dist/components/agent-chat.d.ts +163 -0
  57. package/dist/components/agent-chat.js +673 -0
  58. package/dist/components/agent-chat.js.map +1 -0
  59. package/dist/components/app-shell.d.ts +126 -0
  60. package/dist/components/app-shell.js +297 -0
  61. package/dist/components/app-shell.js.map +1 -0
  62. package/dist/components/badge.d.ts +1 -1
  63. package/dist/components/button-link.js +1 -1
  64. package/dist/components/button.d.ts +1 -1
  65. package/dist/components/checkbox.d.ts +1 -1
  66. package/dist/components/combobox.d.ts +1 -1
  67. package/dist/components/combobox.js +1 -1
  68. package/dist/components/consent-screen.d.ts +65 -0
  69. package/dist/components/consent-screen.js +123 -0
  70. package/dist/components/consent-screen.js.map +1 -0
  71. package/dist/components/data-table/data-table.d.ts +15 -1
  72. package/dist/components/data-table/data-table.js +18 -4
  73. package/dist/components/data-table/data-table.js.map +1 -1
  74. package/dist/components/data-table/index.d.ts +4 -4
  75. package/dist/components/data-table/parts.d.ts +27 -3
  76. package/dist/components/data-table/parts.js +175 -55
  77. package/dist/components/data-table/parts.js.map +1 -1
  78. package/dist/components/data-table/types.d.ts +61 -0
  79. package/dist/components/data-table/use-data-table.js +91 -6
  80. package/dist/components/data-table/use-data-table.js.map +1 -1
  81. package/dist/components/data-table/use-server-source.js +119 -28
  82. package/dist/components/data-table/use-server-source.js.map +1 -1
  83. package/dist/components/help-panel.d.ts +131 -0
  84. package/dist/components/help-panel.js +545 -0
  85. package/dist/components/help-panel.js.map +1 -0
  86. package/dist/components/login-screen.d.ts +127 -0
  87. package/dist/components/login-screen.js +339 -0
  88. package/dist/components/login-screen.js.map +1 -0
  89. package/dist/components/session-guard.d.ts +268 -0
  90. package/dist/components/session-guard.js +632 -0
  91. package/dist/components/session-guard.js.map +1 -0
  92. package/dist/components/toast.d.ts +1 -1
  93. package/dist/core.d.ts +5 -1
  94. package/dist/core.js +11 -7
  95. package/dist/data-table.d.ts +13 -4
  96. package/dist/data-table.js +10 -2
  97. package/dist/hooks/use-cortena-theme.js +49 -3
  98. package/dist/hooks/use-cortena-theme.js.map +1 -1
  99. package/dist/index.d.ts +17 -4
  100. package/dist/index.js +21 -8
  101. package/dist/markdown.d.ts +2 -1
  102. package/dist/markdown.js +2 -1
  103. package/package.json +18 -5
  104. package/src/agent-chat/a2ui-block.ts +118 -0
  105. package/src/agent-chat/agui-client.ts +405 -0
  106. package/src/agent-chat/bridge.ts +445 -0
  107. package/src/agent-chat/session.ts +549 -0
  108. package/src/agent-chat/step-label.ts +177 -0
  109. package/src/agent-chat/store.ts +1234 -0
  110. package/src/agent-chat/types.ts +308 -0
  111. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  112. package/src/components/admin-permissions/context.tsx +376 -0
  113. package/src/components/admin-permissions/index.tsx +32 -0
  114. package/src/components/admin-permissions/licence.tsx +84 -0
  115. package/src/components/admin-permissions/matrix.tsx +257 -0
  116. package/src/components/admin-permissions/members.tsx +204 -0
  117. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  118. package/src/components/admin-permissions/roles.tsx +169 -0
  119. package/src/components/admin-permissions/types.ts +231 -0
  120. package/src/components/agent-chat-popup.tsx +289 -0
  121. package/src/components/agent-chat.tsx +1006 -0
  122. package/src/components/app-shell.tsx +502 -0
  123. package/src/components/consent-screen.tsx +239 -0
  124. package/src/components/data-table/data-table.tsx +36 -0
  125. package/src/components/data-table/index.tsx +6 -1
  126. package/src/components/data-table/parts.tsx +223 -47
  127. package/src/components/data-table/types.ts +68 -0
  128. package/src/components/data-table/use-data-table.ts +152 -4
  129. package/src/components/data-table/use-server-source.ts +150 -12
  130. package/src/components/help-panel.tsx +765 -0
  131. package/src/components/login-screen.tsx +479 -0
  132. package/src/components/session-guard.tsx +1071 -0
  133. package/src/entries/agent-chat.ts +137 -0
  134. package/src/entries/core.ts +8 -0
  135. package/src/entries/data-table.ts +41 -0
  136. package/src/entries/markdown.ts +25 -0
  137. package/src/hooks/use-cortena-theme.ts +63 -4
  138. 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 };