@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/package.json +7 -5
- package/src/auth-email-password/i18n.ts +8 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +128 -1
- package/src/auth-email-password/web/login-screen.tsx +73 -8
- package/src/config/__tests__/cascade.integration.ts +419 -0
- package/src/config/__tests__/config.integration.ts +109 -2
- package/src/config/constants.ts +1 -0
- package/src/config/feature.ts +2 -0
- package/src/config/handlers/cascade.query.ts +70 -0
- package/src/config/handlers/values.query.ts +14 -4
- package/src/config/index.ts +17 -0
- package/src/config/resolver.ts +273 -1
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +6 -4
- package/src/delivery/index.ts +0 -1
- package/src/legal-pages/web/client-plugin.ts +50 -10
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +205 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +71 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +81 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +105 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +2 -0
- package/src/text-content/handlers/set.write.ts +23 -0
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/client-plugin.ts +0 -113
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-bundled-features
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
|
|
8
|
+
|
|
9
|
+
LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
|
|
10
|
+
im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
|
|
11
|
+
wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
|
|
12
|
+
("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
|
|
13
|
+
|
|
14
|
+
UX-Details:
|
|
15
|
+
|
|
16
|
+
- Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
|
|
17
|
+
- Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
|
|
18
|
+
- Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
|
|
19
|
+
verschwindet der Resend-Link — sonst würde Resend silent-success an die
|
|
20
|
+
geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
|
|
21
|
+
- Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
|
|
22
|
+
Resend-Link.
|
|
23
|
+
|
|
24
|
+
i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
|
|
25
|
+
Apps die ihre Translations override-en müssen nichts ändern.
|
|
26
|
+
|
|
27
|
+
Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [010b410]
|
|
30
|
+
- @cosmicdrift/kumiko-framework@0.4.1
|
|
31
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.4.1
|
|
32
|
+
- @cosmicdrift/kumiko-renderer@0.4.1
|
|
33
|
+
- @cosmicdrift/kumiko-renderer-web@0.4.1
|
|
34
|
+
|
|
35
|
+
## 0.4.0
|
|
36
|
+
|
|
37
|
+
### Minor Changes
|
|
38
|
+
|
|
39
|
+
- 825e7d2: Visual-Tree V.1.4 → V.1.6 — Feature-complete Editor + Folder-Hierarchy + Roving-tabindex.
|
|
40
|
+
|
|
41
|
+
**V.1.4** — explicit `folder?: string` Schema-Field auf text-block-entity. Slug bleibt
|
|
42
|
+
kebab-only validiert, Folder explizit gesetzt. Tree gruppiert via `groupBlocksByFolder`
|
|
43
|
+
(ersetzt `groupBlocksBySlugPrefix`). `Subscribe<T>` Signature um optional `emitError`
|
|
44
|
+
erweitert für explicit async-error-Pfade. ProviderBranch zeigt Error-Banner mit
|
|
45
|
+
Retry-Button. Drift-Test pinnt seedTextBlock-vs-set.write Slug-Validation.
|
|
46
|
+
|
|
47
|
+
**V.1.4b** — URL-State-Routing für Editor-Target via `nav.searchParams`. F5 + Back-Button
|
|
48
|
+
stellen den Editor-State wieder her. Format: `?t=text-content:edit&a_slug=...&a_lang=...`.
|
|
49
|
+
Plus `useDispatchTarget` hook ersetzt globalen `dispatchTarget` als empfohlenen Production-
|
|
50
|
+
Pfad (legacy bleibt für Test-Hooks).
|
|
51
|
+
|
|
52
|
+
**V.1.5** — Arrow-Key-Navigation (`<aside role="tree">`, ARIA-tree-Pattern) + SSE-driven
|
|
53
|
+
Tree-Refresh. `ClientFeatureDefinition.treeEntities?: string[]` listet Entity-Namen pro
|
|
54
|
+
Provider; live-events triggern provider-re-mount → Stale-Tree-state="stub"→"filled"
|
|
55
|
+
flippt nach save automatisch.
|
|
56
|
+
|
|
57
|
+
**V.1.5c+d** — Active-Node-Highlight (explicit blue + 2px border-l + scrollIntoView),
|
|
58
|
+
VS-Code-Polish (compact spacing, focus-visible, folder-icon-color text-amber, indent-
|
|
59
|
+
guides per ancestor-depth), Folder-Wrapper für legal-pages ("📁 Legal" + slug-first
|
|
60
|
+
Verschachtelung) und text-content ("📁 Content").
|
|
61
|
+
|
|
62
|
+
**V.1.6** — Multi-level Folder-Splitting (`folder="page/marketing"` → nested folders,
|
|
63
|
+
walk-or-create-pattern, folder/leaf-collision-tolerant). Roving-tabindex (nur focused-
|
|
64
|
+
treeitem hat tabIndex=0, Tab cyclt aus dem Tree raus).
|
|
65
|
+
|
|
66
|
+
35/35 kumiko check PASS, 13/13 group-blocks + 22/22 text-content integration tests grün.
|
|
67
|
+
Browser + Keyboard lokal validated.
|
|
68
|
+
|
|
69
|
+
**Breaking**: `TreeContext` Type entfernt (V.1.2 SR2-Rip — war nie genutzt). Provider sind
|
|
70
|
+
session-bound: `TreeChildrenSubscribe = () => Subscribe<T>` statt `(ctx) => Subscribe<T>`.
|
|
71
|
+
|
|
72
|
+
**V.1.7-Followups**: useEffect-deps in VisualTree-focus-init (Performance), Cancellation-
|
|
73
|
+
Token in TreeProvider's fetch (emit-after-unmount-warning), inline-rename, drag-drop,
|
|
74
|
+
file-icons per slug-extension, parent-jump bei ArrowLeft auf collapsed-item.
|
|
75
|
+
|
|
76
|
+
### Patch Changes
|
|
77
|
+
|
|
78
|
+
- Updated dependencies [825e7d2]
|
|
79
|
+
- @cosmicdrift/kumiko-framework@0.4.0
|
|
80
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.4.0
|
|
81
|
+
- @cosmicdrift/kumiko-renderer@0.4.0
|
|
82
|
+
- @cosmicdrift/kumiko-renderer-web@0.4.0
|
|
83
|
+
|
|
3
84
|
## 0.3.0
|
|
4
85
|
|
|
5
86
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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>",
|
|
@@ -64,6 +64,8 @@
|
|
|
64
64
|
"./text-content": "./src/text-content/index.ts",
|
|
65
65
|
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
66
66
|
"./text-content/web": "./src/text-content/web/index.ts",
|
|
67
|
+
"./template-resolver": "./src/template-resolver/index.ts",
|
|
68
|
+
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
67
69
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
68
70
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
69
71
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
@@ -72,10 +74,10 @@
|
|
|
72
74
|
"@aws-sdk/client-s3": "^3.1045.0",
|
|
73
75
|
"@aws-sdk/lib-storage": "^3.1045.0",
|
|
74
76
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
75
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
77
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
78
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
77
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.4.1",
|
|
78
|
+
"@cosmicdrift/kumiko-framework": "0.4.1",
|
|
79
|
+
"@cosmicdrift/kumiko-renderer": "0.4.1",
|
|
80
|
+
"@cosmicdrift/kumiko-renderer-web": "0.4.1",
|
|
79
81
|
"@mollie/api-client": "^4.5.0",
|
|
80
82
|
"@node-rs/argon2": "^2.0.2",
|
|
81
83
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -18,6 +18,10 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
18
18
|
"auth.login.submit": "Einloggen",
|
|
19
19
|
"auth.login.submitting": "…",
|
|
20
20
|
"auth.login.forgotPassword": "Passwort vergessen?",
|
|
21
|
+
"auth.login.resendVerification": "Bestätigungs-Mail erneut senden",
|
|
22
|
+
"auth.login.resendSuccess": "Wir haben dir eine neue Bestätigungs-Mail geschickt.",
|
|
23
|
+
"auth.login.resendRateLimited": "Bitte warte kurz und versuche es erneut.",
|
|
24
|
+
"auth.login.resendError": "Konnte nicht senden. Bitte erneut versuchen.",
|
|
21
25
|
"auth.errors.invalidCredentials": "E-Mail oder Passwort falsch.",
|
|
22
26
|
"auth.errors.noMembership": "Dieses Konto hat keinen Tenant-Zugang.",
|
|
23
27
|
"auth.errors.accountLocked": "Konto vorübergehend gesperrt.",
|
|
@@ -113,6 +117,10 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
113
117
|
"auth.login.submit": "Sign in",
|
|
114
118
|
"auth.login.submitting": "…",
|
|
115
119
|
"auth.login.forgotPassword": "Forgot password?",
|
|
120
|
+
"auth.login.resendVerification": "Send verification email again",
|
|
121
|
+
"auth.login.resendSuccess": "We've sent you a new verification email.",
|
|
122
|
+
"auth.login.resendRateLimited": "Please wait a moment and try again.",
|
|
123
|
+
"auth.login.resendError": "Could not send. Please try again.",
|
|
116
124
|
"auth.errors.invalidCredentials": "Invalid email or password.",
|
|
117
125
|
"auth.errors.noMembership": "This account has no tenant access.",
|
|
118
126
|
"auth.errors.accountLocked": "Account temporarily locked.",
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
|
-
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
// Mock-Bridge für den resend-Flow: LoginScreen ruft requestEmailVerification
|
|
6
|
+
// aus ../auth-client auf, wir stubben das hier um die Server-Antwort pro
|
|
7
|
+
// Test-Case zu kontrollieren. vi.hoisted weil vi.mock() vor allen anderen
|
|
8
|
+
// Statements gehoisted wird und sonst die Variable nicht sehen kann.
|
|
9
|
+
const { requestEmailVerificationMock } = vi.hoisted(() => ({
|
|
10
|
+
requestEmailVerificationMock: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../auth-client", async () => {
|
|
13
|
+
const actual = await vi.importActual<typeof import("../auth-client")>("../auth-client");
|
|
14
|
+
return { ...actual, requestEmailVerification: requestEmailVerificationMock };
|
|
15
|
+
});
|
|
16
|
+
|
|
4
17
|
import { LoginScreen } from "../login-screen";
|
|
5
18
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
6
19
|
|
|
7
20
|
describe("LoginScreen", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
requestEmailVerificationMock.mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
8
25
|
test("renders translated title + email + password labels (de)", () => {
|
|
9
26
|
renderWithProviders(<LoginScreen />);
|
|
10
27
|
expect(screen.getByText("Anmelden")).toBeTruthy();
|
|
@@ -91,4 +108,114 @@ describe("LoginScreen", () => {
|
|
|
91
108
|
renderWithProviders(<LoginScreen />);
|
|
92
109
|
expect(screen.queryByRole("link", { name: /Passwort vergessen/i })).toBeNull();
|
|
93
110
|
});
|
|
111
|
+
|
|
112
|
+
// Resend-Flow: bei email_not_verified bietet der LoginScreen einen
|
|
113
|
+
// "Bestätigungs-Mail erneut senden"-Link im Fehler-Banner an.
|
|
114
|
+
describe("resend verification on email_not_verified", () => {
|
|
115
|
+
async function loginUntilEmailNotVerified(): Promise<void> {
|
|
116
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
117
|
+
target: { value: "demo@example.com" },
|
|
118
|
+
});
|
|
119
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), {
|
|
120
|
+
target: { value: "secret" },
|
|
121
|
+
});
|
|
122
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(screen.getByRole("alert")).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function unverifiedSession() {
|
|
129
|
+
return makeSessionApi({
|
|
130
|
+
status: "unauthenticated",
|
|
131
|
+
user: null,
|
|
132
|
+
login: vi.fn(async () => ({ ok: false, error: { reason: "email_not_verified" } })),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
test("renders resend button only after email_not_verified failure", async () => {
|
|
137
|
+
renderWithProviders(<LoginScreen />, { session: unverifiedSession() });
|
|
138
|
+
// Vor Submit gibt es keinen Resend-Trigger
|
|
139
|
+
expect(screen.queryByRole("button", { name: /erneut senden/i })).toBeNull();
|
|
140
|
+
await loginUntilEmailNotVerified();
|
|
141
|
+
expect(screen.getByRole("button", { name: "Bestätigungs-Mail erneut senden" })).toBeTruthy();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("click → calls requestEmailVerification with form email and shows success banner", async () => {
|
|
145
|
+
requestEmailVerificationMock.mockResolvedValueOnce({ ok: true });
|
|
146
|
+
renderWithProviders(<LoginScreen />, { session: unverifiedSession() });
|
|
147
|
+
await loginUntilEmailNotVerified();
|
|
148
|
+
|
|
149
|
+
fireEvent.click(screen.getByRole("button", { name: "Bestätigungs-Mail erneut senden" }));
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(requestEmailVerificationMock).toHaveBeenCalledWith("demo@example.com");
|
|
153
|
+
});
|
|
154
|
+
// Banner variant="info" setzt kein role — wir suchen per Text
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
expect(
|
|
157
|
+
screen.getByText("Wir haben dir eine neue Bestätigungs-Mail geschickt."),
|
|
158
|
+
).toBeTruthy();
|
|
159
|
+
});
|
|
160
|
+
// Fehler-Banner (role=alert) ist weg, Success-Banner ist da
|
|
161
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("rate_limited → inline hint statt success", async () => {
|
|
165
|
+
requestEmailVerificationMock.mockResolvedValueOnce({
|
|
166
|
+
ok: false,
|
|
167
|
+
error: { reason: "rate_limited" },
|
|
168
|
+
});
|
|
169
|
+
renderWithProviders(<LoginScreen />, { session: unverifiedSession() });
|
|
170
|
+
await loginUntilEmailNotVerified();
|
|
171
|
+
|
|
172
|
+
fireEvent.click(screen.getByRole("button", { name: "Bestätigungs-Mail erneut senden" }));
|
|
173
|
+
|
|
174
|
+
await waitFor(() => {
|
|
175
|
+
expect(screen.getByRole("alert").textContent).toMatch(
|
|
176
|
+
/Bitte warte kurz und versuche es erneut/,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
// Original-Fehler bleibt sichtbar
|
|
180
|
+
expect(screen.getByRole("alert").textContent).toMatch(/E-Mail-Adresse noch nicht bestätigt/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("network/unknown error → generischer inline hint", async () => {
|
|
184
|
+
requestEmailVerificationMock.mockRejectedValueOnce(new Error("offline"));
|
|
185
|
+
renderWithProviders(<LoginScreen />, { session: unverifiedSession() });
|
|
186
|
+
await loginUntilEmailNotVerified();
|
|
187
|
+
|
|
188
|
+
fireEvent.click(screen.getByRole("button", { name: "Bestätigungs-Mail erneut senden" }));
|
|
189
|
+
|
|
190
|
+
await waitFor(() => {
|
|
191
|
+
expect(screen.getByRole("alert").textContent).toMatch(
|
|
192
|
+
/Konnte nicht senden. Bitte erneut versuchen/,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("Email-Änderung nach Failure → Resend-Button verschwindet (anti-typo-Falle)", async () => {
|
|
198
|
+
renderWithProviders(<LoginScreen />, { session: unverifiedSession() });
|
|
199
|
+
await loginUntilEmailNotVerified();
|
|
200
|
+
expect(screen.getByRole("button", { name: "Bestätigungs-Mail erneut senden" })).toBeTruthy();
|
|
201
|
+
|
|
202
|
+
// User korrigiert die Email-Eingabe — Resend-Button darf nicht mehr
|
|
203
|
+
// sichtbar sein, damit kein silent-send an typo-Adresse passiert
|
|
204
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
205
|
+
target: { value: "typo@example.com" },
|
|
206
|
+
});
|
|
207
|
+
expect(screen.queryByRole("button", { name: /erneut senden/i })).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("invalid_credentials → KEIN Resend-Button (nur bei email_not_verified)", async () => {
|
|
211
|
+
const session = makeSessionApi({
|
|
212
|
+
status: "unauthenticated",
|
|
213
|
+
user: null,
|
|
214
|
+
login: vi.fn(async () => ({ ok: false, error: { reason: "invalid_credentials" } })),
|
|
215
|
+
});
|
|
216
|
+
renderWithProviders(<LoginScreen />, { session });
|
|
217
|
+
await loginUntilEmailNotVerified();
|
|
218
|
+
expect(screen.queryByRole("button", { name: /erneut senden/i })).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
94
221
|
});
|
|
@@ -11,10 +11,19 @@
|
|
|
11
11
|
|
|
12
12
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
13
13
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
14
|
-
import type
|
|
14
|
+
import { type LoginFailure, requestEmailVerification } from "./auth-client";
|
|
15
15
|
import { AuthCard, authMutedLinkClass } from "./auth-form-primitives";
|
|
16
16
|
import { useSession } from "./session";
|
|
17
17
|
|
|
18
|
+
// Resend-Status für den "Bestätigungs-Mail erneut senden"-Flow, der bei
|
|
19
|
+
// reason=email_not_verified unter dem Fehler-Banner angeboten wird.
|
|
20
|
+
type ResendStatus =
|
|
21
|
+
| { readonly kind: "idle" }
|
|
22
|
+
| { readonly kind: "sending" }
|
|
23
|
+
| { readonly kind: "success" }
|
|
24
|
+
| { readonly kind: "rateLimited" }
|
|
25
|
+
| { readonly kind: "error" };
|
|
26
|
+
|
|
18
27
|
export type LoginScreenProps = {
|
|
19
28
|
/** Overridet den `auth.login.title`-i18n-Key. Nur setzen wenn der
|
|
20
29
|
* Titel stark app-branded ist und keine Translation braucht. */
|
|
@@ -77,13 +86,22 @@ export function LoginScreen({
|
|
|
77
86
|
const [password, setPassword] = useState("");
|
|
78
87
|
const [submitting, setSubmitting] = useState(false);
|
|
79
88
|
const [error, setError] = useState<LoginFailure | null>(null);
|
|
89
|
+
const [resendStatus, setResendStatus] = useState<ResendStatus>({ kind: "idle" });
|
|
90
|
+
// Tracked, damit der Resend-Button verschwindet sobald der User die
|
|
91
|
+
// Email-Eingabe ändert — sonst würde Resend silent an die geänderte
|
|
92
|
+
// (potentiell typoed) Adresse gehen ohne User-Feedback.
|
|
93
|
+
const [failedLoginEmail, setFailedLoginEmail] = useState<string | null>(null);
|
|
80
94
|
|
|
81
95
|
const doSubmit = async (): Promise<void> => {
|
|
82
96
|
setSubmitting(true);
|
|
83
97
|
setError(null);
|
|
98
|
+
setResendStatus({ kind: "idle" });
|
|
84
99
|
const res = await session.login({ email, password });
|
|
85
100
|
setSubmitting(false);
|
|
86
|
-
if (!res.ok)
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
setError(res.error);
|
|
103
|
+
setFailedLoginEmail(email);
|
|
104
|
+
}
|
|
87
105
|
};
|
|
88
106
|
|
|
89
107
|
const onSubmit = (e?: FormEvent): void => {
|
|
@@ -91,6 +109,26 @@ export function LoginScreen({
|
|
|
91
109
|
void doSubmit();
|
|
92
110
|
};
|
|
93
111
|
|
|
112
|
+
// Resend-Bestätigungsmail bei reason=email_not_verified. requestEmail-
|
|
113
|
+
// Verification ist silent-success (200 auch wenn kein User existiert),
|
|
114
|
+
// sodass kein anti-enumeration-Branching nötig ist; 429 → rate-limit-
|
|
115
|
+
// Hint inline, sonstige Fehler → generischer Inline-Hint.
|
|
116
|
+
const onResend = async (): Promise<void> => {
|
|
117
|
+
setResendStatus({ kind: "sending" });
|
|
118
|
+
try {
|
|
119
|
+
const res = await requestEmailVerification(email);
|
|
120
|
+
if (res.ok) {
|
|
121
|
+
setResendStatus({ kind: "success" });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
setResendStatus({
|
|
125
|
+
kind: res.error.reason === "rate_limited" ? "rateLimited" : "error",
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
setResendStatus({ kind: "error" });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
94
132
|
const effectiveTitle = title ?? t("auth.login.title");
|
|
95
133
|
const effectiveSubmit = submitLabel ?? t("auth.login.submit");
|
|
96
134
|
|
|
@@ -122,14 +160,41 @@ export function LoginScreen({
|
|
|
122
160
|
autoComplete="current-password"
|
|
123
161
|
/>
|
|
124
162
|
</Field>
|
|
125
|
-
{
|
|
163
|
+
{resendStatus.kind === "success" ? (
|
|
164
|
+
<Banner variant="info">{t("auth.login.resendSuccess")}</Banner>
|
|
165
|
+
) : error !== null ? (
|
|
126
166
|
<Banner variant="error">
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
<div className="flex flex-col gap-1">
|
|
168
|
+
<span>
|
|
169
|
+
{(() => {
|
|
170
|
+
const { key, params } = reasonToKey(error);
|
|
171
|
+
return t(key, params);
|
|
172
|
+
})()}
|
|
173
|
+
</span>
|
|
174
|
+
{error.reason === "email_not_verified" &&
|
|
175
|
+
email.trim().length > 0 &&
|
|
176
|
+
email === failedLoginEmail && (
|
|
177
|
+
// kumiko-lint-ignore primitives-discipline Inline-Link im Banner (UX-Choice); Button-Primitive hat keinen link-Variant
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={() => void onResend()}
|
|
181
|
+
disabled={resendStatus.kind === "sending"}
|
|
182
|
+
className={`${authMutedLinkClass} self-start text-left disabled:opacity-50`}
|
|
183
|
+
>
|
|
184
|
+
{resendStatus.kind === "sending"
|
|
185
|
+
? t("auth.login.submitting")
|
|
186
|
+
: t("auth.login.resendVerification")}
|
|
187
|
+
</button>
|
|
188
|
+
)}
|
|
189
|
+
{resendStatus.kind === "rateLimited" && (
|
|
190
|
+
<span className="text-xs">{t("auth.login.resendRateLimited")}</span>
|
|
191
|
+
)}
|
|
192
|
+
{resendStatus.kind === "error" && (
|
|
193
|
+
<span className="text-xs">{t("auth.login.resendError")}</span>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
131
196
|
</Banner>
|
|
132
|
-
)}
|
|
197
|
+
) : null}
|
|
133
198
|
<Button type="submit" loading={submitting} disabled={submitting}>
|
|
134
199
|
{submitting ? t("auth.login.submitting") : effectiveSubmit}
|
|
135
200
|
</Button>
|