@flaredev/cli 0.1.2 → 0.3.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 (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -0,0 +1,96 @@
1
+ import { forwardRef } from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ /*
5
+ * The auth screens' controls. They read the theme tokens (--brand, --button-radius,
6
+ * --radius, --font-theme), so the same markup takes each theme's shape and colour.
7
+ */
8
+
9
+ export const AuthInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(function AuthInput({ className, ...props }, ref) {
10
+ return (
11
+ <input
12
+ ref={ref}
13
+ className={cn(
14
+ "h-11 w-full rounded-[calc(var(--radius)-2px)] border border-border bg-surface px-3.5 text-[15px] text-foreground outline-none transition-[border-color,box-shadow]",
15
+ "placeholder:text-foreground-muted/80 focus-visible:border-brand focus-visible:ring-[3px] focus-visible:ring-brand/20",
16
+ "aria-invalid:border-danger aria-invalid:ring-danger/15 disabled:opacity-60",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ );
22
+ });
23
+
24
+ export function AuthLabel({ className, ...props }: React.ComponentProps<"label">) {
25
+ return <label className={cn("text-sm font-medium text-foreground", className)} {...props} />;
26
+ }
27
+
28
+ export function PrimaryButton({ className, pending, children, ...props }: React.ComponentProps<"button"> & { pending?: boolean }) {
29
+ return (
30
+ <button
31
+ className={cn(
32
+ "relative inline-flex h-11 w-full items-center justify-center gap-2 rounded-[var(--button-radius)] bg-brand px-4 text-[15px] font-medium text-brand-foreground",
33
+ "[background-image:var(--brand-gradient,none)] transition-[filter,opacity] hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-60",
34
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
35
+ className,
36
+ )}
37
+ disabled={pending || props.disabled}
38
+ {...props}
39
+ >
40
+ {pending && <span className="size-4 animate-spin rounded-full border-2 border-current border-r-transparent" aria-hidden="true" />}
41
+ {children}
42
+ </button>
43
+ );
44
+ }
45
+
46
+ export function SecondaryButton({ className, ...props }: React.ComponentProps<"button">) {
47
+ return (
48
+ <button
49
+ className={cn(
50
+ "inline-flex h-11 w-full items-center justify-center gap-2.5 rounded-[var(--button-radius)] border border-border bg-surface px-4 text-[15px] font-medium text-foreground",
51
+ "transition-colors hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-60",
52
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
53
+ className,
54
+ )}
55
+ type="button"
56
+ {...props}
57
+ />
58
+ );
59
+ }
60
+
61
+ export function TextButton({ className, ...props }: React.ComponentProps<"button">) {
62
+ return (
63
+ <button
64
+ type="button"
65
+ className={cn("text-sm font-medium text-link underline-offset-4 hover:underline focus-visible:underline focus-visible:outline-none disabled:opacity-60", className)}
66
+ {...props}
67
+ />
68
+ );
69
+ }
70
+
71
+ export function Divider({ children = "or" }: { children?: React.ReactNode }) {
72
+ return (
73
+ <div className="flex items-center gap-3 text-xs text-foreground-muted uppercase" role="separator">
74
+ <span className="h-px flex-1 bg-border" />
75
+ {children}
76
+ <span className="h-px flex-1 bg-border" />
77
+ </div>
78
+ );
79
+ }
80
+
81
+ export function FormMessage({ tone = "error", children }: { tone?: "error" | "info" | "success"; children: React.ReactNode }) {
82
+ if (!children) return null;
83
+ return (
84
+ <p
85
+ role={tone === "error" ? "alert" : "status"}
86
+ className={cn(
87
+ "rounded-[calc(var(--radius)-2px)] px-3 py-2.5 text-sm",
88
+ tone === "error" && "bg-danger/10 text-danger",
89
+ tone === "info" && "bg-surface-muted text-foreground",
90
+ tone === "success" && "bg-success/10 text-success",
91
+ )}
92
+ >
93
+ {children}
94
+ </p>
95
+ );
96
+ }
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ /**
6
+ * "Send a new code", with a countdown so it can't be hammered.
7
+ *
8
+ * Asking again doesn't invalidate the code already sitting in the inbox — people press
9
+ * resend, then find the first email. The server keeps both valid until they expire.
10
+ */
11
+ export function useResend(send: () => Promise<unknown>, seconds = 30) {
12
+ const [remaining, setRemaining] = useState(0);
13
+ const [sending, setSending] = useState(false);
14
+ const timer = useRef<ReturnType<typeof setInterval>>(undefined);
15
+
16
+ useEffect(() => () => clearInterval(timer.current), []);
17
+
18
+ const resend = useCallback(async () => {
19
+ if (remaining > 0 || sending) return;
20
+ setSending(true);
21
+ try {
22
+ await send();
23
+ } finally {
24
+ setSending(false);
25
+ }
26
+ setRemaining(seconds);
27
+ clearInterval(timer.current);
28
+ timer.current = setInterval(() => {
29
+ setRemaining((value) => {
30
+ if (value <= 1) clearInterval(timer.current);
31
+ return Math.max(0, value - 1);
32
+ });
33
+ }, 1000);
34
+ }, [remaining, sending, send, seconds]);
35
+
36
+ return {
37
+ resend,
38
+ sending,
39
+ remaining,
40
+ /** "Send a new code" → "Send a new code in 24s" */
41
+ label: remaining > 0 ? `Send a new code in ${remaining}s` : "Send a new code",
42
+ disabled: remaining > 0 || sending,
43
+ };
44
+ }
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CheckCircle2Icon, MailIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { OtpInput } from "./otp-input";
7
+ import { authErrorMessage } from "./sign-in-flow";
8
+ import { useResend } from "./use-resend";
9
+ import { AuthInput, AuthLabel, Divider, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "./ui";
10
+
11
+ interface Props {
12
+ /** The address to verify; empty when they arrived without one. */
13
+ initialEmail: string;
14
+ /** Whether emailed codes are switched on, as well as the link. */
15
+ codes: boolean;
16
+ next: string;
17
+ }
18
+
19
+ /**
20
+ * Verify an email address, by the code in the message or by the link in it.
21
+ *
22
+ * People land here from the "check your email" step, from the account page, or by
23
+ * opening a link whose token has expired — so the page has to work without one.
24
+ */
25
+ export function VerifyEmailForm({ initialEmail, codes, next }: Props) {
26
+ const [email, setEmail] = useState(initialEmail);
27
+ const [code, setCode] = useState("");
28
+ const [pending, setPending] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [sent, setSent] = useState(false);
31
+ const [done, setDone] = useState(false);
32
+
33
+ const send = async () => {
34
+ setError(null);
35
+ if (!email) return setError("Enter the email address you signed up with.");
36
+ const { error } = codes
37
+ ? await authClient.emailOtp.sendVerificationOtp({ email, type: "email-verification" })
38
+ : await authClient.sendVerificationEmail({ email, callbackURL: next });
39
+ if (error) return setError(authErrorMessage(error));
40
+ setSent(true);
41
+ };
42
+
43
+ const resend = useResend(send);
44
+
45
+ const verify = async (value = code) => {
46
+ if (value.length !== 6) return;
47
+ setPending(true);
48
+ setError(null);
49
+ const { error } = await authClient.emailOtp.verifyEmail({ email, otp: value });
50
+ setPending(false);
51
+ if (error) return setError(authErrorMessage(error));
52
+ setDone(true);
53
+ window.location.href = next;
54
+ };
55
+
56
+ if (done) {
57
+ return (
58
+ <div className="flex flex-col items-center gap-3 text-center">
59
+ <span className="grid size-12 place-items-center rounded-full bg-success/15 text-success">
60
+ <CheckCircle2Icon className="size-6" />
61
+ </span>
62
+ <p className="text-base font-medium">Email verified</p>
63
+ <p className="text-sm text-foreground-muted">Taking you to your account…</p>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ // Nothing sent yet: ask where to send it.
69
+ if (!sent) {
70
+ return (
71
+ <form
72
+ className="flex flex-col gap-4"
73
+ onSubmit={async (event) => {
74
+ event.preventDefault();
75
+ setPending(true);
76
+ await send();
77
+ setPending(false);
78
+ }}
79
+ >
80
+ <p className="text-sm text-foreground-muted">
81
+ {codes ? "We'll email you a 6-digit code to confirm this address." : "We'll email you a link to confirm this address."}
82
+ </p>
83
+ <div className="flex flex-col gap-2">
84
+ <AuthLabel htmlFor="email">Email</AuthLabel>
85
+ <AuthInput
86
+ id="email"
87
+ type="email"
88
+ autoComplete="email"
89
+ value={email}
90
+ onChange={(event) => setEmail(event.target.value)}
91
+ required
92
+ autoFocus={!initialEmail}
93
+ />
94
+ </div>
95
+ <FormMessage>{error}</FormMessage>
96
+ <PrimaryButton type="submit" pending={pending}>
97
+ {codes ? "Send me a code" : "Send me a link"}
98
+ </PrimaryButton>
99
+ </form>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <div className="flex flex-col gap-4">
105
+ <div className="flex flex-col items-center gap-3 text-center">
106
+ <span className="grid size-12 place-items-center rounded-full bg-brand/15 text-link">
107
+ <MailIcon className="size-6" />
108
+ </span>
109
+ <p className="text-sm text-foreground-muted">
110
+ {codes ? "Enter the 6-digit code we sent to " : "Open the link we sent to "}
111
+ <span className="font-medium text-foreground">{email}</span>.
112
+ </p>
113
+ </div>
114
+
115
+ {codes && (
116
+ <>
117
+ <OtpInput value={code} onChange={setCode} onComplete={(value) => void verify(value)} label="Verification code" autoFocus disabled={pending} />
118
+ <FormMessage>{error}</FormMessage>
119
+ <PrimaryButton type="button" onClick={() => void verify()} pending={pending} disabled={code.length !== 6}>
120
+ Verify email
121
+ </PrimaryButton>
122
+ <Divider />
123
+ </>
124
+ )}
125
+ {!codes && <FormMessage>{error}</FormMessage>}
126
+
127
+ <div className="flex flex-col items-center gap-2">
128
+ <SecondaryButton onClick={() => void resend.resend()} disabled={resend.disabled}>
129
+ {resend.label.replace("code", codes ? "code" : "link")}
130
+ </SecondaryButton>
131
+ <TextButton onClick={() => setSent(false)}>Use a different email</TextButton>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -1,78 +1,78 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { LogOutIcon, MoonIcon, SunIcon } from "lucide-react";
5
- import { authClient } from "@/lib/auth-client";
6
- import { Button } from "@/components/ui/button";
7
- import {
8
- DropdownMenu,
9
- DropdownMenuContent,
10
- DropdownMenuGroup,
11
- DropdownMenuItem,
12
- DropdownMenuLabel,
13
- DropdownMenuSeparator,
14
- DropdownMenuTrigger,
15
- } from "@/components/ui/dropdown-menu";
16
- import { Separator } from "@/components/ui/separator";
17
- import { SidebarTrigger } from "@/components/ui/sidebar";
18
-
19
- export type Theme = "light" | "dark" | "system";
20
-
21
- /** Persist the choice for SSR (so the first paint matches) and apply it now. */
22
- function applyTheme(theme: Theme) {
23
- document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
24
- const root = document.documentElement;
25
- root.classList.remove("light", "dark");
26
- if (theme !== "system") root.classList.add(theme);
27
- }
28
-
29
- export function AdminHeader({ user, initialTheme }: { user: { name?: string | null; email: string }; initialTheme: Theme }) {
30
- const [theme, setTheme] = useState<Theme>(initialTheme);
31
- const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
32
-
33
- return (
34
- <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
35
- <SidebarTrigger />
36
- <Separator orientation="vertical" className="mr-1 h-4" />
37
- <div className="ml-auto flex items-center gap-1">
38
- <Button
39
- variant="ghost"
40
- size="icon"
41
- aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
42
- onClick={() => {
43
- const next: Theme = dark ? "light" : "dark";
44
- setTheme(next);
45
- applyTheme(next);
46
- }}
47
- >
48
- {dark ? <SunIcon /> : <MoonIcon />}
49
- </Button>
50
- <DropdownMenu>
51
- <DropdownMenuTrigger asChild>
52
- <Button variant="ghost" size="sm" className="gap-2">
53
- <span className="max-w-40 truncate">{user.name || user.email}</span>
54
- </Button>
55
- </DropdownMenuTrigger>
56
- <DropdownMenuContent align="end" className="w-56">
57
- <DropdownMenuLabel className="font-normal">
58
- <span className="block truncate text-sm font-medium">{user.name || "Signed in"}</span>
59
- <span className="block truncate text-xs text-muted-foreground">{user.email}</span>
60
- </DropdownMenuLabel>
61
- <DropdownMenuSeparator />
62
- <DropdownMenuGroup>
63
- <DropdownMenuItem
64
- onSelect={async () => {
65
- await authClient.signOut();
66
- window.location.assign("/sign-in");
67
- }}
68
- >
69
- <LogOutIcon />
70
- Sign out
71
- </DropdownMenuItem>
72
- </DropdownMenuGroup>
73
- </DropdownMenuContent>
74
- </DropdownMenu>
75
- </div>
76
- </header>
77
- );
78
- }
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { LogOutIcon, MoonIcon, SunIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuGroup,
11
+ DropdownMenuItem,
12
+ DropdownMenuLabel,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger,
15
+ } from "@/components/ui/dropdown-menu";
16
+ import { Separator } from "@/components/ui/separator";
17
+ import { SidebarTrigger } from "@/components/ui/sidebar";
18
+
19
+ export type Theme = "light" | "dark" | "system";
20
+
21
+ /** Persist the choice for SSR (so the first paint matches) and apply it now. */
22
+ function applyTheme(theme: Theme) {
23
+ document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
24
+ const root = document.documentElement;
25
+ root.classList.remove("light", "dark");
26
+ if (theme !== "system") root.classList.add(theme);
27
+ }
28
+
29
+ export function DashboardHeader({ user, initialTheme }: { user: { name?: string | null; email: string }; initialTheme: Theme }) {
30
+ const [theme, setTheme] = useState<Theme>(initialTheme);
31
+ const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
32
+
33
+ return (
34
+ <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
35
+ <SidebarTrigger />
36
+ <Separator orientation="vertical" className="mr-1 h-4" />
37
+ <div className="ml-auto flex items-center gap-1">
38
+ <Button
39
+ variant="ghost"
40
+ size="icon"
41
+ aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
42
+ onClick={() => {
43
+ const next: Theme = dark ? "light" : "dark";
44
+ setTheme(next);
45
+ applyTheme(next);
46
+ }}
47
+ >
48
+ {dark ? <SunIcon /> : <MoonIcon />}
49
+ </Button>
50
+ <DropdownMenu>
51
+ <DropdownMenuTrigger asChild>
52
+ <Button variant="ghost" size="sm" className="gap-2">
53
+ <span className="max-w-40 truncate">{user.name || user.email}</span>
54
+ </Button>
55
+ </DropdownMenuTrigger>
56
+ <DropdownMenuContent align="end" className="w-56">
57
+ <DropdownMenuLabel className="font-normal">
58
+ <span className="block truncate text-sm font-medium">{user.name || "Signed in"}</span>
59
+ <span className="block truncate text-xs text-muted-foreground">{user.email}</span>
60
+ </DropdownMenuLabel>
61
+ <DropdownMenuSeparator />
62
+ <DropdownMenuGroup>
63
+ <DropdownMenuItem
64
+ onSelect={async () => {
65
+ await authClient.signOut();
66
+ window.location.assign("/sign-in");
67
+ }}
68
+ >
69
+ <LogOutIcon />
70
+ Sign out
71
+ </DropdownMenuItem>
72
+ </DropdownMenuGroup>
73
+ </DropdownMenuContent>
74
+ </DropdownMenu>
75
+ </div>
76
+ </header>
77
+ );
78
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname } from "next/navigation";
5
- import { LayoutDashboardIcon } from "lucide-react";
5
+ import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
6
6
  import {
7
7
  Sidebar,
8
8
  SidebarContent,
@@ -30,17 +30,27 @@ export interface NavResource {
30
30
  * descriptor's `icon`, the label from its plural label, and the active item from the
31
31
  * current path.
32
32
  */
33
- export function ResourceNav({
33
+ /** Sections every dashboard has, whatever resources the app defines. */
34
+ const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
35
+ // Security has its own page once `flare gen security` runs, which adds its link to
36
+ // lib/dashboard-nav.ts; observability ships with every app.
37
+ const ADMIN_LINKS = [{ href: "/dashboard/observability", label: "Observability", icon: ActivityIcon }];
38
+
39
+ export function DashboardSidebar({
34
40
  appName,
35
41
  resources,
36
42
  links = [],
43
+ isAdmin = false,
37
44
  }: {
38
45
  appName: string;
39
46
  resources: NavResource[];
40
- /** Extra pages (see lib/admin-nav.ts), shown under "Platform". */
47
+ /** Extra pages (see lib/dashboard-nav.ts), shown under "Platform". */
41
48
  links?: Array<{ label: string; href: string; icon: string }>;
49
+ /** Whether to show the sections that manage the app itself. */
50
+ isAdmin?: boolean;
42
51
  }) {
43
52
  const pathname = usePathname();
53
+ const manage = [...ACCOUNT_LINKS, ...(isAdmin ? ADMIN_LINKS : [])];
44
54
 
45
55
  return (
46
56
  <Sidebar collapsible="icon">
@@ -48,7 +58,7 @@ export function ResourceNav({
48
58
  <SidebarMenu>
49
59
  <SidebarMenuItem>
50
60
  <SidebarMenuButton asChild size="lg" tooltip={appName}>
51
- <Link href="/admin">
61
+ <Link href="/dashboard">
52
62
  <div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
53
63
  <LayoutDashboardIcon className="size-4" />
54
64
  </div>
@@ -60,12 +70,28 @@ export function ResourceNav({
60
70
  </SidebarHeader>
61
71
 
62
72
  <SidebarContent>
73
+ <SidebarGroup>
74
+ <SidebarGroupContent>
75
+ <SidebarMenu>
76
+ <SidebarMenuItem>
77
+ <SidebarMenuButton asChild isActive={pathname === "/dashboard"} tooltip="Overview">
78
+ <Link href="/dashboard">
79
+ <LayoutDashboardIcon />
80
+ <span>Overview</span>
81
+ </Link>
82
+ </SidebarMenuButton>
83
+ </SidebarMenuItem>
84
+ </SidebarMenu>
85
+ </SidebarGroupContent>
86
+ </SidebarGroup>
87
+
88
+ {resources.length > 0 && (
63
89
  <SidebarGroup>
64
90
  <SidebarGroupLabel>Resources</SidebarGroupLabel>
65
91
  <SidebarGroupContent>
66
92
  <SidebarMenu>
67
93
  {resources.map((resource) => {
68
- const href = `/admin/${resource.slug}`;
94
+ const href = `/dashboard/${resource.slug}`;
69
95
  const Icon = resourceIcon(resource.icon);
70
96
  return (
71
97
  <SidebarMenuItem key={resource.name}>
@@ -81,6 +107,26 @@ export function ResourceNav({
81
107
  </SidebarMenu>
82
108
  </SidebarGroupContent>
83
109
  </SidebarGroup>
110
+ )}
111
+
112
+ <SidebarGroup>
113
+ <SidebarGroupLabel>Manage</SidebarGroupLabel>
114
+ <SidebarGroupContent>
115
+ <SidebarMenu>
116
+ {manage.map((link) => (
117
+ <SidebarMenuItem key={link.href}>
118
+ <SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
119
+ <Link href={link.href}>
120
+ <link.icon />
121
+ <span>{link.label}</span>
122
+ </Link>
123
+ </SidebarMenuButton>
124
+ </SidebarMenuItem>
125
+ ))}
126
+ </SidebarMenu>
127
+ </SidebarGroupContent>
128
+ </SidebarGroup>
129
+
84
130
  {links.length > 0 && (
85
131
  <SidebarGroup>
86
132
  <SidebarGroupLabel>Platform</SidebarGroupLabel>
@@ -88,13 +134,24 @@ export function ResourceNav({
88
134
  <SidebarMenu>
89
135
  {links.map((link) => {
90
136
  const Icon = resourceIcon(link.icon);
137
+ // Route handlers (/api/...) and other sites are whole pages: open them in a new tab.
138
+ const external = link.href.startsWith("/api/") || /^https?:\/\//.test(link.href);
139
+ const content = (
140
+ <>
141
+ <Icon />
142
+ <span>{link.label}</span>
143
+ </>
144
+ );
91
145
  return (
92
146
  <SidebarMenuItem key={link.href}>
93
147
  <SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
94
- <Link href={link.href}>
95
- <Icon />
96
- <span>{link.label}</span>
97
- </Link>
148
+ {external ? (
149
+ <a href={link.href} target="_blank" rel="noreferrer">
150
+ {content}
151
+ </a>
152
+ ) : (
153
+ <Link href={link.href}>{content}</Link>
154
+ )}
98
155
  </SidebarMenuButton>
99
156
  </SidebarMenuItem>
100
157
  );
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { useTransition } from "react";
4
+ import { useSearchParams } from "next/navigation";
5
+ import { DownloadIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { exportRecordsAction } from "@/app/dashboard/actions";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Spinner } from "@/components/ui/spinner";
10
+ import { downloadCsv } from "./table-selection";
11
+
12
+ /**
13
+ * Exports what the table is showing — the same search, filters and sort, not just the
14
+ * page on screen. The server builds the CSV because the browser only ever holds one page.
15
+ */
16
+ export function ExportButton({ resourceName, pluralLabel }: { resourceName: string; pluralLabel: string }) {
17
+ const params = useSearchParams();
18
+ const [pending, startTransition] = useTransition();
19
+
20
+ const run = () =>
21
+ startTransition(async () => {
22
+ const query = new URLSearchParams(params.toString());
23
+ query.delete("page");
24
+ const result = await exportRecordsAction(resourceName, query.toString());
25
+ if (!result.ok) {
26
+ toast.error(result.error);
27
+ return;
28
+ }
29
+ const { csv, rows, truncated } = result.data;
30
+ downloadCsv(`${resourceName.toLowerCase()}-${new Date().toISOString().slice(0, 10)}.csv`, csv);
31
+ if (truncated) toast.warning(`Exported the first ${rows.toLocaleString()} ${pluralLabel.toLowerCase()}. Filter down for the rest.`);
32
+ else toast.success(`Exported ${rows.toLocaleString()} ${pluralLabel.toLowerCase()}.`);
33
+ });
34
+
35
+ return (
36
+ <Button variant="outline" onClick={run} disabled={pending}>
37
+ {pending ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
38
+ Export
39
+ </Button>
40
+ );
41
+ }