create-m5kdev 0.39.0 → 0.40.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-m5kdev",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "license": "GPL-3.0-only",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -636,28 +636,26 @@ test("session management endpoints list and revoke sessions", async ({ page, req
636
636
  await expectAuthOk(revokeResponse);
637
637
  });
638
638
 
639
- test("delete account request stores a verification email", async ({ page, request }) => {
640
- const email = `expo.delete.${Date.now()}@auth-e2e.local`;
639
+ test("Close User refuses login until Admin Restore", async ({ page, request }) => {
640
+ const email = `expo.close.${Date.now()}@auth-e2e.local`;
641
641
  const password = "password1234";
642
642
 
643
643
  await createVerifiedExpoAccount(page, request, email, password);
644
644
  await expoLoginNative(page, email, password);
645
645
 
646
- const deleteResponse = await authFetch(page, profile, "/api/auth/delete-user", {
647
- method: "POST",
648
- body: {
649
- callbackURL: `${profiles[profile].webUrl}/login`,
650
- },
651
- });
652
- await expectAuthOk(deleteResponse);
646
+ const userTrpc = await createExpoTrpcClient(page, profile);
647
+ await userTrpc.auth.closeUser.mutate({ email });
653
648
 
654
- const deletionEmail = await latestEmail(request, profile, {
655
- to: email,
656
- templateId: "account-deletion",
657
- });
658
- expect(deletionEmail.subject).toContain("Delete your account");
659
- expect(deletionEmail.html).toContain("Confirm account deletion");
660
- expect(emailUrl(deletionEmail)).toContain("/api/auth/delete-user/callback");
649
+ await expectExpoLoginRejectedNative(page, email, password);
650
+
651
+ await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
652
+ const adminTrpc = await createExpoTrpcClient(page, profile);
653
+ const users = await adminTrpc.auth.searchAdminUsers.query({ q: email, limit: 10 });
654
+ const user = users.rows.find((row) => row.email === email);
655
+ expect(user).toBeTruthy();
656
+ await adminTrpc.auth.restoreUser.mutate({ id: user?.id ?? "" });
657
+
658
+ await expoLoginNative(page, email, password);
661
659
  });
662
660
 
663
661
  test("admin can create and switch to a child organization from an enterprise org", async ({
@@ -32,4 +32,8 @@ test("upload a file and see it inventoried", async ({ page, request }) => {
32
32
  await expect(page.getByTestId("file-row").first()).toContainText("hello.png", {
33
33
  timeout: 15_000,
34
34
  });
35
+ await expect(page.getByTestId("file-download")).toHaveAttribute(
36
+ "href",
37
+ /\/files\/[0-9a-f-]{36}$/i
38
+ );
35
39
  });
@@ -781,31 +781,53 @@ test("session management endpoints list and revoke sessions", async ({ page, req
781
781
  ).toBe(true);
782
782
  });
783
783
 
784
- test("delete account request stores a verification email", async ({ page, request }) => {
785
- const email = `delete.${Date.now()}@auth-e2e.local`;
784
+ test("Close account from preferences refuses login until Admin Restore", async ({
785
+ page,
786
+ request,
787
+ }) => {
788
+ const email = `close.${Date.now()}@auth-e2e.local`;
786
789
  const password = "password1234";
787
790
 
788
791
  await createVerifiedAccount(page, request, email, password);
789
792
  await login(page, email, password);
790
793
 
791
- const deleteResponse = await authFetch(page, profile, "/api/auth/delete-user", {
792
- method: "POST",
793
- body: {
794
- callbackURL: `${profiles.standard.webUrl}/login`,
795
- },
796
- });
794
+ await page.goto("/user/preferences");
795
+ await page.getByPlaceholder(email).fill(email);
796
+ const closeResponsePromise = page.waitForResponse(
797
+ (response) =>
798
+ response.request().method() === "POST" && response.url().includes("closeUser")
799
+ );
800
+ await page.getByRole("button", { name: /close account/i }).click();
801
+ const closeResponse = await closeResponsePromise;
797
802
  expect(
798
- deleteResponse.ok,
799
- `${deleteResponse.status} ${deleteResponse.statusText}: ${deleteResponse.text}`
803
+ closeResponse.ok(),
804
+ `${closeResponse.status()} ${closeResponse.statusText()}: ${await closeResponse.text()}`
800
805
  ).toBe(true);
806
+ await expect(page).toHaveURL(/\/login/);
801
807
 
802
- const deletionEmail = await latestEmail(request, profile, {
803
- to: email,
804
- templateId: "account-deletion",
805
- });
806
- expect(deletionEmail.subject).toContain("Delete your account");
807
- expect(deletionEmail.html).toContain("Confirm account deletion");
808
- expect(emailUrl(deletionEmail)).toContain("/api/auth/delete-user/callback");
808
+ await expectLoginRejected(page, email, password);
809
+
810
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
811
+ await page.goto("/admin/users");
812
+ await page.locator('input[name="search"]').fill(email);
813
+ const row = page.getByRole("row").filter({ hasText: email });
814
+ await expect(row).toBeVisible();
815
+ await expect(row.getByText(/^closed$/i)).toBeVisible();
816
+ await row.getByLabel("User actions").click();
817
+ const restoreResponsePromise = page.waitForResponse(
818
+ (response) =>
819
+ response.request().method() === "POST" && response.url().includes("restoreUser")
820
+ );
821
+ await page.getByRole("menuitem", { name: /^restore$/i }).click();
822
+ const restoreResponse = await restoreResponsePromise;
823
+ expect(
824
+ restoreResponse.ok(),
825
+ `${restoreResponse.status()} ${restoreResponse.statusText()}: ${await restoreResponse.text()}`
826
+ ).toBe(true);
827
+ await expect(row.getByText(/^active$/i)).toBeVisible();
828
+
829
+ await logout(page);
830
+ await login(page, email, password);
809
831
  });
810
832
 
811
833
  test("admin can create and switch to a child organization from an enterprise org", async ({
@@ -79,6 +79,30 @@ export function FilesRoute() {
79
79
  </Chip>
80
80
  ) : null}
81
81
  </Card.Header>
82
+ <Card.Content className="flex flex-wrap items-center gap-3">
83
+ <img
84
+ src={row.downloadUrl}
85
+ alt={row.originalName}
86
+ className="h-16 w-16 rounded-md object-cover"
87
+ data-testid="file-preview"
88
+ />
89
+ <a
90
+ href={row.downloadUrl}
91
+ data-testid="file-download"
92
+ className="text-sm font-medium underline"
93
+ >
94
+ {t("files.row.open")}
95
+ </a>
96
+ <Button
97
+ size="sm"
98
+ variant="ghost"
99
+ data-testid="file-delete"
100
+ isDisabled={files.isDeleting}
101
+ onPress={() => files.remove(row.id)}
102
+ >
103
+ {t("files.row.delete")}
104
+ </Button>
105
+ </Card.Content>
82
106
  </Card>
83
107
  ))}
84
108
  </div>
@@ -1,4 +1,6 @@
1
- import { useFileUpload } from "@m5kdev/frontend/modules/file/hooks/useUpload";
1
+ import { useAppConfig } from "@m5kdev/frontend/modules/app/hooks/useAppConfig";
2
+ import { fileCookieDownloadUrl } from "@m5kdev/frontend/modules/file/hooks/useS3DownloadUrl";
3
+ import { useS3Upload } from "@m5kdev/frontend/modules/file/hooks/useS3Upload";
2
4
  import type { AppRouter } from "{{PACKAGE_SCOPE}}/server/types";
3
5
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
6
  import type { inferRouterOutputs } from "@trpc/server";
@@ -9,25 +11,32 @@ import { useTRPC } from "@/utils/trpc";
9
11
  type FileListOutput = inferRouterOutputs<AppRouter>["file"]["list"];
10
12
  type FileRow = FileListOutput["rows"][number];
11
13
 
14
+ interface FilesRouteRow extends FileRow {
15
+ readonly downloadUrl: string;
16
+ }
17
+
12
18
  interface FilesRouteState {
13
- readonly rows: FileRow[];
19
+ readonly rows: FilesRouteRow[];
14
20
  readonly isLoading: boolean;
15
21
  readonly isFetching: boolean;
16
22
  readonly isUploading: boolean;
23
+ readonly isDeleting: boolean;
17
24
  readonly upload: (file: File) => void;
25
+ readonly remove: (fileId: string) => void;
18
26
  }
19
27
 
20
28
  export function useFilesRoute(): FilesRouteState {
21
29
  const { t } = useTranslation("{{APP_SLUG}}");
30
+ const { serverUrl } = useAppConfig();
22
31
  const trpc = useTRPC();
23
32
  const queryClient = useQueryClient();
24
- const fileUpload = useFileUpload();
33
+ const fileUpload = useS3Upload({ scope: "organization" });
25
34
 
26
35
  const listQuery = useQuery(trpc.file.list.queryOptions({}));
27
36
 
28
37
  const uploadMutation = useMutation({
29
38
  mutationFn: async (file: File) => {
30
- await fileUpload.upload("image", file);
39
+ await fileUpload.upload(file);
31
40
  },
32
41
  onSuccess: async () => {
33
42
  toast.success(t("files.toast.uploaded"));
@@ -38,13 +47,32 @@ export function useFilesRoute(): FilesRouteState {
38
47
  },
39
48
  });
40
49
 
50
+ const deleteMutation = useMutation(
51
+ trpc.file.delete.mutationOptions({
52
+ onSuccess: async () => {
53
+ toast.success(t("files.toast.deleted"));
54
+ await queryClient.invalidateQueries(trpc.file.list.queryFilter());
55
+ },
56
+ onError: (error) => {
57
+ toast.error(error instanceof Error ? error.message : t("files.toast.deleteFailed"));
58
+ },
59
+ })
60
+ );
61
+
41
62
  return {
42
- rows: listQuery.data?.rows ?? [],
63
+ rows: (listQuery.data?.rows ?? []).map((row) => ({
64
+ ...row,
65
+ downloadUrl: fileCookieDownloadUrl(serverUrl, row.id),
66
+ })),
43
67
  isLoading: listQuery.isLoading,
44
68
  isFetching: listQuery.isFetching,
45
69
  isUploading: uploadMutation.isPending,
70
+ isDeleting: deleteMutation.isPending,
46
71
  upload: (file: File) => {
47
72
  void uploadMutation.mutateAsync(file).catch(() => undefined);
48
73
  },
74
+ remove: (fileId: string) => {
75
+ void deleteMutation.mutateAsync({ fileId }).catch(() => undefined);
76
+ },
49
77
  };
50
78
  }
@@ -168,7 +168,7 @@
168
168
  "hero": {
169
169
  "eyebrow": "Files",
170
170
  "title": "Upload an image and see it inventoried.",
171
- "body": "Local uploads land on disk and in the files table. Organization-scoped rows stamp the active member.",
171
+ "body": "The browser PUTs to S3 after initiate, then finalize inventories the File. Open uses /files/:id.",
172
172
  "upload": "Upload image",
173
173
  "syncing": "Refreshing",
174
174
  "synced": "Live against the files table"
@@ -178,11 +178,15 @@
178
178
  "body": "Upload an image to see it listed here."
179
179
  },
180
180
  "row": {
181
- "organization": "Organization"
181
+ "organization": "Organization",
182
+ "open": "Open",
183
+ "delete": "Delete"
182
184
  },
183
185
  "toast": {
184
186
  "uploaded": "File uploaded.",
185
- "failed": "Upload failed."
187
+ "failed": "Upload failed.",
188
+ "deleted": "File deleted.",
189
+ "deleteFailed": "Delete failed."
186
190
  }
187
191
  },
188
192
  "notifications": {
@@ -7,18 +7,18 @@ catalogs:
7
7
  "@heroui/react": 3.0.3
8
8
  "@heroui/styles": 3.0.3
9
9
  "@libsql/client": 0.17.4
10
- "@m5kdev/backend": 0.39.0
11
- "@m5kdev/commons": 0.39.0
12
- "@m5kdev/config": 0.39.0
13
- "@m5kdev/email": 0.39.0
14
- "@m5kdev/expo": 0.39.0
15
- "@m5kdev/frontend": 0.39.0
16
- "@m5kdev/module-clay": 0.39.0
17
- "@m5kdev/module-docx": 0.39.0
18
- "@m5kdev/module-pdf": 0.39.0
19
- "@m5kdev/module-social": 0.39.0
20
- "@m5kdev/module-video": 0.39.0
21
- "@m5kdev/web-ui": 0.39.0
10
+ "@m5kdev/backend": 0.40.1
11
+ "@m5kdev/commons": 0.40.1
12
+ "@m5kdev/config": 0.40.1
13
+ "@m5kdev/email": 0.40.1
14
+ "@m5kdev/expo": 0.40.1
15
+ "@m5kdev/frontend": 0.40.1
16
+ "@m5kdev/module-clay": 0.40.1
17
+ "@m5kdev/module-docx": 0.40.1
18
+ "@m5kdev/module-pdf": 0.40.1
19
+ "@m5kdev/module-social": 0.40.1
20
+ "@m5kdev/module-video": 0.40.1
21
+ "@m5kdev/web-ui": 0.40.1
22
22
  "@mastra/core": 1.51.0
23
23
  "@openrouter/ai-sdk-provider": 3.0.0
24
24
  "@opentelemetry/api": 1.9.0