@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.241.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
- package/src/admin-shell/changes.json +6 -0
- package/src/admin-shell/feature.ts +60 -6
- package/src/admin-shell/i18n.ts +0 -7
- package/src/admin-shell/web/client-plugin.tsx +1 -11
- package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +57 -7
- package/src/audit/handlers/list.query.ts +50 -14
- package/src/audit/i18n.ts +3 -18
- package/src/audit/web/client-plugin.tsx +1 -7
- package/src/audit/web/index.ts +0 -2
- package/src/config/__tests__/config.integration.test.ts +31 -0
- package/src/config/handlers/readiness.query.ts +8 -1
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
- package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
- package/src/jobs/changes.json +7 -0
- package/src/jobs/feature.ts +101 -9
- package/src/jobs/handlers/list.query.ts +21 -4
- package/src/jobs/handlers/trigger.write.ts +16 -3
- package/src/jobs/i18n.ts +3 -25
- package/src/jobs/index.ts +1 -0
- package/src/jobs/web/client-plugin.tsx +1 -7
- package/src/jobs/web/index.ts +0 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
- package/src/tenant/handlers/list.query.ts +1 -0
- package/src/tier-engine/__tests__/drift.test.ts +1 -1
- package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
- package/src/tier-engine/changes.json +9 -1
- package/src/tier-engine/constants.ts +5 -5
- package/src/tier-engine/feature.ts +36 -10
- package/src/tier-engine/i18n.ts +19 -22
- package/src/tier-engine/web/client-plugin.tsx +5 -11
- package/src/tier-engine/web/index.ts +0 -1
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
- package/src/user-data-rights/changes.json +7 -0
- package/src/user-data-rights/constants.ts +20 -6
- package/src/user-data-rights/feature.ts +113 -14
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
- package/src/user-data-rights/web/client-plugin.tsx +19 -21
- package/src/user-data-rights/web/i18n.ts +6 -11
- package/src/user-data-rights/web/index.ts +1 -1
- package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
- package/src/user-profile/changes.json +7 -0
- package/src/user-profile/constants.ts +15 -7
- package/src/user-profile/feature.ts +96 -9
- package/src/user-profile/i18n.ts +8 -12
- package/src/user-profile/web/client-plugin.ts +22 -5
- package/src/user-profile/web/index.ts +1 -1
- package/src/user-profile/web/profile-screen.tsx +122 -265
- package/src/admin-shell/web/overview-layout.tsx +0 -69
- package/src/admin-shell/web/overview-query.ts +0 -15
- package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
- package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
- package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
- package/src/audit/web/audit-log-detail-screen.tsx +0 -169
- package/src/audit/web/audit-log-screen.tsx +0 -290
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
- package/src/jobs/web/job-run-detail-screen.tsx +0 -181
- package/src/jobs/web/job-runs-screen.tsx +0 -290
- package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
package/src/user-profile/i18n.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Default
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `auth.errors.invalidCredentials` + `user.errors.emailAlreadyExists`
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// Default bundles for the `profile` screen. userProfileClient() hangs this
|
|
3
|
+
// in the LocaleProvider as a fallback bundle — apps override individual keys
|
|
4
|
+
// via `userProfileClient({ translations })`.
|
|
5
|
+
// `auth.errors.invalidCredentials` + `user.errors.emailAlreadyExists` are
|
|
6
|
+
// duplicated here so the screen stays fully translated even without those
|
|
7
|
+
// features' own bundles.
|
|
8
8
|
|
|
9
9
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
10
10
|
|
|
11
11
|
export const defaultTranslations: TranslationsByLocale = {
|
|
12
12
|
en: {
|
|
13
|
-
"profile.title": "Profile",
|
|
14
|
-
"profile.loading": "Loading…",
|
|
13
|
+
"screen:profile.title": "Profile",
|
|
15
14
|
|
|
16
15
|
"profile.email.title": "Email address",
|
|
17
16
|
"profile.email.current": "Current email",
|
|
@@ -32,13 +31,10 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
32
31
|
"profile.danger.explainer":
|
|
33
32
|
"Your account will be permanently deleted after a grace period. Until then you can cancel the deletion at any time.",
|
|
34
33
|
"profile.danger.delete": "Delete account",
|
|
35
|
-
"profile.danger.dialogTitle": "Really delete your account?",
|
|
36
34
|
"profile.danger.dialogDescription":
|
|
37
35
|
"After the grace period your data will be permanently deleted. Until then you can cancel.",
|
|
38
|
-
"profile.danger.
|
|
39
|
-
"Deletion requested — your account will be permanently deleted on {date}.",
|
|
36
|
+
"profile.danger.gracePeriodEnd": "Deletion date",
|
|
40
37
|
"profile.danger.cancelDeletion": "Cancel deletion",
|
|
41
|
-
"profile.danger.cancelSuccess": "Deletion cancelled. Your account stays.",
|
|
42
38
|
|
|
43
39
|
"profile.errors.generic": "Something went wrong.",
|
|
44
40
|
"profile.errors.emailUnchanged": "That is already your email address.",
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionSectionComponent,
|
|
4
|
+
mergeTranslations,
|
|
5
|
+
type TranslationsByLocale,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
3
7
|
import type { ComponentType, ReactNode } from "react";
|
|
8
|
+
import {
|
|
9
|
+
CHANGE_EMAIL_SECTION_EXTENSION_NAME,
|
|
10
|
+
CHANGE_PASSWORD_SECTION_EXTENSION_NAME,
|
|
11
|
+
} from "../constants";
|
|
4
12
|
import { defaultTranslations } from "../i18n";
|
|
13
|
+
import { ChangeEmailSection, ChangePasswordSection } from "./profile-screen";
|
|
5
14
|
|
|
6
15
|
export type UserProfileClientOptions = {
|
|
7
|
-
/**
|
|
16
|
+
/** Per-key overrides on top of the default bundles (de/en). */
|
|
8
17
|
readonly translations?: TranslationsByLocale;
|
|
9
18
|
};
|
|
10
19
|
|
|
@@ -13,16 +22,24 @@ export type UserProfileClientFeature = {
|
|
|
13
22
|
readonly providers: readonly ComponentType<{ children: ReactNode }>[];
|
|
14
23
|
readonly gates: readonly ComponentType<{ children: ReactNode }>[];
|
|
15
24
|
readonly translations: TranslationsByLocale;
|
|
25
|
+
readonly extensionSectionComponents: Readonly<Record<string, ExtensionSectionComponent>>;
|
|
16
26
|
};
|
|
17
27
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
28
|
+
// Supplies the ChangeEmail/ChangePassword extension components for the
|
|
29
|
+
// declarative `profile` projectionDetail screen (fw#2312, ../feature.ts,
|
|
30
|
+
// registered there server-side) plus the default translations. Apps add it
|
|
31
|
+
// via createKumikoApp({ clientFeatures: [userProfileClient()] }) and place
|
|
32
|
+
// the screen (id "profile") via r.nav — the screen itself has no r.nav of
|
|
33
|
+
// its own, same as user-data-rights' privacy-center.
|
|
21
34
|
export function userProfileClient(options?: UserProfileClientOptions): UserProfileClientFeature {
|
|
22
35
|
return {
|
|
23
36
|
name: "user-profile",
|
|
24
37
|
providers: [],
|
|
25
38
|
gates: [],
|
|
26
39
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
40
|
+
extensionSectionComponents: {
|
|
41
|
+
[CHANGE_EMAIL_SECTION_EXTENSION_NAME]: ChangeEmailSection,
|
|
42
|
+
[CHANGE_PASSWORD_SECTION_EXTENSION_NAME]: ChangePasswordSection,
|
|
43
|
+
},
|
|
27
44
|
};
|
|
28
45
|
}
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// Change-password and change-email sections for the declarative `profile`
|
|
3
|
+
// projectionDetail screen (fw#2312, ../feature.ts). Both keep re-auth flows
|
|
4
|
+
// a declarative action can't express, so they stay React `EditExtensionSection`
|
|
5
|
+
// components; account deletion moved to declarative fields/actions on the
|
|
6
|
+
// screen itself — same split as user-data-rights' privacy-center
|
|
7
|
+
// (../../user-data-rights/web/privacy-center-screen.tsx), which this file
|
|
8
|
+
// mirrors.
|
|
8
9
|
|
|
9
10
|
import {
|
|
11
|
+
type ExtensionSectionProps,
|
|
10
12
|
useDispatcher,
|
|
11
13
|
usePrimitives,
|
|
12
|
-
useQuery,
|
|
13
14
|
useTranslation,
|
|
14
15
|
} from "@cosmicdrift/kumiko-renderer";
|
|
15
|
-
import {
|
|
16
|
+
import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
|
|
16
17
|
import { type ReactNode, useState } from "react";
|
|
17
18
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
18
19
|
import { requestEmailVerification } from "../../auth-email-password/web";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
type MeRow = {
|
|
22
|
-
readonly id: string;
|
|
23
|
-
readonly email: string;
|
|
24
|
-
readonly status?: string;
|
|
25
|
-
readonly gracePeriodEnd?: string | null;
|
|
26
|
-
};
|
|
20
|
+
import { UserProfileHandlers } from "../constants";
|
|
27
21
|
|
|
28
22
|
type SectionStatus =
|
|
29
23
|
| { kind: "idle" }
|
|
@@ -31,21 +25,13 @@ type SectionStatus =
|
|
|
31
25
|
| { kind: "success"; messageKey: string }
|
|
32
26
|
| { kind: "error"; messageKey: string };
|
|
33
27
|
|
|
34
|
-
// Dispatcher
|
|
35
|
-
//
|
|
28
|
+
// Dispatcher failures carry an i18nKey only when the handler sets one —
|
|
29
|
+
// boundary read with a generic fallback.
|
|
36
30
|
function failureKey(error: unknown): string {
|
|
37
31
|
const key = (error as { i18nKey?: unknown } | null)?.i18nKey; // @cast-boundary dispatcher-error
|
|
38
32
|
return typeof key === "string" ? key : "profile.errors.generic";
|
|
39
33
|
}
|
|
40
34
|
|
|
41
|
-
// Date-only slice of the raw ISO instant — the time/Z part is noise to the user.
|
|
42
|
-
// Pure string-slice: no Date API (no-date-api guard), universal (RN+Web). Empty → "—".
|
|
43
|
-
export function formatDeletionDate(iso: string | null | undefined): string {
|
|
44
|
-
if (!iso) return "—";
|
|
45
|
-
const tIndex = iso.indexOf("T");
|
|
46
|
-
return tIndex > 0 ? iso.slice(0, tIndex) : iso;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
35
|
function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode {
|
|
50
36
|
const t = useTranslation();
|
|
51
37
|
const { Banner } = usePrimitives();
|
|
@@ -58,7 +44,12 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
|
|
|
58
44
|
return null;
|
|
59
45
|
}
|
|
60
46
|
|
|
61
|
-
|
|
47
|
+
// Mounted by the renderer's ExtensionSectionMount (already wrapped in
|
|
48
|
+
// <Section title={...} testId="section-extension-...">) — takes no props of
|
|
49
|
+
// its own. BareFormProvider keeps <Form> from rendering a second card/title
|
|
50
|
+
// inside that Section (same pattern as AuthCard, see
|
|
51
|
+
// auth-email-password/web/auth-form-primitives.tsx).
|
|
52
|
+
export function ChangePasswordSection(): ReactNode {
|
|
62
53
|
const t = useTranslation();
|
|
63
54
|
const { Form, Field, Input, Button } = usePrimitives();
|
|
64
55
|
const dispatcher = useDispatcher();
|
|
@@ -91,71 +82,74 @@ function ChangePasswordSection(): ReactNode {
|
|
|
91
82
|
|
|
92
83
|
const submitting = status.kind === "submitting";
|
|
93
84
|
return (
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
85
|
+
<BareFormProvider>
|
|
86
|
+
<Form
|
|
87
|
+
testId="profile-password"
|
|
88
|
+
onSubmit={onSubmit}
|
|
89
|
+
actions={
|
|
90
|
+
<Button type="submit" disabled={submitting} testId="profile-password-submit">
|
|
91
|
+
{t("profile.password.submit")}
|
|
92
|
+
</Button>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<Field id="profile-old-password" label={t("profile.password.old")} required>
|
|
96
|
+
<Input
|
|
97
|
+
kind="password"
|
|
98
|
+
id="profile-old-password"
|
|
99
|
+
name="profile-old-password"
|
|
100
|
+
value={oldPassword}
|
|
101
|
+
onChange={setOldPassword}
|
|
102
|
+
disabled={submitting}
|
|
103
|
+
required
|
|
104
|
+
autoComplete="current-password"
|
|
105
|
+
/>
|
|
106
|
+
</Field>
|
|
107
|
+
<Field id="profile-new-password" label={t("profile.password.new")} required>
|
|
108
|
+
<Input
|
|
109
|
+
kind="password"
|
|
110
|
+
id="profile-new-password"
|
|
111
|
+
name="profile-new-password"
|
|
112
|
+
value={newPassword}
|
|
113
|
+
onChange={setNewPassword}
|
|
114
|
+
disabled={submitting}
|
|
115
|
+
required
|
|
116
|
+
autoComplete="new-password"
|
|
117
|
+
/>
|
|
118
|
+
</Field>
|
|
119
|
+
<Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
|
|
120
|
+
<Input
|
|
121
|
+
kind="password"
|
|
122
|
+
id="profile-confirm-password"
|
|
123
|
+
name="profile-confirm-password"
|
|
124
|
+
value={confirm}
|
|
125
|
+
onChange={setConfirm}
|
|
126
|
+
disabled={submitting}
|
|
127
|
+
required
|
|
128
|
+
autoComplete="new-password"
|
|
129
|
+
/>
|
|
130
|
+
</Field>
|
|
131
|
+
<StatusBanner status={status} />
|
|
132
|
+
</Form>
|
|
133
|
+
</BareFormProvider>
|
|
142
134
|
);
|
|
143
135
|
}
|
|
144
136
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}): ReactNode {
|
|
137
|
+
// Mounted by the renderer's ExtensionSectionMount analogous to
|
|
138
|
+
// ChangePasswordSection above. `values.email` is the host projectionDetail's
|
|
139
|
+
// current `me` row (seeded from the `user:query:user:me` query, ../feature.ts)
|
|
140
|
+
// — ExtensionSectionProps carries no refetch hook, so a successful change
|
|
141
|
+
// pushes the new address back into the host form via `patch` instead of
|
|
142
|
+
// relying on a full-screen refetch to un-stale the "current email" line.
|
|
143
|
+
export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): ReactNode {
|
|
152
144
|
const t = useTranslation();
|
|
153
|
-
const { Form, Field, Input, Button } = usePrimitives();
|
|
145
|
+
const { Form, Field, Input, Button, Text } = usePrimitives();
|
|
154
146
|
const dispatcher = useDispatcher();
|
|
155
147
|
const [newEmail, setNewEmail] = useState("");
|
|
156
148
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
157
149
|
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
158
150
|
|
|
151
|
+
const currentEmail = typeof values?.["email"] === "string" ? values["email"] : "";
|
|
152
|
+
|
|
159
153
|
const onSubmit = (): void => {
|
|
160
154
|
void (async () => {
|
|
161
155
|
setStatus({ kind: "submitting" });
|
|
@@ -167,10 +161,10 @@ function ChangeEmailSection({
|
|
|
167
161
|
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
168
162
|
return;
|
|
169
163
|
}
|
|
170
|
-
// Verification
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
164
|
+
// Verification mail to the new address. The change is already
|
|
165
|
+
// persisted → a send failure must not reverse the success, but it is
|
|
166
|
+
// NOT swallowed (otherwise the user waits for a mail that never
|
|
167
|
+
// arrives). The success message accordingly promises no send.
|
|
174
168
|
try {
|
|
175
169
|
const verification = await requestEmailVerification(newEmail);
|
|
176
170
|
if (!verification.ok) {
|
|
@@ -185,191 +179,54 @@ function ChangeEmailSection({
|
|
|
185
179
|
`[user-profile] email changed but the verification email send threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
186
180
|
);
|
|
187
181
|
}
|
|
182
|
+
patch?.({ email: newEmail });
|
|
188
183
|
setNewEmail("");
|
|
189
184
|
setCurrentPassword("");
|
|
190
185
|
setStatus({ kind: "success", messageKey: "profile.email.success" });
|
|
191
|
-
onChanged();
|
|
192
186
|
})();
|
|
193
187
|
};
|
|
194
188
|
|
|
195
189
|
const submitting = status.kind === "submitting";
|
|
196
190
|
return (
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
<
|
|
208
|
-
{t("profile.email.
|
|
209
|
-
</
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
/>
|
|
235
|
-
</
|
|
236
|
-
|
|
237
|
-
</Form>
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function DangerZoneSection({
|
|
242
|
-
me,
|
|
243
|
-
onChanged,
|
|
244
|
-
}: {
|
|
245
|
-
readonly me: MeRow;
|
|
246
|
-
readonly onChanged: () => void;
|
|
247
|
-
}): ReactNode {
|
|
248
|
-
const t = useTranslation();
|
|
249
|
-
const { Button, Banner, Dialog, Card } = usePrimitives();
|
|
250
|
-
// Card.slots.title always renders <h3> — with the page's <h1> (variant="page")
|
|
251
|
-
// as this screen's only other heading, that skips <h2> (607/4). An explicit
|
|
252
|
-
// header slot with a real <h2> keeps the levels contiguous; layout mirrors
|
|
253
|
-
// DefaultCard's own default-header markup.
|
|
254
|
-
const dangerZoneHeader = (
|
|
255
|
-
<div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-6 pb-4">
|
|
256
|
-
<h2 className="text-base font-semibold leading-none tracking-tight">
|
|
257
|
-
{t("profile.danger.title")}
|
|
258
|
-
</h2>
|
|
259
|
-
</div>
|
|
260
|
-
);
|
|
261
|
-
const dispatcher = useDispatcher();
|
|
262
|
-
const [dialogOpen, setDialogOpen] = useState(false);
|
|
263
|
-
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
264
|
-
|
|
265
|
-
const deletionRequested = me.status === "deletionRequested";
|
|
266
|
-
|
|
267
|
-
const requestDeletion = async (): Promise<void> => {
|
|
268
|
-
const res = await dispatcher.write(UserDataRightsHandlers.requestDeletion, {});
|
|
269
|
-
if (!res.isSuccess) {
|
|
270
|
-
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
setStatus({ kind: "idle" });
|
|
274
|
-
onChanged();
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
const cancelDeletion = async (): Promise<void> => {
|
|
278
|
-
const res = await dispatcher.write(UserDataRightsHandlers.cancelDeletion, {});
|
|
279
|
-
if (!res.isSuccess) {
|
|
280
|
-
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
setStatus({ kind: "success", messageKey: "profile.danger.cancelSuccess" });
|
|
284
|
-
onChanged();
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
const footer = deletionRequested ? (
|
|
288
|
-
<Button
|
|
289
|
-
variant="secondary"
|
|
290
|
-
onClick={() => void cancelDeletion()}
|
|
291
|
-
testId="profile-danger-cancel"
|
|
292
|
-
>
|
|
293
|
-
{t("profile.danger.cancelDeletion")}
|
|
294
|
-
</Button>
|
|
295
|
-
) : (
|
|
296
|
-
<Button variant="danger" onClick={() => setDialogOpen(true)} testId="profile-danger-delete">
|
|
297
|
-
{t("profile.danger.delete")}
|
|
298
|
-
</Button>
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
return (
|
|
302
|
-
<Card
|
|
303
|
-
testId="profile-danger"
|
|
304
|
-
className="border-destructive/40"
|
|
305
|
-
slots={{ header: dangerZoneHeader, footer }}
|
|
306
|
-
>
|
|
307
|
-
<div className="flex flex-col gap-4">
|
|
308
|
-
{deletionRequested ? (
|
|
309
|
-
<>
|
|
310
|
-
<Banner variant="error" testId="profile-danger-requested">
|
|
311
|
-
{t("profile.danger.requested", {
|
|
312
|
-
date: formatDeletionDate(me.gracePeriodEnd),
|
|
313
|
-
})}
|
|
314
|
-
</Banner>
|
|
315
|
-
<StatusBanner status={status} />
|
|
316
|
-
</>
|
|
317
|
-
) : (
|
|
318
|
-
<>
|
|
319
|
-
<p className="text-sm text-muted-foreground">{t("profile.danger.explainer")}</p>
|
|
320
|
-
<StatusBanner status={status} />
|
|
321
|
-
<Dialog
|
|
322
|
-
open={dialogOpen}
|
|
323
|
-
onOpenChange={setDialogOpen}
|
|
324
|
-
title={t("profile.danger.dialogTitle")}
|
|
325
|
-
description={t("profile.danger.dialogDescription")}
|
|
326
|
-
variant="danger"
|
|
327
|
-
confirmLabel={t("profile.danger.delete")}
|
|
328
|
-
onConfirm={requestDeletion}
|
|
329
|
-
testId="profile-danger-dialog"
|
|
330
|
-
/>
|
|
331
|
-
</>
|
|
332
|
-
)}
|
|
333
|
-
</div>
|
|
334
|
-
</Card>
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
export function ProfileScreen(): ReactNode {
|
|
339
|
-
const t = useTranslation();
|
|
340
|
-
const { Banner, Heading } = usePrimitives();
|
|
341
|
-
const meQuery = useQuery<MeRow | null>(UserProfileQueries.me, {});
|
|
342
|
-
|
|
343
|
-
if (meQuery.error) {
|
|
344
|
-
return (
|
|
345
|
-
<Banner padded variant="error" testId="profile-error">
|
|
346
|
-
{meQuery.error.i18nKey}
|
|
347
|
-
</Banner>
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
const me = meQuery.data;
|
|
351
|
-
if (me === null || me === undefined) {
|
|
352
|
-
return (
|
|
353
|
-
<Banner padded variant="loading" testId="profile-loading">
|
|
354
|
-
{t("profile.loading")}
|
|
355
|
-
</Banner>
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const refetch = (): void => {
|
|
360
|
-
void meQuery.refetch?.();
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
return (
|
|
364
|
-
<FormScreenShell className="flex flex-col gap-6" testId="profile-screen">
|
|
365
|
-
<Heading variant="page">{t("profile.title")}</Heading>
|
|
366
|
-
{/* The two short account forms share a row from md up; the
|
|
367
|
-
danger zone stays full width below. */}
|
|
368
|
-
<div className="grid gap-6 md:grid-cols-2">
|
|
369
|
-
<ChangeEmailSection me={me} onChanged={refetch} />
|
|
370
|
-
<ChangePasswordSection />
|
|
371
|
-
</div>
|
|
372
|
-
<DangerZoneSection me={me} onChanged={refetch} />
|
|
373
|
-
</FormScreenShell>
|
|
191
|
+
<BareFormProvider>
|
|
192
|
+
<Form
|
|
193
|
+
testId="profile-email"
|
|
194
|
+
onSubmit={onSubmit}
|
|
195
|
+
actions={
|
|
196
|
+
<Button type="submit" disabled={submitting} testId="profile-email-submit">
|
|
197
|
+
{t("profile.email.submit")}
|
|
198
|
+
</Button>
|
|
199
|
+
}
|
|
200
|
+
>
|
|
201
|
+
<Text variant="muted" testId="profile-email-current">
|
|
202
|
+
{t("profile.email.current")}: {currentEmail}
|
|
203
|
+
</Text>
|
|
204
|
+
<Field id="profile-new-email" label={t("profile.email.new")} required>
|
|
205
|
+
<Input
|
|
206
|
+
kind="email"
|
|
207
|
+
id="profile-new-email"
|
|
208
|
+
name="profile-new-email"
|
|
209
|
+
value={newEmail}
|
|
210
|
+
onChange={setNewEmail}
|
|
211
|
+
disabled={submitting}
|
|
212
|
+
required
|
|
213
|
+
autoComplete="email"
|
|
214
|
+
/>
|
|
215
|
+
</Field>
|
|
216
|
+
<Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
|
|
217
|
+
<Input
|
|
218
|
+
kind="password"
|
|
219
|
+
id="profile-email-password"
|
|
220
|
+
name="profile-email-password"
|
|
221
|
+
value={currentPassword}
|
|
222
|
+
onChange={setCurrentPassword}
|
|
223
|
+
disabled={submitting}
|
|
224
|
+
required
|
|
225
|
+
autoComplete="current-password"
|
|
226
|
+
/>
|
|
227
|
+
</Field>
|
|
228
|
+
<StatusBanner status={status} />
|
|
229
|
+
</Form>
|
|
230
|
+
</BareFormProvider>
|
|
374
231
|
);
|
|
375
232
|
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import type { ReactNode } from "react";
|
|
5
|
-
|
|
6
|
-
export type OverviewCard = {
|
|
7
|
-
readonly label: string;
|
|
8
|
-
readonly value: string;
|
|
9
|
-
readonly hint?: string;
|
|
10
|
-
/** When true and hint is set, render an attention Banner inside the card. */
|
|
11
|
-
readonly attention?: boolean;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export type OverviewState =
|
|
15
|
-
| { readonly kind: "loading" }
|
|
16
|
-
| { readonly kind: "error"; readonly message: string }
|
|
17
|
-
| { readonly kind: "ready"; readonly cards: readonly OverviewCard[] };
|
|
18
|
-
|
|
19
|
-
export function OverviewLayout({
|
|
20
|
-
testId,
|
|
21
|
-
title,
|
|
22
|
-
state,
|
|
23
|
-
loadingLabel,
|
|
24
|
-
columns = 3,
|
|
25
|
-
}: {
|
|
26
|
-
readonly testId: string;
|
|
27
|
-
readonly title: string;
|
|
28
|
-
readonly state: OverviewState;
|
|
29
|
-
readonly loadingLabel: string;
|
|
30
|
-
readonly columns?: 2 | 3;
|
|
31
|
-
}): ReactNode {
|
|
32
|
-
const { Banner, Card, Heading, Text } = usePrimitives();
|
|
33
|
-
const gridClass = columns === 2 ? "sm:grid-cols-2" : "sm:grid-cols-3";
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<FormScreenShell testId={testId} className="flex flex-col gap-6">
|
|
37
|
-
<Heading variant="page">{title}</Heading>
|
|
38
|
-
|
|
39
|
-
{state.kind === "loading" && (
|
|
40
|
-
<Text variant="small" testId={`${testId}-loading`}>
|
|
41
|
-
{loadingLabel}
|
|
42
|
-
</Text>
|
|
43
|
-
)}
|
|
44
|
-
|
|
45
|
-
{state.kind === "error" && (
|
|
46
|
-
<Banner variant="error" testId={`${testId}-error`}>
|
|
47
|
-
{state.message}
|
|
48
|
-
</Banner>
|
|
49
|
-
)}
|
|
50
|
-
|
|
51
|
-
{state.kind === "ready" && (
|
|
52
|
-
<div className={`grid grid-cols-1 gap-4 ${gridClass}`}>
|
|
53
|
-
{state.cards.map((card) => (
|
|
54
|
-
<Card key={card.label} testId={`${testId}-card`} slots={{ title: card.label }}>
|
|
55
|
-
<div className="flex flex-col gap-2" data-overview-card={card.label}>
|
|
56
|
-
<span className="text-3xl font-semibold tabular-nums">{card.value}</span>
|
|
57
|
-
{card.attention === true && card.hint !== undefined ? (
|
|
58
|
-
<Banner variant="error">{card.hint}</Banner>
|
|
59
|
-
) : (
|
|
60
|
-
card.hint !== undefined && <Text variant="small">{card.hint}</Text>
|
|
61
|
-
)}
|
|
62
|
-
</div>
|
|
63
|
-
</Card>
|
|
64
|
-
))}
|
|
65
|
-
</div>
|
|
66
|
-
)}
|
|
67
|
-
</FormScreenShell>
|
|
68
|
-
);
|
|
69
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
import type { Dispatcher, QueryResult } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { isOverviewQueryAllowed, type OverviewWorkspaceKind } from "../overview-allowlist";
|
|
4
|
-
|
|
5
|
-
export async function overviewQuery<T>(
|
|
6
|
-
kind: OverviewWorkspaceKind,
|
|
7
|
-
dispatcher: Dispatcher,
|
|
8
|
-
queryName: string,
|
|
9
|
-
payload: Record<string, unknown>,
|
|
10
|
-
): Promise<QueryResult<T>> {
|
|
11
|
-
if (!isOverviewQueryAllowed(kind, queryName)) {
|
|
12
|
-
throw new Error(`admin-shell:overview query not allowlisted: ${queryName}`);
|
|
13
|
-
}
|
|
14
|
-
return dispatcher.query<T>(queryName, payload);
|
|
15
|
-
}
|