@cosmicdrift/kumiko-samples 0.383.1 → 0.385.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 (83) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/event-classification.ts +83 -0
  5. package/packages/bundled-features/src/audit/feature.ts +3 -2
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +63 -5
  7. package/packages/bundled-features/src/audit/handlers/details-all-tenants.query.ts +6 -5
  8. package/packages/bundled-features/src/audit/handlers/details.query.ts +12 -6
  9. package/packages/bundled-features/src/audit/handlers/list-all-tenants.query.ts +20 -12
  10. package/packages/bundled-features/src/audit/handlers/list.query.ts +14 -3
  11. package/packages/bundled-features/src/audit/screens.ts +32 -4
  12. package/packages/bundled-features/src/audit/web/audit-event-cell.tsx +22 -0
  13. package/packages/bundled-features/src/audit/web/audit-event-labels.ts +30 -0
  14. package/packages/bundled-features/src/audit/web/client-plugin.tsx +10 -1
  15. package/packages/bundled-features/src/audit/web/constants.ts +11 -0
  16. package/packages/bundled-features/src/audit/web/i18n.ts +48 -1
  17. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  18. package/packages/bundled-features/src/auth-email-password/feature.ts +3 -2
  19. package/packages/bundled-features/src/auth-email-password/handlers/login-options.query.ts +19 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +7 -1
  21. package/packages/bundled-features/src/auth-email-password/password-policy.ts +196 -2087
  22. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +9 -12
  23. package/packages/bundled-features/src/auth-email-password/{auth-paths.ts → web/auth-paths.ts} +7 -4
  24. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -0
  25. package/packages/bundled-features/src/auth-email-password/web/constants.ts +1 -0
  26. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +31 -0
  27. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +68 -42
  28. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +1 -1
  29. package/packages/bundled-features/src/custom-fields/db/queries/projection.ts +1 -1
  30. package/packages/bundled-features/src/file-derivatives/feature.ts +1 -1
  31. package/packages/bundled-features/src/jobs/changes.json +6 -0
  32. package/packages/bundled-features/src/jobs/feature.ts +34 -127
  33. package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +98 -0
  34. package/packages/bundled-features/src/jobs/handlers/consumers.query.ts +137 -0
  35. package/packages/bundled-features/src/jobs/screens.ts +262 -0
  36. package/packages/bundled-features/src/jobs/web/constants.ts +29 -0
  37. package/packages/bundled-features/src/jobs/web/i18n.ts +41 -0
  38. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  39. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  40. package/packages/bundled-features/src/personal-access-tokens/changes.json +7 -0
  41. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +4 -0
  42. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -0
  43. package/packages/bundled-features/src/secrets/i18n.ts +0 -3
  44. package/packages/bundled-features/src/sessions/changes.json +6 -0
  45. package/packages/bundled-features/src/sessions/constants.ts +3 -1
  46. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +7 -3
  47. package/packages/bundled-features/src/sessions/handlers/list.query.ts +16 -10
  48. package/packages/bundled-features/src/sessions/handlers/mine.query.ts +15 -9
  49. package/packages/bundled-features/src/sessions/screens.ts +23 -8
  50. package/packages/bundled-features/src/sessions/web/client-plugin.tsx +17 -0
  51. package/packages/bundled-features/src/sessions/web/constants.ts +3 -0
  52. package/packages/bundled-features/src/sessions/web/default-translations.ts +8 -0
  53. package/packages/bundled-features/src/sessions/web/i18n.ts +5 -1
  54. package/packages/bundled-features/src/sessions/web/index.ts +1 -0
  55. package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +19 -0
  56. package/packages/bundled-features/src/sessions/web/user-agent.ts +74 -0
  57. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +1 -1
  58. package/packages/bundled-features/src/template-resolver/changes.json +6 -0
  59. package/packages/bundled-features/src/template-resolver/feature.ts +2 -0
  60. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +34 -1
  61. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +53 -0
  62. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +51 -0
  63. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +7 -38
  64. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  65. package/packages/bundled-features/src/user-data-rights/changes.json +18 -0
  66. package/packages/bundled-features/src/user-data-rights/feature.ts +33 -33
  67. package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -1
  68. package/samples/apps/showcase/src/features/items/schema/item.ts +8 -2
  69. package/samples/apps/ui-walkthrough/src/features/tasks/schema/task.ts +2 -2
  70. package/samples/apps/use-all-bundled/bin/main.ts +7 -1
  71. package/samples/apps/use-all-bundled/feature-manifest.json +19 -1
  72. package/samples/apps/use-all-bundled/kumiko/schema.ts +3 -1
  73. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  74. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +4 -2
  75. package/samples/apps/use-all-bundled/src/app/invoice-reports-feature.ts +30 -0
  76. package/samples/apps/use-all-bundled/src/app/pat-scope-labels-feature.ts +21 -0
  77. package/samples/apps/use-all-bundled/src/app/seed.ts +51 -0
  78. package/samples/apps/use-all-bundled/src/run-config.ts +5 -0
  79. package/samples/apps/workspaces/src/features/demo/schema/order.ts +2 -2
  80. package/samples/recipes/custom-fields-basic/src/feature.ts +13 -3
  81. package/samples/recipes/designer-demo/src/feature.ts +6 -2
  82. package/samples/recipes/i18n/README.md +15 -0
  83. package/samples/recipes/i18n/src/feature.ts +4 -0
