Skip to content

Commit 6d4e8b6

Browse files
authored
fix(files): R2 empty-list + passkey list method (#93)
* fix(files): handle R2 NoSuchKey on empty listing; fix passkey list method R2 returns NoSuchKey (404) for empty buckets/prefixes where S3 returns an empty Contents array. listUserObjects now catches and returns [] so /api/files succeeds for users with no files. PasskeySection was calling authClient.listPasskeys (undefined) instead of authClient.passkey.listUserPasskeys — wrong path, 404, undefined return that probably crashed downstream. Same fix for deletePasskey. * fix(ui): wrap DropdownMenuLabel + items in DropdownMenuGroup Base UI (shadcn v3) requires MenuGroupRootContext — DropdownMenuLabel + child items must live inside <Menu.Group>. Wrap all three callsites: - apps/web/src/components/app/notification-bell.tsx (header) - apps/web/src/components/app/app-sidebar.tsx (account menu) - apps/admin/src/app/[locale]/users/_user-actions-menu.tsx
1 parent c51e863 commit 6d4e8b6

8 files changed

Lines changed: 111 additions & 96 deletions

File tree

apps/admin/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts";
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/admin/src/app/[locale]/users/_user-actions-menu.tsx

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
DropdownMenu,
1414
DropdownMenuContent,
15+
DropdownMenuGroup,
1516
DropdownMenuItem,
1617
DropdownMenuLabel,
1718
DropdownMenuSeparator,
@@ -114,44 +115,49 @@ export function UserActionsMenu({ row, currentUserId }: Props) {
114115
}
115116
/>
116117
<DropdownMenuContent align="end" className="w-56">
117-
<DropdownMenuLabel className="truncate">
118-
{row.name ?? row.email}
119-
</DropdownMenuLabel>
120-
<DropdownMenuSeparator />
121-
{isAdmin ? (
122-
<DropdownMenuItem onClick={onDemote} disabled={pending || isSelf}>
123-
<ShieldOff className="mr-2 h-4 w-4" />
124-
Demote to user
125-
</DropdownMenuItem>
126-
) : (
127-
<DropdownMenuItem onClick={onPromote} disabled={pending}>
128-
<ShieldUser className="mr-2 h-4 w-4" />
129-
Promote to admin
130-
</DropdownMenuItem>
131-
)}
132-
<DropdownMenuItem
133-
onClick={onImpersonate}
134-
disabled={pending || isSelf}
135-
>
136-
<UserCog className="mr-2 h-4 w-4" />
137-
Impersonate
138-
</DropdownMenuItem>
139-
<DropdownMenuSeparator />
140-
{row.banned ? (
141-
<DropdownMenuItem onClick={onUnban} disabled={pending}>
142-
<CheckCircle2 className="mr-2 h-4 w-4" />
143-
Unban
144-
</DropdownMenuItem>
145-
) : (
118+
<DropdownMenuGroup>
119+
<DropdownMenuLabel className="truncate">
120+
{row.name ?? row.email}
121+
</DropdownMenuLabel>
122+
<DropdownMenuSeparator />
123+
{isAdmin ? (
124+
<DropdownMenuItem
125+
onClick={onDemote}
126+
disabled={pending || isSelf}
127+
>
128+
<ShieldOff className="mr-2 h-4 w-4" />
129+
Demote to user
130+
</DropdownMenuItem>
131+
) : (
132+
<DropdownMenuItem onClick={onPromote} disabled={pending}>
133+
<ShieldUser className="mr-2 h-4 w-4" />
134+
Promote to admin
135+
</DropdownMenuItem>
136+
)}
146137
<DropdownMenuItem
147-
onClick={() => setBanOpen(true)}
138+
onClick={onImpersonate}
148139
disabled={pending || isSelf}
149-
className="text-destructive focus:text-destructive"
150140
>
151-
<Ban className="mr-2 h-4 w-4" />
152-
Ban…
141+
<UserCog className="mr-2 h-4 w-4" />
142+
Impersonate
153143
</DropdownMenuItem>
154-
)}
144+
<DropdownMenuSeparator />
145+
{row.banned ? (
146+
<DropdownMenuItem onClick={onUnban} disabled={pending}>
147+
<CheckCircle2 className="mr-2 h-4 w-4" />
148+
Unban
149+
</DropdownMenuItem>
150+
) : (
151+
<DropdownMenuItem
152+
onClick={() => setBanOpen(true)}
153+
disabled={pending || isSelf}
154+
className="text-destructive focus:text-destructive"
155+
>
156+
<Ban className="mr-2 h-4 w-4" />
157+
Ban…
158+
</DropdownMenuItem>
159+
)}
160+
</DropdownMenuGroup>
155161
</DropdownMenuContent>
156162
</DropdownMenu>
157163

apps/marketing/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts";
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/web/src/components/app/app-sidebar.tsx

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Avatar, AvatarFallback } from "@starter-saas/ui/components/avatar";
44
import {
55
DropdownMenu,
66
DropdownMenuContent,
7+
DropdownMenuGroup,
78
DropdownMenuItem,
89
DropdownMenuLabel,
910
DropdownMenuSeparator,
@@ -146,30 +147,32 @@ export function AppSidebar() {
146147
</div>
147148
</DropdownMenuTrigger>
148149
<DropdownMenuContent side="top" align="end" className="w-56">
149-
<DropdownMenuLabel>My account</DropdownMenuLabel>
150-
<DropdownMenuSeparator />
151-
<DropdownMenuItem
152-
onClick={() => router.push("/dashboard/settings")}
153-
>
154-
<Settings className="mr-2 h-4 w-4" />
155-
Settings
156-
</DropdownMenuItem>
157-
<DropdownMenuItem
158-
onClick={() => router.push("/dashboard/billing")}
159-
>
160-
<CreditCard className="mr-2 h-4 w-4" />
161-
Billing
162-
</DropdownMenuItem>
163-
<DropdownMenuSeparator />
164-
<DropdownMenuItem
165-
onClick={async () => {
166-
await authClient.signOut();
167-
window.location.href = "/sign-in";
168-
}}
169-
>
170-
<LogOut className="mr-2 h-4 w-4" />
171-
Sign out
172-
</DropdownMenuItem>
150+
<DropdownMenuGroup>
151+
<DropdownMenuLabel>My account</DropdownMenuLabel>
152+
<DropdownMenuSeparator />
153+
<DropdownMenuItem
154+
onClick={() => router.push("/dashboard/settings")}
155+
>
156+
<Settings className="mr-2 h-4 w-4" />
157+
Settings
158+
</DropdownMenuItem>
159+
<DropdownMenuItem
160+
onClick={() => router.push("/dashboard/billing")}
161+
>
162+
<CreditCard className="mr-2 h-4 w-4" />
163+
Billing
164+
</DropdownMenuItem>
165+
<DropdownMenuSeparator />
166+
<DropdownMenuItem
167+
onClick={async () => {
168+
await authClient.signOut();
169+
window.location.href = "/sign-in";
170+
}}
171+
>
172+
<LogOut className="mr-2 h-4 w-4" />
173+
Sign out
174+
</DropdownMenuItem>
175+
</DropdownMenuGroup>
173176
</DropdownMenuContent>
174177
</DropdownMenu>
175178
</SidebarMenuItem>

apps/web/src/components/app/notification-bell.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from "@starter-saas/ui/components/button";
55
import {
66
DropdownMenu,
77
DropdownMenuContent,
8+
DropdownMenuGroup,
89
DropdownMenuLabel,
910
DropdownMenuSeparator,
1011
DropdownMenuTrigger,
@@ -102,21 +103,23 @@ export function NotificationBell() {
102103
}
103104
/>
104105
<DropdownMenuContent align="end" className="w-80">
105-
<div className="flex items-center justify-between gap-2 px-1">
106-
<DropdownMenuLabel className="font-semibold">
107-
Notifications
108-
</DropdownMenuLabel>
109-
{unread > 0 ? (
110-
<Button
111-
variant="ghost"
112-
size="sm"
113-
className="h-7 gap-1 text-xs"
114-
onClick={markAllRead}
115-
>
116-
<CheckCheck className="h-3 w-3" /> Mark all read
117-
</Button>
118-
) : null}
119-
</div>
106+
<DropdownMenuGroup>
107+
<div className="flex items-center justify-between gap-2 px-1">
108+
<DropdownMenuLabel className="font-semibold">
109+
Notifications
110+
</DropdownMenuLabel>
111+
{unread > 0 ? (
112+
<Button
113+
variant="ghost"
114+
size="sm"
115+
className="h-7 gap-1 text-xs"
116+
onClick={markAllRead}
117+
>
118+
<CheckCheck className="h-3 w-3" /> Mark all read
119+
</Button>
120+
) : null}
121+
</div>
122+
</DropdownMenuGroup>
120123
<DropdownMenuSeparator />
121124

122125
{rows === null ? (

apps/web/src/components/app/passkey-section.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,10 @@ export function PasskeySection() {
2828

2929
const refresh = async () => {
3030
try {
31-
// Better Auth's passkey plugin exposes listUserPasskeys on the client.
32-
// The shape isn't strongly typed for arbitrary plugins so we coerce.
33-
const res = await (
34-
authClient as unknown as {
35-
listPasskeys: () => Promise<{ data?: PasskeyRow[] | null }>;
36-
}
37-
).listPasskeys();
38-
setPasskeys(res?.data ?? []);
31+
// Better Auth's passkey plugin exposes listUserPasskeys on the
32+
// `passkey` namespace of the client.
33+
const res = await authClient.passkey.listUserPasskeys();
34+
setPasskeys((res?.data ?? []) as PasskeyRow[]);
3935
} catch {
4036
setPasskeys([]);
4137
}
@@ -71,11 +67,7 @@ export function PasskeySection() {
7167

7268
const remove = async (passkeyId: string) => {
7369
try {
74-
await (
75-
authClient as unknown as {
76-
deletePasskey: (args: { id: string }) => Promise<unknown>;
77-
}
78-
).deletePasskey({ id: passkeyId });
70+
await authClient.passkey.deletePasskey({ id: passkeyId });
7971
toast.success("Passkey removed");
8072
await refresh();
8173
} catch (err) {

packages/email/src/lib/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export const resend = new Proxy({} as Resend, {
2121
get(_target, prop) {
2222
const r = getResend();
2323
const value = (r as unknown as Record<string | symbol, unknown>)[prop];
24-
return typeof value === "function" ? (value as () => unknown).bind(r) : value;
24+
return typeof value === "function"
25+
? (value as () => unknown).bind(r)
26+
: value;
2527
},
2628
});
2729

packages/storage/src/upload.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,22 @@ export async function listUserObjects(
185185
opts: { limit?: number } = {},
186186
): Promise<UserObject[]> {
187187
const prefix = `${userPrefix(ownerId)}/`;
188-
const result = await r2.send(
189-
new ListObjectsV2Command({
190-
Bucket: R2_BUCKET,
191-
Prefix: prefix,
192-
MaxKeys: opts.limit ?? 200,
193-
}),
194-
);
188+
const result = await r2
189+
.send(
190+
new ListObjectsV2Command({
191+
Bucket: R2_BUCKET,
192+
Prefix: prefix,
193+
MaxKeys: opts.limit ?? 200,
194+
}),
195+
)
196+
.catch((err: { Code?: string } | undefined) => {
197+
// Cloudflare R2 returns NoSuchKey (404) for empty buckets/prefixes
198+
// where S3 returns an empty Contents array — treat as "no files".
199+
if (err?.Code === "NoSuchKey") {
200+
return { Contents: [] };
201+
}
202+
throw err;
203+
});
195204
const contents = result.Contents ?? [];
196205
return contents
197206
.map((o) => ({

0 commit comments

Comments
 (0)