@cosmicdrift/kumiko-samples 0.341.0 → 0.342.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 (29) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/constants.ts +5 -0
  4. package/packages/bundled-features/src/audit/feature.ts +47 -6
  5. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
  6. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +18 -2
  7. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  9. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  10. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  11. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  12. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  13. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  14. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  15. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  16. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  17. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  18. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  19. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  20. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  21. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  22. package/packages/bundled-features/src/channel-email/email-channel.ts +8 -1
  23. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  24. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  25. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  26. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  27. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +1 -1
  28. package/packages/bundled-features/src/waitlist/options.ts +3 -2
  29. package/samples/apps/use-all-bundled/feature-manifest.json +27 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.341.0",
3
+ "version": "0.342.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.341.0",
3
+ "version": "0.342.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -24,3 +24,8 @@ export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
24
24
 
25
25
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
26
26
  export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail" as const;
27
+
28
+ /** Config key (system scope) for how long `escapeHatchUse` audit events are kept. */
29
+ export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days" as const;
30
+
31
+ export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -1,14 +1,22 @@
1
1
  import {
2
2
  access,
3
+ createSystemConfig,
3
4
  defineFeature,
4
5
  type FeatureDefinition,
5
6
  i18nKey,
7
+ SYSTEM_USER_ID,
6
8
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants.js";
9
+ import {
10
+ AUDIT_LOG_DETAIL_SCREEN_ID,
11
+ AUDIT_LOG_SCREEN_ID,
12
+ AuditQueries,
13
+ DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
14
+ } from "./constants.js";
8
15
  import { escapeHatchUsedSchema } from "./escape-hatch-audit-sink.js";
9
16
  import { detailsQuery } from "./handlers/details.query.js";
10
17
  import { listQuery } from "./handlers/list.query.js";
11
18
  import { AUDIT_I18N } from "./i18n.js";
19
+ import { runEscapeHatchRetention } from "./run-escape-hatch-retention.js";
12
20
 
13
21
  // Audit feature — exposes a filtered read over the framework's event log.
14
22
  //
@@ -18,10 +26,11 @@ import { AUDIT_I18N } from "./i18n.js";
18
26
  // the existing `events` table. See handlers/list.query.ts for the filter
19
27
  // surface.
20
28
  //
21
- // Retention lives elsewhere. Events are kept indefinitely as the source of
22
- // truth for state; archive or compress policies are a separate concern
23
- // (tracked with the snapshot/archive infrastructure that already exists in
24
- // the framework).
29
+ // Retention: only `escapeHatchUse` events are pruned (daily job, config key
30
+ // `escapeHatchRetentionDays`). All other events stay as the source of truth
31
+ // for state; archive or compress policies are a separate concern (tracked
32
+ // with the snapshot/archive infrastructure that already exists in the
33
+ // framework).
25
34
  export function createAuditFeature(): FeatureDefinition {
26
35
  return defineFeature(
27
36
  "audit",
@@ -35,11 +44,43 @@ export function createAuditFeature(): FeatureDefinition {
35
44
  recommended: false,
36
45
  });
37
46
  r.translations({ keys: AUDIT_I18N });
38
- r.requires("tenant", "user");
47
+ r.requires("tenant", "user", "config");
39
48
 
40
49
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
41
50
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
42
51
 
52
+ r.config(
53
+ "escapeHatchRetentionDays",
54
+ createSystemConfig("number", {
55
+ default: DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
56
+ bounds: { min: 1 },
57
+ }),
58
+ );
59
+
60
+ r.job({
61
+ name: "escape-hatch-retention",
62
+ trigger: { cron: "30 3 * * *" },
63
+ concurrency: "skip",
64
+ // pruneEvents needs a transaction-capable connection, which only unsafeRaw() hands a job.
65
+ escapeHatch: {
66
+ reason: "prunes expired escapeHatchUse audit events through pruneEvents",
67
+ grants: ["unsafeRaw"],
68
+ },
69
+ handler: async (_payload, ctx) => {
70
+ if (!ctx.registry) {
71
+ throw new Error(
72
+ "escape-hatch-retention: ctx.registry required (JobContext incomplete)",
73
+ );
74
+ }
75
+ await runEscapeHatchRetention({
76
+ db: ctx.db.unsafeRaw(),
77
+ registry: ctx.registry,
78
+ configResolver: ctx.configResolver,
79
+ userId: ctx._userId ?? SYSTEM_USER_ID,
80
+ });
81
+ },
82
+ });
83
+
43
84
  const queries = {
44
85
  list: r.queryHandler(listQuery),
45
86
  details: r.queryHandler(detailsQuery),
@@ -0,0 +1,53 @@
1
+ import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ type ConfigResolver,
4
+ type Registry,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { type PruneEventsResult, pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
8
+ import { createConfigAccessor } from "../config/index.js";
9
+ import {
10
+ DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
11
+ ESCAPE_HATCH_RETENTION_DAYS_KEY,
12
+ } from "./constants.js";
13
+ import { ESCAPE_HATCH_USE_AGGREGATE_TYPE } from "./escape-hatch-audit-sink.js";
14
+
15
+ // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
16
+ export function isDbConnection(runner: DbRunner): runner is DbConnection {
17
+ return "begin" in runner && typeof runner.begin === "function";
18
+ }
19
+
20
+ async function resolveRetentionDays(args: {
21
+ readonly registry: Registry;
22
+ readonly configResolver: ConfigResolver | undefined;
23
+ readonly db: DbConnection;
24
+ readonly userId: string;
25
+ }): Promise<number> {
26
+ if (!args.configResolver) return DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
27
+ const config = createConfigAccessor(
28
+ args.registry,
29
+ args.configResolver,
30
+ SYSTEM_TENANT_ID,
31
+ args.userId,
32
+ args.db,
33
+ );
34
+ const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
35
+ return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
36
+ }
37
+
38
+ export async function runEscapeHatchRetention(args: {
39
+ readonly db: DbRunner;
40
+ readonly registry: Registry;
41
+ readonly configResolver: ConfigResolver | undefined;
42
+ readonly userId: string;
43
+ }): Promise<PruneEventsResult> {
44
+ const { db } = args;
45
+ if (!isDbConnection(db)) {
46
+ throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
47
+ }
48
+ const olderThanDays = await resolveRetentionDays({ ...args, db });
49
+ return pruneEvents(db, {
50
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
51
+ olderThanDays,
52
+ });
53
+ }
@@ -5,13 +5,17 @@
5
5
  // invitation row holds membership role + global roles until the invitee
6
6
  // accepts through the regular invite-accept routes.
7
7
 
8
- import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
8
+ import {
9
+ makeDispatchSystemWrite,
10
+ type SystemDispatchArgs,
11
+ } from "@cosmicdrift/kumiko-framework/api";
9
12
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
10
13
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
14
  import {
12
15
  ConfigScopes,
13
16
  createSystemUser,
14
17
  type TenantId,
18
+ type WriteResult,
15
19
  } from "@cosmicdrift/kumiko-framework/engine";
16
20
  import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
17
21
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
@@ -48,6 +52,12 @@ export type BootstrapSeedDeps = {
48
52
  readonly tenantId: TenantId;
49
53
  readonly db: DbConnection;
50
54
  readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
55
+ /** Writes as the system user with the given tenant roles in the seeded
56
+ * tenant; use it when the app gates tenant data on its own roles rather
57
+ * than SystemAdmin. */
58
+ readonly dispatchWriteAs: (
59
+ args: Pick<SystemDispatchArgs, "handlerQn" | "payload"> & { readonly roles: readonly string[] },
60
+ ) => Promise<WriteResult>;
51
61
  };
52
62
 
53
63
  export type BootstrapConfigValue = string | number | boolean;
@@ -276,7 +286,13 @@ export async function bootstrapTenants(
276
286
  const outcome = await ensureTenant(deps, tenant);
277
287
  const shouldSeed = outcome === "created" && plan.seed !== undefined;
278
288
  if (shouldSeed) {
279
- await plan.seed?.({ tenantId: tenant.id, db: deps.db, dispatchSystemWrite });
289
+ await plan.seed?.({
290
+ tenantId: tenant.id,
291
+ db: deps.db,
292
+ dispatchSystemWrite,
293
+ dispatchWriteAs: ({ handlerQn, payload, roles }) =>
294
+ deps.dispatcher.write(handlerQn, payload, createSystemUser(tenant.id, roles)),
295
+ });
280
296
  }
281
297
  const configApplied = await applyTenantConfig(deps, tenant);
282
298
  tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "AuthCard styling hooks (className, headerClassName, titleClassName, bodyClassName)",
6
+ "detail": "AuthCard takes className, headerClassName, titleClassName and bodyClassName\n`AuthCard` renders the body wrapper (`p-6 pt-0 flex flex-col gap-4`) itself and accepts `className` (Card), `headerClassName`, `titleClassName` and `bodyClassName`, merged with tailwind-merge over the defaults. The auth-email-password and auth-mfa screens no longer carry their own body wrapper; the rendered DOM stays the same. The session bootstrap error screen keeps its former bottom padding through `bodyClassName`."
7
+ },
8
+ {
9
+ "version": "0.342.0",
10
+ "type": "improvement",
11
+ "title": "Bootstrap seed can write with app tenant roles",
12
+ "detail": "Bootstrap seed gets `dispatchWriteAs` to write with app tenant roles instead of SystemAdmin."
13
+ },
2
14
  {
3
15
  "version": "0.341.0",
4
16
  "type": "breaking",
@@ -15,7 +15,7 @@
15
15
  // die früheren authButtonClass/authMutedLinkClass sind dorthin gewandert.
16
16
 
17
17
  import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
18
- import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
18
+ import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer-web";
19
19
  import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
20
20
 
21
21
  // Wrappt die zentrierte Auth-Card in ihre Umgebung. Default = Fullscreen-
@@ -47,25 +47,41 @@ export function useAuthShell(): AuthShellRenderer | null {
47
47
  export type AuthCardProps = {
48
48
  readonly title?: string;
49
49
  readonly subtitle?: ReactNode;
50
+ /** Merged onto the Card (default `w-full max-w-sm`). */
51
+ readonly className?: string;
52
+ readonly headerClassName?: string;
53
+ readonly titleClassName?: string;
54
+ /** Merged onto the body wrapper (default `p-6 pt-0 flex flex-col gap-4`). */
55
+ readonly bodyClassName?: string;
50
56
  readonly children: ReactNode;
51
57
  };
52
58
 
53
59
  // kumiko-lint-ignore no-custom-primitives Wraps usePrimitives().Card, pure domain name for the auth-screen chrome, not a primitive reimplementation
54
- export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode {
60
+ export function AuthCard({
61
+ title,
62
+ subtitle,
63
+ className,
64
+ headerClassName,
65
+ titleClassName,
66
+ bodyClassName,
67
+ children,
68
+ }: AuthCardProps): ReactNode {
55
69
  const { Card } = usePrimitives();
56
70
  const shell = useAuthShell() ?? defaultAuthShell;
57
71
  // h1 (Seiten-Hauptüberschrift) via Header-Slot erhalten — die Card-Default-
58
72
  // Header wäre h3. padded:false = Form sitzt randlos wie bisher (bare form).
59
73
  const card = (
60
74
  <Card
61
- className="w-full max-w-sm"
75
+ className={cn("w-full max-w-sm", className)}
62
76
  options={{ padded: false }}
63
77
  slots={{
64
78
  header:
65
79
  title !== undefined || subtitle !== undefined ? (
66
- <div className="flex flex-col space-y-1.5 p-6 pb-4">
80
+ <div className={cn("flex flex-col space-y-1.5 p-6 pb-4", headerClassName)}>
67
81
  {title !== undefined && (
68
- <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
82
+ <h1 className={cn("text-xl font-semibold tracking-tight", titleClassName)}>
83
+ {title}
84
+ </h1>
69
85
  )}
70
86
  {subtitle !== undefined && (
71
87
  <p className="text-sm text-muted-foreground">{subtitle}</p>
@@ -74,7 +90,9 @@ export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNod
74
90
  ) : undefined,
75
91
  }}
76
92
  >
77
- <BareFormProvider>{children}</BareFormProvider>
93
+ <BareFormProvider>
94
+ <div className={cn("p-6 pt-0 flex flex-col gap-4", bodyClassName)}>{children}</div>
95
+ </BareFormProvider>
78
96
  </Card>
79
97
  );
80
98
  return shell(card);
@@ -47,12 +47,10 @@ export function ConfirmAccountUnlockScreen({
47
47
  if (status === "missing-token") {
48
48
  return (
49
49
  <AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
50
- <div className="p-6 pt-0 flex flex-col gap-4">
51
- <p className="text-sm text-muted-foreground">{t("auth.unlockAccount.missingToken")}</p>
52
- <Link href={loginHref} variant="muted">
53
- {t("auth.unlockAccount.goToLogin")}
54
- </Link>
55
- </div>
50
+ <p className="text-sm text-muted-foreground">{t("auth.unlockAccount.missingToken")}</p>
51
+ <Link href={loginHref} variant="muted">
52
+ {t("auth.unlockAccount.goToLogin")}
53
+ </Link>
56
54
  </AuthCard>
57
55
  );
58
56
  }
@@ -72,12 +70,10 @@ export function ConfirmAccountUnlockScreen({
72
70
  if (status === "success") {
73
71
  return (
74
72
  <AuthCard title={title ?? t("auth.unlockAccount.successTitle")}>
75
- <div className="p-6 pt-0 flex flex-col gap-4">
76
- <p className="text-sm text-muted-foreground">{t("auth.unlockAccount.successBody")}</p>
77
- <Link href={loginHref} variant="button">
78
- {t("auth.unlockAccount.goToLogin")}
79
- </Link>
80
- </div>
73
+ <p className="text-sm text-muted-foreground">{t("auth.unlockAccount.successBody")}</p>
74
+ <Link href={loginHref} variant="button">
75
+ {t("auth.unlockAccount.goToLogin")}
76
+ </Link>
81
77
  </AuthCard>
82
78
  );
83
79
  }
@@ -85,12 +81,10 @@ export function ConfirmAccountUnlockScreen({
85
81
  // status === "error"
86
82
  return (
87
83
  <AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
88
- <div className="p-6 pt-0 flex flex-col gap-4">
89
- <p className="text-sm text-muted-foreground">{t("auth.unlockAccount.errorBody")}</p>
90
- <Link href={loginHref} variant="muted">
91
- {t("auth.unlockAccount.goToLogin")}
92
- </Link>
93
- </div>
84
+ <p className="text-sm text-muted-foreground">{t("auth.unlockAccount.errorBody")}</p>
85
+ <Link href={loginHref} variant="muted">
86
+ {t("auth.unlockAccount.goToLogin")}
87
+ </Link>
94
88
  </AuthCard>
95
89
  );
96
90
  }
@@ -72,7 +72,7 @@ export function ForgotPasswordScreen({
72
72
  return (
73
73
  <AuthCard title={effectiveTitle} subtitle={subtitle}>
74
74
  {done ? (
75
- <div className="p-6 pt-0 flex flex-col gap-4">
75
+ <>
76
76
  <Banner variant="info">
77
77
  <p className="font-medium text-foreground">{t("auth.forgotPassword.successTitle")}</p>
78
78
  <p className="mt-1">{t("auth.forgotPassword.successBody")}</p>
@@ -80,9 +80,9 @@ export function ForgotPasswordScreen({
80
80
  <Link href={loginHref} variant="muted">
81
81
  {t("auth.forgotPassword.backToLogin")}
82
82
  </Link>
83
- </div>
83
+ </>
84
84
  ) : (
85
- <div className="p-6 pt-0 flex flex-col gap-4">
85
+ <>
86
86
  <p className="text-sm text-muted-foreground">{t("auth.forgotPassword.intro")}</p>
87
87
  <Form onSubmit={onSubmit}>
88
88
  <Field id="forgot-email" label={t("auth.forgotPassword.email")} required>
@@ -104,7 +104,7 @@ export function ForgotPasswordScreen({
104
104
  <Link href={loginHref} variant="muted" className="self-center">
105
105
  {t("auth.forgotPassword.backToLogin")}
106
106
  </Link>
107
- </div>
107
+ </>
108
108
  )}
109
109
  </AuthCard>
110
110
  );
@@ -186,87 +186,83 @@ export function InviteAcceptScreen({
186
186
  if (token === "") {
187
187
  return (
188
188
  <AuthCard title={effectiveTitle}>
189
- <div className="p-6 pt-0 flex flex-col gap-4">
190
- <p className="text-sm text-muted-foreground">{t("auth.inviteAccept.missingToken")}</p>
191
- <Link href={loginHref} variant="muted">
192
- {t("auth.inviteAccept.goToLogin")}
193
- </Link>
194
- </div>
189
+ <p className="text-sm text-muted-foreground">{t("auth.inviteAccept.missingToken")}</p>
190
+ <Link href={loginHref} variant="muted">
191
+ {t("auth.inviteAccept.goToLogin")}
192
+ </Link>
195
193
  </AuthCard>
196
194
  );
197
195
  }
198
196
 
199
197
  return (
200
198
  <AuthCard title={effectiveTitle}>
201
- <div className="p-6 pt-0 flex flex-col gap-4">
202
- <p className="text-sm text-muted-foreground">{t("auth.inviteAccept.intro")}</p>
199
+ <p className="text-sm text-muted-foreground">{t("auth.inviteAccept.intro")}</p>
203
200
 
204
- {mode === "loggedin" ? (
205
- <Form onSubmit={onSubmit}>
206
- <p className="text-sm">
207
- {t("auth.inviteAccept.loggedInAs", { email: session.user?.email ?? "" })}
208
- </p>
209
- {error !== null && <Banner variant="error">{error}</Banner>}
210
- <Button type="submit" loading={submitting} disabled={submitting}>
211
- {submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.acceptButton")}
212
- </Button>
213
- <Button
214
- type="button"
215
- variant="secondary"
216
- onClick={() => {
217
- setMode("anon-existing");
218
- }}
219
- >
220
- {t("auth.inviteAccept.useOtherAccount")}
221
- </Button>
222
- </Form>
223
- ) : (
224
- <Form onSubmit={onSubmit}>
225
- {mode === "anon-existing" && (
226
- <Field id="invite-email" label={t("auth.inviteAccept.email")} required>
227
- <Input
228
- kind="email"
229
- id="invite-email"
230
- name="invite-email"
231
- value={email}
232
- onChange={setEmail}
233
- disabled={submitting}
234
- required
235
- autoComplete="email"
236
- />
237
- </Field>
238
- )}
239
- <Field id="invite-password" label={t("auth.inviteAccept.password")} required>
201
+ {mode === "loggedin" ? (
202
+ <Form onSubmit={onSubmit}>
203
+ <p className="text-sm">
204
+ {t("auth.inviteAccept.loggedInAs", { email: session.user?.email ?? "" })}
205
+ </p>
206
+ {error !== null && <Banner variant="error">{error}</Banner>}
207
+ <Button type="submit" loading={submitting} disabled={submitting}>
208
+ {submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.acceptButton")}
209
+ </Button>
210
+ <Button
211
+ type="button"
212
+ variant="secondary"
213
+ onClick={() => {
214
+ setMode("anon-existing");
215
+ }}
216
+ >
217
+ {t("auth.inviteAccept.useOtherAccount")}
218
+ </Button>
219
+ </Form>
220
+ ) : (
221
+ <Form onSubmit={onSubmit}>
222
+ {mode === "anon-existing" && (
223
+ <Field id="invite-email" label={t("auth.inviteAccept.email")} required>
240
224
  <Input
241
- kind="password"
242
- id="invite-password"
243
- name="invite-password"
244
- value={password}
245
- onChange={setPassword}
225
+ kind="email"
226
+ id="invite-email"
227
+ name="invite-email"
228
+ value={email}
229
+ onChange={setEmail}
246
230
  disabled={submitting}
247
231
  required
248
- autoComplete={mode === "anon-existing" ? "current-password" : "new-password"}
232
+ autoComplete="email"
249
233
  />
250
234
  </Field>
251
- {error !== null && <Banner variant="error">{error}</Banner>}
252
- <Button type="submit" loading={submitting} disabled={submitting}>
253
- {submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.submit")}
254
- </Button>
255
- <Button
256
- type="button"
257
- variant="secondary"
258
- onClick={() => {
259
- setMode(mode === "anon-existing" ? "anon-new" : "anon-existing");
260
- setError(null);
261
- }}
262
- >
263
- {mode === "anon-existing"
264
- ? t("auth.inviteAccept.toggleNew")
265
- : t("auth.inviteAccept.toggleExisting")}
266
- </Button>
267
- </Form>
268
- )}
269
- </div>
235
+ )}
236
+ <Field id="invite-password" label={t("auth.inviteAccept.password")} required>
237
+ <Input
238
+ kind="password"
239
+ id="invite-password"
240
+ name="invite-password"
241
+ value={password}
242
+ onChange={setPassword}
243
+ disabled={submitting}
244
+ required
245
+ autoComplete={mode === "anon-existing" ? "current-password" : "new-password"}
246
+ />
247
+ </Field>
248
+ {error !== null && <Banner variant="error">{error}</Banner>}
249
+ <Button type="submit" loading={submitting} disabled={submitting}>
250
+ {submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.submit")}
251
+ </Button>
252
+ <Button
253
+ type="button"
254
+ variant="secondary"
255
+ onClick={() => {
256
+ setMode(mode === "anon-existing" ? "anon-new" : "anon-existing");
257
+ setError(null);
258
+ }}
259
+ >
260
+ {mode === "anon-existing"
261
+ ? t("auth.inviteAccept.toggleNew")
262
+ : t("auth.inviteAccept.toggleExisting")}
263
+ </Button>
264
+ </Form>
265
+ )}
270
266
  </AuthCard>
271
267
  );
272
268
  }