@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +985 -337
- package/package.json +3 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +40 -0
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -0
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +123 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +522 -0
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +306 -0
- package/templates/app/components/auth/sign-up-form.tsx +93 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +139 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
- package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +38 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +115 -6
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +47 -4
- package/templates/app/migrations/meta/0000_snapshot.json +311 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/auth-form.tsx +0 -120
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /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
|
|
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
|
-
|
|
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/
|
|
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="/
|
|
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 = `/
|
|
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
|
-
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|