@@ -1,17 +1,14 @@
1
- // Shared Web-Primitives für die Auth-Screens. Nur noch Layout/Style-
2
- // Helpers — Form/Field/Input/Button/Banner kommen jetzt über
3
- // usePrimitives() aus dem Framework-Vertrag, damit Native dieselben
4
- // Auth-Screens rendern kann (renderer-native registriert eigene
5
- // Implementations).
1
+ // Shared web primitives for the auth screens. Only layout/style helpers are
2
+ // left here; Form/Field/Input/Button/Banner come through usePrimitives() from
3
+ // the framework contract, so apps and tests can override the implementations
4
+ // via PrimitivesProvider.
6
5
  //
7
- // <AuthCard> — Card-Wrapper für die Auth-Screen-Layouts
8
- // (full-screen, zentriert, max-w-sm). Web-only;
9
- // eine Native-Variante landet bei Bedarf
10
- // daneben (z.B. SafeArea + ScrollView).
11
- // parseUrlToken — URL-Param-Helper (window.location.search).
6
+ // <AuthCard> — card wrapper for the auth screen layouts
7
+ // (full-screen, centered, max-w-sm). Web-only.
8
+ // parseUrlToken — URL param helper (window.location.search).
12
9
  //
13
- // Link-Styles laufen über das Link-Primitive (variant="button"/"muted") —
14
- // die früheren authButtonClass/authMutedLinkClass sind dorthin gewandert.
10
+ // Link styles go through the Link primitive (variant="button"/"muted"); the
11
+ // former authButtonClass/authMutedLinkClass moved there.
15
12
 
16
13
  import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
17
14
  import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer/web";
@@ -1,4 +1,4 @@
1
- // Convention paths for the four auth pages (relative to the app base URL).
1
+ // Convention paths for the five auth pages (relative to the app base URL).
2
2
  // resolveAuthMail builds each flow's magic-link appUrl from baseUrl + these;
3
3
  // the handlers append `?token=…` and mail via delivery (ctx.notify).
4
4
 
@@ -6,23 +6,26 @@
6
6
  * (e.g. `/en/reset-password`, `/de/reset-password`). */
7
7
  export type AuthPath = string | ((locale: string) => string);
8
8
 
9
- /** Pfad-Konstanten der 4 Auth-Seiten (relativ zur App-baseUrl). */
10
9
  export type AuthPaths = {
11
10
  readonly resetPassword: AuthPath;
12
11
  readonly verifyEmail: AuthPath;
13
12
  readonly signupComplete: AuthPath;
14
13
  readonly inviteAccept: AuthPath;
14
+ readonly unlockAccount: AuthPath;
15
15
  };
16
16
 
17
- /** Konventions-Pfade — alle Kumiko-Apps nutzen dieselben. Apps überschreiben
18
- * nur die Ausnahme via `makeAuthPaths({ ... })`. */
17
+ /** Shared by every Kumiko app; apps override only the exceptions via `makeAuthPaths({ ... })`. */
19
18
  export const DEFAULT_AUTH_PATHS = {
20
19
  resetPassword: "/reset-password",
21
20
  verifyEmail: "/verify-email",
22
21
  signupComplete: "/signup/complete",
23
22
  inviteAccept: "/invite/accept",
23
+ unlockAccount: "/unlock-account",
24
24
  } as const satisfies AuthPaths;
25
25
 
26
+ /** Client page that requests a reset mail; no mail links to it, so it is not part of AuthPaths. */
27
+ export const DEFAULT_FORGOT_PASSWORD_PATH = "/forgot-password";
28
+
26
29
  export function makeAuthPaths(overrides: Partial<AuthPaths> = {}): AuthPaths {
27
30
  return { ...DEFAULT_AUTH_PATHS, ...overrides };
28
31
  }
@@ -13,6 +13,7 @@ import {
13
13
  makeSessionAuthGate,
14
14
  } from "./auth-gate.tsx";
15
15
  import { emailPasswordClientActions } from "./client-actions.ts";
16
+ import { DefaultAuthPagesGate } from "./default-auth-pages-gate.tsx";
16
17
  import { defaultTranslations, mergeTranslations } from "./i18n.ts";
17
18
  import type { LoginScreenProps } from "./login-screen.tsx";
18
19
 
@@ -45,6 +46,11 @@ export type EmailPasswordClientOptions = {
45
46
  * Für Branding ("Sign in" → "Login to Acme") oder weitere Sprachen
46
47
  * (`fr`, `es`, …) zusätzlich. */
47
48
  readonly translations?: TranslationsByLocale;
49
+ /** Serve the forgot-password, reset-password and unlock-account pages at
50
+ * their convention paths for signed-out visitors. Apps with their own
51
+ * screens at those paths either register an earlier gate (it wins) or
52
+ * set this to false. Default: true. */
53
+ readonly defaultAuthPages?: boolean;
48
54
  };
49
55
 
50
56
  // Structurally identical to the renderer/web ClientFeatureDefinition, but
@@ -66,6 +72,7 @@ export function emailPasswordClient(
66
72
  name: "auth-email-password",
67
73
  providers: [],
68
74
  gates: [
75
+ ...(options.defaultAuthPages === false ? [] : [DefaultAuthPagesGate]),
69
76
  makeSessionAuthGate({
70
77
  loginScreen: options.loginScreen,
71
78
  loginScreenProps: options.loginScreenProps,
@@ -48,6 +48,7 @@ export const AuthHandlers = {
48
48
  // Qualified query names. Anonymous-readable status so the (unauthenticated)
49
49
  // signup page can decide whether to show its own link/form.
50
50
  export const AuthQueries = {
51
+ loginOptions: "auth-email-password:query:login-options",
51
52
  signupRegistrationStatus: "auth-email-password:query:signup-registration-status",
52
53
  // Anonymous, read-only invite lookup for the invite-acceptance page —
53
54
  // see invite-info.query.ts. Does not consume the token.
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react";
2
+ import { DEFAULT_AUTH_PATHS, DEFAULT_FORGOT_PASSWORD_PATH } from "./auth-paths.ts";
3
+ import { ConfirmAccountUnlockScreen } from "./confirm-account-unlock-screen.tsx";
4
+ import { ForgotPasswordScreen } from "./forgot-password-screen.tsx";
5
+ import { RequestAccountUnlockScreen } from "./request-account-unlock-screen.tsx";
6
+ import { ResetPasswordScreen } from "./reset-password-screen.tsx";
7
+
8
+ function hasToken(search: string): boolean {
9
+ return (new URLSearchParams(search).get("token") ?? "") !== "";
10
+ }
11
+
12
+ function pickDefaultAuthPage(pathname: string, search: string): ReactNode {
13
+ const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
14
+ switch (path) {
15
+ case DEFAULT_FORGOT_PASSWORD_PATH:
16
+ return <ForgotPasswordScreen />;
17
+ case DEFAULT_AUTH_PATHS.resetPassword:
18
+ return hasToken(search) ? <ResetPasswordScreen /> : null;
19
+ case DEFAULT_AUTH_PATHS.unlockAccount:
20
+ return hasToken(search) ? <ConfirmAccountUnlockScreen /> : <RequestAccountUnlockScreen />;
21
+ default:
22
+ return null;
23
+ }
24
+ }
25
+
26
+ // Sits inside emailPasswordClient's gate list, so app gates registered earlier
27
+ // (outer) still win on the same path. The pages are public: no session needed.
28
+ export function DefaultAuthPagesGate({ children }: { readonly children: ReactNode }): ReactNode {
29
+ const { pathname, search } = window.location;
30
+ return pickDefaultAuthPage(pathname, search) ?? <>{children}</>;
31
+ }
@@ -1,14 +1,12 @@
1
- // Default-LoginScreen. Reine E-Mail-+-Passwort-Form, zentriert, Card-
2
- // Layout. Texte kommen aus `useTranslation()` — die Default-Bundles
3
- // (de+en) liefert das Feature selber mit (translations.ts); Apps die
4
- // was anderes wollen, setzen entweder einen eigenen LocaleResolver auf
5
- // App-Level oder reichen Key-Overrides an `emailPasswordClient()`.
6
- //
7
- // Die Reason-Codes aus dem Login-Handler werden 1:1 auf i18n-Keys
8
- // gemappt (reasonToI18nKey) — neue Reason-Codes brauchen nur eine
9
- // neue Zeile im Bundle + hier im Mapping.
1
+ // Login handler reason codes map 1:1 to i18n keys (reasonToI18nKey): a new
2
+ // reason code needs a bundle entry plus a line in that mapping.
10
3
 
11
- import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import {
5
+ useOptionalDispatcher,
6
+ usePrimitives,
7
+ useQuery,
8
+ useTranslation,
9
+ } from "@cosmicdrift/kumiko-renderer";
12
10
  import { type FormEvent, type ReactNode, useState } from "react";
13
11
  import {
14
12
  type KnownLoginFailureReason,
@@ -16,11 +14,12 @@ import {
16
14
  requestEmailVerification,
17
15
  } from "./auth-client.ts";
18
16
  import { AuthCard } from "./auth-form-primitives.tsx";
17
+ import { DEFAULT_AUTH_PATHS, DEFAULT_FORGOT_PASSWORD_PATH } from "./auth-paths.ts";
19
18
  import { followNextOrLandingAfterLogin } from "./auth-redirect.ts";
19
+ import { AuthQueries } from "./constants.ts";
20
20
  import { useSession } from "./session.tsx";
21
21
 
22
- // Resend-Status für den "Bestätigungs-Mail erneut senden"-Flow, der bei
23
- // reason=email_not_verified unter dem Fehler-Banner angeboten wird.
22
+ // Resend state for the confirmation mail offered under the error banner on reason=email_not_verified.
24
23
  type ResendStatus =
25
24
  | { readonly kind: "idle" }
26
25
  | { readonly kind: "sending" }
@@ -34,32 +33,27 @@ export type AuthLegalLink = {
34
33
  };
35
34
 
36
35
  export type LoginScreenProps = {
37
- /** Overridet den `auth.login.title`-i18n-Key. Nur setzen wenn der
38
- * Titel stark app-branded ist und keine Translation braucht. */
36
+ /** Overrides the `auth.login.title` key; only for a branded title that needs no translation. */
39
37
  readonly title?: string;
40
38
  readonly subtitle?: ReactNode;
41
39
  readonly submitLabel?: string;
42
- /** Optional href zum ForgotPasswordScreen. Wenn gesetzt rendert die
43
- * LoginScreen unter dem Submit-Button einen "Passwort vergessen?"-
44
- * Link. Apps die den Reset-Flow NICHT anbieten (z.B. nur Magic-Link),
45
- * setzen das einfach nicht — Login bleibt minimalistisch. */
46
- readonly forgotPasswordHref?: string;
40
+ /** Href of the "Forgot password?" link under the submit button. Unset: the
41
+ * default path once the server offers the reset flow (login-options);
42
+ * `null` hides the link deliberately. */
43
+ readonly forgotPasswordHref?: string | null;
47
44
  /** Optional href to RequestAccountUnlockScreen (#1266). When set, an
48
45
  * account_locked login error renders an "Unlock account?" link inline —
49
46
  * the account-lockout escape hatch, since forgotPasswordHref does NOT
50
47
  * help here: the lockout gate blocks login before password verify even
51
48
  * runs, so it also blocks the post-reset login. Apps without
52
- * accountUnlock configured just leave this unset. */
53
- readonly unlockAccountHref?: string;
54
- /** Optional href zum SignupScreen. Wenn gesetzt rendert die LoginScreen
55
- * einen "Account erstellen"-Link. Apps die kein Self-Signup wollen
56
- * (closed-invite-only) setzen das einfach nicht. */
49
+ * accountUnlock configured just leave this unset. Unset: default path once
50
+ * the server offers the flow (login-options); `null` = deliberately no link. */
51
+ readonly unlockAccountHref?: string | null;
52
+ /** Href of the "Create account" link; invite-only apps leave it unset. */
57
53
  readonly signupHref?: string;
58
- /** Impressum/Datenschutz unterhalb der Card — der Login-Screen ist
59
- * oft die einzige öffentliche Seite einer Admin-Domain und braucht
60
- * dann selbst erreichbare Legal-Links (DE: Impressumspflicht).
61
- * Labels kommen vom Caller (typisch schon übersetzt bzw. Eigennamen
62
- * wie "Impressum"). */
54
+ /** Imprint/privacy links under the card: the login screen is often the only
55
+ * public page of an admin domain, which then must reach its legal pages
56
+ * itself (German imprint duty). Labels come translated from the caller. */
63
57
  readonly legalLinks?: readonly AuthLegalLink[];
64
58
  /** Called when the server responds with an MFA challenge instead of a
65
59
  * session. Apps typically swap this screen out for auth-mfa's
@@ -112,7 +106,7 @@ function reasonToKey(failure: LoginFailure): {
112
106
  }
113
107
  }
114
108
 
115
- export function LoginScreen({
109
+ function LoginScreenBody({
116
110
  title,
117
111
  subtitle,
118
112
  submitLabel,
@@ -122,7 +116,8 @@ export function LoginScreen({
122
116
  legalLinks,
123
117
  onMfaChallenge,
124
118
  onMfaSetupRequired,
125
- }: LoginScreenProps): ReactNode {
119
+ loginOptions,
120
+ }: LoginScreenProps & { readonly loginOptions: LoginOptions | null }): ReactNode {
126
121
  const t = useTranslation();
127
122
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
128
123
  const session = useSession();
@@ -131,10 +126,21 @@ export function LoginScreen({
131
126
  const [submitting, setSubmitting] = useState(false);
132
127
  const [error, setError] = useState<LoginFailure | null>(null);
133
128
  const [resendStatus, setResendStatus] = useState<ResendStatus>({ kind: "idle" });
134
- // Tracked, damit der Resend-Button verschwindet sobald der User die
135
- // Email-Eingabe ändert — sonst würde Resend silent an die geänderte
136
- // (potentiell typoed) Adresse gehen ohne User-Feedback.
129
+ // Hides the resend button once the email input changes; otherwise resend
130
+ // would silently go to the edited, possibly mistyped address.
137
131
  const [failedLoginEmail, setFailedLoginEmail] = useState<string | null>(null);
132
+ const effectiveForgotHref =
133
+ forgotPasswordHref === undefined
134
+ ? loginOptions?.passwordReset === true
135
+ ? DEFAULT_FORGOT_PASSWORD_PATH
136
+ : null
137
+ : forgotPasswordHref;
138
+ const effectiveUnlockHref =
139
+ unlockAccountHref === undefined
140
+ ? loginOptions?.accountUnlock === true
141
+ ? DEFAULT_AUTH_PATHS.unlockAccount
142
+ : null
143
+ : unlockAccountHref;
138
144
 
139
145
  const doSubmit = async (): Promise<void> => {
140
146
  setSubmitting(true);
@@ -173,10 +179,8 @@ export function LoginScreen({
173
179
  void doSubmit();
174
180
  };
175
181
 
176
- // Resend-Bestätigungsmail bei reason=email_not_verified. requestEmail-
177
- // Verification ist silent-success (200 auch wenn kein User existiert),
178
- // sodass kein anti-enumeration-Branching nötig ist; 429 → rate-limit-
179
- // Hint inline, sonstige Fehler → generischer Inline-Hint.
182
+ // requestEmailVerification answers 200 even for unknown users, so no
183
+ // anti-enumeration branching is needed; only 429 gets its own hint.
180
184
  const onResend = async (): Promise<void> => {
181
185
  setResendStatus({ kind: "sending" });
182
186
  try {
@@ -252,9 +256,9 @@ export function LoginScreen({
252
256
  </Button>
253
257
  </span>
254
258
  )}
255
- {error.reason === "account_locked" && unlockAccountHref !== undefined && (
259
+ {error.reason === "account_locked" && effectiveUnlockHref !== null && (
256
260
  <span className="self-start">
257
- <Link href={unlockAccountHref} variant="default">
261
+ <Link href={effectiveUnlockHref} variant="default">
258
262
  {t("auth.login.unlockAccount")}
259
263
  </Link>
260
264
  </span>
@@ -272,8 +276,8 @@ export function LoginScreen({
272
276
  {submitting ? t("auth.login.submitting") : effectiveSubmit}
273
277
  </Button>
274
278
  </Form>
275
- {forgotPasswordHref !== undefined && (
276
- <Link href={forgotPasswordHref} variant="muted" className="self-center">
279
+ {effectiveForgotHref !== null && (
280
+ <Link href={effectiveForgotHref} variant="muted" className="self-center">
277
281
  {t("auth.login.forgotPassword")}
278
282
  </Link>
279
283
  )}
@@ -297,3 +301,25 @@ export function LoginScreen({
297
301
  </AuthCard>
298
302
  );
299
303
  }
304
+
305
+ type LoginOptions = { readonly passwordReset: boolean; readonly accountUnlock: boolean };
306
+
307
+ function LoginScreenWithQueriedOptions(props: LoginScreenProps): ReactNode {
308
+ const { data } = useQuery<LoginOptions>(
309
+ AuthQueries.loginOptions,
310
+ {},
311
+ { enabled: props.forgotPasswordHref === undefined || props.unlockAccountHref === undefined },
312
+ );
313
+ return <LoginScreenBody {...props} loginOptions={data} />;
314
+ }
315
+
316
+ // Without a mounted dispatcher (standalone mounts, tests) the query is skipped
317
+ // and only explicit hrefs render.
318
+ export function LoginScreen(props: LoginScreenProps): ReactNode {
319
+ const dispatcher = useOptionalDispatcher();
320
+ return dispatcher === undefined ? (
321
+ <LoginScreenBody {...props} loginOptions={null} />
322
+ ) : (
323
+ <LoginScreenWithQueriedOptions {...props} />
324
+ );
325
+ }
@@ -16,7 +16,7 @@ export const unreadCountQuery = defineQueryHandler({
16
16
  description:
17
17
  "Counts the calling user's unread in-app notification messages; use it for an inbox badge or to check whether a user has pending notifications.",
18
18
  handler: async (query, ctx) => {
19
- // bun-db hat keinen aggregate-helper — selectMany alle matching rows,
19
+ // The query helpers in db/query.ts have no aggregate helper — selectMany alle matching rows,
20
20
  // count() in JS. Pragma: unread-counts sind low-cardinality (user-
21
21
  // scoped, max ~hunderte rows). Wenn das wachsen sollte: raw .unsafe()
22
22
  // mit COUNT(*).
@@ -9,7 +9,7 @@ function quoteTable(tableName: string): string {
9
9
  function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
10
10
  // Scalar JSON primitives can't bind directly to ::jsonb — Postgres rejects a
11
11
  // bound boolean/number with "cannot cast type boolean/integer to jsonb" (and
12
- // Bun.SQL infers boolean[] candidates). Route them through ::text::jsonb with
12
+ // the driver may infer array candidates). Route them through ::text::jsonb with
13
13
  // a JSON-encoded literal. Objects/arrays/strings already bind as ::jsonb.
14
14
  if (typeof value === "boolean" || typeof value === "number") {
15
15
  return { sql: "$1::text::jsonb", bound: JSON.stringify(value) };
@@ -183,7 +183,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
183
183
  // answer identically, no name-oracle. fileRefsTable.id is a UUID
184
184
  // column — a malformed id must fail here, not at the DB. Without
185
185
  // this, an anonymous, internet-facing caller can throw a Postgres
186
- // 22P02 on every request (Bun.SQL pools the failed connection), a
186
+ // 22P02 on every request (the pooled connection is affected), a
187
187
  // DoS primitive with no auth required.
188
188
  if (!fileRefId || !variant || !VARIANT_NAME_RE.test(variant) || !isUuid(fileRefId)) {
189
189
  return c.text("not found", 404);
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "improvement",
5
+ "title": "Event consumers screen with restart, skip, pause and resume",
6
+ "detail": "SystemAdmins get the screens event-consumers and event-consumer-detail. A stopped consumer offers Restart as the primary action; Skip event (with a confirm dialog), Pause and Resume sit in the overflow menu. The write handlers no longer return the raw error text: unknown consumer, wrong status and nothing to skip have their own codes and messages, anything else is logged on the server and answers a generic message. The shared instance reads \"Shared\"."
7
+ },
2
8
  {
3
9
  "version": "0.383.0",
4
10
  "type": "breaking",
@@ -1,9 +1,12 @@
1
- import {
2
- defineFeature,
3
- type FeatureDefinition,
4
- i18nKey,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { catalogQuery } from "./handlers/catalog.query.ts";
3
+ import {
4
+ consumerDisableWrite,
5
+ consumerEnableWrite,
6
+ consumerRestartWrite,
7
+ consumerSkipWrite,
8
+ } from "./handlers/consumer.write.ts";
9
+ import { consumerDetailQuery, consumersQuery } from "./handlers/consumers.query.ts";
7
10
  import { detailQuery } from "./handlers/detail.query.ts";
8
11
  import { listQuery } from "./handlers/list.query.ts";
9
12
  import {
@@ -24,14 +27,15 @@ import { tenantFailuresQuery } from "./handlers/tenant-failures.query.ts";
24
27
  import { tenantRunsQuery } from "./handlers/tenant-runs.query.ts";
25
28
  import { triggerWrite } from "./handlers/trigger.write.ts";
26
29
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.ts";
30
+ import {
31
+ eventConsumerDetailScreen,
32
+ eventConsumersScreen,
33
+ jobRunDetailScreen,
34
+ jobRunsScreen,
35
+ jobTriggerScreen,
36
+ } from "./screens.ts";
27
37
  import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table.ts";
28
38
  import { tenantJobRunsTableMeta } from "./tenant-job-run-table.ts";
29
- import {
30
- JOB_RUN_DETAIL_SCREEN_ID,
31
- JOB_RUNS_SCREEN_ID,
32
- JobHandlers,
33
- JobQueries,
34
- } from "./web/constants.ts";
35
39
  import { JOBS_I18N } from "./web/i18n.ts";
36
40
 
37
41
  export type JobsFeatureOptions = {
@@ -124,6 +128,10 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
124
128
  const handlers = {
125
129
  trigger: r.writeHandler(triggerWrite),
126
130
  retry: r.writeHandler(retryWrite),
131
+ consumerRestart: r.writeHandler(consumerRestartWrite),
132
+ consumerSkip: r.writeHandler(consumerSkipWrite),
133
+ consumerDisable: r.writeHandler(consumerDisableWrite),
134
+ consumerEnable: r.writeHandler(consumerEnableWrite),
127
135
  };
128
136
 
129
137
  const queries = {
@@ -132,126 +140,15 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
132
140
  catalog: r.queryHandler(catalogQuery),
133
141
  failures: r.queryHandler(tenantFailuresQuery),
134
142
  tenantRuns: r.queryHandler(tenantRunsQuery),
143
+ consumers: r.queryHandler(consumersQuery),
144
+ consumerDetails: r.queryHandler(consumerDetailQuery),
135
145
  };
136
146
 
137
- const systemAdminAccess = { roles: ["SystemAdmin"] as const };
138
-
139
147
  r.translations({ keys: JOBS_I18N });
140
148
 
141
- r.screen({
142
- id: JOB_RUNS_SCREEN_ID,
143
- type: "projectionList",
144
- query: JobQueries.list,
145
- columns: [
146
- { field: "jobName", label: i18nKey("jobs.runs.col.job") },
147
- { field: "status", label: i18nKey("jobs.runs.col.status") },
148
- {
149
- field: "startedAt",
150
- label: i18nKey("jobs.runs.col.started"),
151
- renderer: { format: "timestamp" },
152
- },
153
- { field: "duration", label: i18nKey("jobs.runs.col.duration") },
154
- ],
155
- defaultSort: { field: "startedAt", dir: "desc" },
156
- facets: [
157
- {
158
- field: "status",
159
- type: "select",
160
- label: i18nKey("jobs.runs.filter.status"),
161
- options: [
162
- { value: "queued", label: i18nKey("jobs.runs.filter.queued") },
163
- { value: "running", label: i18nKey("jobs.runs.filter.running") },
164
- { value: "completed", label: i18nKey("jobs.runs.filter.completed") },
165
- { value: "failed", label: i18nKey("jobs.runs.filter.failed") },
166
- ],
167
- },
168
- ],
169
- rowActions: [
170
- {
171
- kind: "navigate",
172
- id: "open",
173
- label: i18nKey("jobs.runs.open"),
174
- screen: JOB_RUN_DETAIL_SCREEN_ID,
175
- entityId: "id",
176
- rowClick: true,
177
- },
178
- ],
179
- toolbarActions: [
180
- {
181
- kind: "drawer",
182
- id: "trigger",
183
- label: i18nKey("jobs.trigger.title"),
184
- screen: "job-trigger",
185
- style: "primary",
186
- },
187
- ],
188
- pagination: "infinite",
189
- access: systemAdminAccess,
190
- });
191
- r.screen({
192
- id: "job-trigger",
193
- type: "actionForm",
194
- handler: JobHandlers.trigger,
195
- fields: {
196
- jobName: { type: "text", required: true },
197
- payload: { type: "longText", multiline: true, default: "{}" },
198
- },
199
- layout: { sections: [{ fields: ["jobName", "payload"] }] },
200
- submitLabel: i18nKey("jobs.trigger.submit"),
201
- access: systemAdminAccess,
202
- description: "jobs.screen.job-trigger.subtitle",
203
- });
204
- r.screen({
205
- id: JOB_RUN_DETAIL_SCREEN_ID,
206
- type: "projectionDetail",
207
- query: JobQueries.details,
208
- idParam: "runId",
209
- fieldLabels: {
210
- jobName: i18nKey("jobs.detail.field.job"),
211
- status: i18nKey("jobs.detail.field.status"),
212
- id: i18nKey("jobs.detail.field.id"),
213
- startedAt: i18nKey("jobs.detail.field.started"),
214
- finishedAt: i18nKey("jobs.detail.field.finished"),
215
- duration: i18nKey("jobs.detail.field.duration"),
216
- error: i18nKey("jobs.detail.field.error"),
217
- logs: i18nKey("jobs.detail.logs"),
218
- },
219
- layout: {
220
- sections: [
221
- {
222
- fields: [
223
- "jobName",
224
- "status",
225
- "id",
226
- { field: "startedAt", renderer: { format: "timestamp" } },
227
- { field: "finishedAt", renderer: { format: "timestamp" } },
228
- "duration",
229
- "error",
230
- ],
231
- },
232
- // No section `title` here (fw#2312 label-dedup fix): the section
233
- // holds exactly one field, so a title would repeat the field's own
234
- // label (rendered by RenderField as the Field's heading) verbatim.
235
- {
236
- fields: [{ field: "logs", renderer: { format: "json" } }],
237
- },
238
- ],
239
- },
240
- actions: [
241
- {
242
- kind: "writeHandler",
243
- id: "retry",
244
- label: i18nKey("jobs.detail.retry"),
245
- handler: JobHandlers.retry,
246
- payload: { map: { runId: "id" } },
247
- visible: { field: "status", eq: "failed" },
248
- style: "primary",
249
- },
250
- ],
251
- description: "jobs.screen.job-run-detail.subtitle",
252
- listScreenId: JOB_RUNS_SCREEN_ID,
253
- access: systemAdminAccess,
254
- });
149
+ r.screen(jobRunsScreen);
150
+ r.screen(jobTriggerScreen);
151
+ r.screen(jobRunDetailScreen);
255
152
  r.nav({
256
153
  id: "job-runs",
257
154
  label: "jobs:nav.jobRuns",
@@ -260,6 +157,16 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
260
157
  order: 10,
261
158
  });
262
159
 
160
+ r.screen(eventConsumersScreen);
161
+ r.screen(eventConsumerDetailScreen);
162
+ r.nav({
163
+ id: "event-consumers",
164
+ label: "jobs:nav.eventConsumers",
165
+ icon: "list",
166
+ screen: "jobs:screen:event-consumers",
167
+ order: 11,
168
+ });
169
+
263
170
  return { handlers, queries };
264
171
  });
265
172
  }
@@ -0,0 +1,98 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ InternalError,
4
+ UnprocessableError,
5
+ writeFailure,
6
+ } from "@cosmicdrift/kumiko-framework/errors";
7
+ import {
8
+ type ConsumerRecoveryState,
9
+ ConsumerStatuses,
10
+ ConsumerTransitionError,
11
+ disableConsumer,
12
+ enableConsumer,
13
+ restartConsumer,
14
+ skipPoisonEvent,
15
+ } from "@cosmicdrift/kumiko-framework/pipeline";
16
+ import * as z from "zod";
17
+ import { JobErrors } from "../web/constants.ts";
18
+
19
+ const consumerTargetSchema = z.object({ name: z.string().min(1), instanceId: z.string().min(1) });
20
+
21
+ const CONSUMER_STATE_REASON = "event consumer state is global dispatcher state, not tenant data";
22
+
23
+ function rejected(code: string, i18nKey: string) {
24
+ return writeFailure(new UnprocessableError(code, { i18nKey }));
25
+ }
26
+
27
+ type ConsumerTransition = (
28
+ db: Parameters<typeof restartConsumer>[0],
29
+ name: string,
30
+ instanceId: string,
31
+ ) => Promise<ConsumerRecoveryState & { readonly skippedEventId?: bigint | null }>;
32
+
33
+ function defineConsumerWrite(name: string, description: string, transition: ConsumerTransition) {
34
+ return defineWriteHandler({
35
+ name,
36
+ description,
37
+ schema: consumerTargetSchema,
38
+ access: { roles: ["SystemAdmin"] },
39
+ // Operator recovery for people only; skip drops an event for good.
40
+ agent: { expose: false },
41
+ handler: async (event, ctx) => {
42
+ if (!ctx.systemDb) {
43
+ throw new InternalError({
44
+ message: `jobs:write:${name} requires ctx.systemDb (feature must declare r.systemScope())`,
45
+ });
46
+ }
47
+ const db = ctx.systemDb.unsafeRaw(CONSUMER_STATE_REASON);
48
+ try {
49
+ const next = await transition(db, event.payload.name, event.payload.instanceId);
50
+ if (next.skippedEventId === null) {
51
+ return rejected(JobErrors.consumerNothingToSkip, "jobs.errors.consumerNothingToSkip");
52
+ }
53
+ return {
54
+ isSuccess: true,
55
+ data: {
56
+ name: next.name,
57
+ instanceId: next.instanceId,
58
+ status: next.status,
59
+ skippedEventId: next.skippedEventId?.toString() ?? null,
60
+ },
61
+ };
62
+ } catch (e) {
63
+ if (e instanceof ConsumerTransitionError) {
64
+ return e.rejection === "unknown-consumer"
65
+ ? rejected(JobErrors.consumerUnknown, "jobs.errors.consumerUnknown")
66
+ : rejected(JobErrors.consumerWrongStatus, "jobs.errors.consumerWrongStatus");
67
+ }
68
+ ctx.log?.error(`jobs:write:${name} failed: ${e instanceof Error ? e.message : String(e)}`, {
69
+ consumer: event.payload.name,
70
+ instanceId: event.payload.instanceId,
71
+ });
72
+ return rejected(JobErrors.consumerTransitionFailed, "jobs.errors.consumerTransitionFailed");
73
+ }
74
+ },
75
+ });
76
+ }
77
+
78
+ export const consumerRestartWrite = defineConsumerWrite(
79
+ "consumer-restart",
80
+ "Sets a dead event consumer back to idle so it retries the event that stopped it; use it after a transient failure is fixed.",
81
+ restartConsumer,
82
+ );
83
+ export const consumerSkipWrite = defineConsumerWrite(
84
+ "consumer-skip",
85
+ "Moves a stuck event consumer past the event that keeps failing; that event is never delivered to this consumer, so use it only for events that can never succeed.",
86
+ (db, consumerName, instanceId) =>
87
+ skipPoisonEvent(db, consumerName, instanceId, { requireStatus: ConsumerStatuses.dead }),
88
+ );
89
+ export const consumerDisableWrite = defineConsumerWrite(
90
+ "consumer-disable",
91
+ "Pauses an event consumer; the dispatcher skips it until it is enabled again.",
92
+ disableConsumer,
93
+ );
94
+ export const consumerEnableWrite = defineConsumerWrite(
95
+ "consumer-enable",
96
+ "Resumes a paused event consumer from where it stopped.",
97
+ enableConsumer,
98
+ );