@flaredev/cli 0.3.0 → 0.4.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 (42) hide show
  1. package/dist/index.js +385 -209
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +35 -0
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/components/account/account-nav.tsx +39 -0
  13. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  14. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  15. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  16. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  17. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  18. package/templates/app/components/dashboard/editable.ts +12 -0
  19. package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
  20. package/templates/app/components/dashboard/import-dialog.tsx +60 -7
  21. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  22. package/templates/app/components/dashboard/local-time.tsx +77 -0
  23. package/templates/app/components/dashboard/query.ts +2 -0
  24. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  25. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  26. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  27. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  28. package/templates/app/components/dashboard/resource-table.tsx +405 -331
  29. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  30. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  31. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  32. package/templates/app/components/ui/sonner.tsx +33 -13
  33. package/templates/app/db/flare-schema.ts +22 -0
  34. package/templates/app/lib/api.ts +12 -0
  35. package/templates/app/lib/dashboard.ts +165 -138
  36. package/templates/app/lib/views.ts +29 -0
  37. package/templates/app/migrations/0000_init.sql +11 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  39. package/templates/app/vite.config.ts +41 -31
  40. package/templates/locks/package-lock.json +3 -3
  41. package/templates/locks/pnpm-lock.yaml +4 -4
  42. package/templates/locks/yarn.lock +3548 -0
@@ -1,144 +1,156 @@
1
- "use client";
2
-
3
- import Link from "next/link";
4
- import { useRouter } from "next/navigation";
5
- import { useState, useTransition } from "react";
6
- import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
7
- import { toast } from "sonner";
8
- import type { Resource } from "@flaredev/core";
9
- import { deleteRecordAction } from "@/app/dashboard/actions";
10
- import {
11
- AlertDialog,
12
- AlertDialogCancel,
13
- AlertDialogContent,
14
- AlertDialogDescription,
15
- AlertDialogFooter,
16
- AlertDialogHeader,
17
- AlertDialogTitle,
18
- } from "@/components/ui/alert-dialog";
19
- import { Button } from "@/components/ui/button";
20
- import {
21
- DropdownMenu,
22
- DropdownMenuContent,
23
- DropdownMenuGroup,
24
- DropdownMenuItem,
25
- DropdownMenuSeparator,
26
- DropdownMenuTrigger,
27
- } from "@/components/ui/dropdown-menu";
28
- import { Spinner } from "@/components/ui/spinner";
29
- import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
30
-
31
- /**
32
- * Edit and delete for one row. Edit opens a dialog when the row's record came with it
33
- * (site.dashboard.forms is "modal"), and otherwise goes to the form page.
34
- */
35
- export function RowActions({
36
- resource,
37
- id,
38
- record,
39
- relations = {},
40
- listHref,
41
- editHref,
42
- canUpdate = true,
43
- canDelete = true,
44
- }: {
45
- resource: Resource;
46
- id: string;
47
- /** The record itself, when the form opens in a dialog. */
48
- record?: Record<string, unknown>;
49
- relations?: FormRelations;
50
- listHref: string;
51
- editHref: string;
52
- canUpdate?: boolean;
53
- canDelete?: boolean;
54
- }) {
55
- const router = useRouter();
56
- const [confirming, setConfirming] = useState(false);
57
- const [editing, setEditing] = useState(false);
58
- const [pending, startTransition] = useTransition();
59
- const { name: resourceName, label } = resource;
60
-
61
- function onDelete() {
62
- startTransition(async () => {
63
- const result = await deleteRecordAction(resourceName, id);
64
- if (result.ok) {
65
- toast.success(`${label} deleted.`);
66
- setConfirming(false);
67
- router.refresh();
68
- } else {
69
- toast.error(result.error);
70
- }
71
- });
72
- }
73
-
74
- if (!canUpdate && !canDelete) return null;
75
-
76
- return (
77
- <>
78
- <DropdownMenu>
79
- <DropdownMenuTrigger asChild>
80
- <Button variant="ghost" size="icon" aria-label="Row actions">
81
- <MoreHorizontalIcon />
82
- </Button>
83
- </DropdownMenuTrigger>
84
- <DropdownMenuContent align="end">
85
- {canUpdate && (
86
- <DropdownMenuGroup>
87
- {record ? (
88
- <DropdownMenuItem onSelect={() => setEditing(true)}>
89
- <PencilIcon />
90
- Edit
91
- </DropdownMenuItem>
92
- ) : (
93
- <DropdownMenuItem asChild>
94
- <Link href={editHref}>
95
- <PencilIcon />
96
- Edit
97
- </Link>
98
- </DropdownMenuItem>
99
- )}
100
- </DropdownMenuGroup>
101
- )}
102
- {canUpdate && canDelete && <DropdownMenuSeparator />}
103
- {canDelete && (
104
- <DropdownMenuGroup>
105
- <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
106
- <Trash2Icon />
107
- Delete
108
- </DropdownMenuItem>
109
- </DropdownMenuGroup>
110
- )}
111
- </DropdownMenuContent>
112
- </DropdownMenu>
113
-
114
- {record && canUpdate && (
115
- <ResourceFormDialog
116
- resource={resource}
117
- relations={relations}
118
- listHref={listHref}
119
- mode="edit"
120
- id={id}
121
- record={record}
122
- open={editing}
123
- onOpenChange={setEditing}
124
- />
125
- )}
126
-
127
- <AlertDialog open={confirming} onOpenChange={setConfirming}>
128
- <AlertDialogContent>
129
- <AlertDialogHeader>
130
- <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
131
- <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
132
- </AlertDialogHeader>
133
- <AlertDialogFooter>
134
- <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
135
- <Button variant="destructive" onClick={onDelete} disabled={pending}>
136
- {pending && <Spinner data-icon="inline-start" />}
137
- Delete
138
- </Button>
139
- </AlertDialogFooter>
140
- </AlertDialogContent>
141
- </AlertDialog>
142
- </>
143
- );
144
- }
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState, useTransition } from "react";
6
+ import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import type { Resource } from "@flaredev/core";
9
+ import { deleteRecordAction } from "@/app/dashboard/actions";
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogCancel,
13
+ AlertDialogContent,
14
+ AlertDialogDescription,
15
+ AlertDialogFooter,
16
+ AlertDialogHeader,
17
+ AlertDialogTitle,
18
+ } from "@/components/ui/alert-dialog";
19
+ import { Button } from "@/components/ui/button";
20
+ import {
21
+ DropdownMenu,
22
+ DropdownMenuContent,
23
+ DropdownMenuGroup,
24
+ DropdownMenuItem,
25
+ DropdownMenuSeparator,
26
+ DropdownMenuTrigger,
27
+ } from "@/components/ui/dropdown-menu";
28
+ import { Spinner } from "@/components/ui/spinner";
29
+ import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
30
+
31
+ /**
32
+ * Edit and delete for one row. Edit opens a dialog when the row's record came with it
33
+ * (site.dashboard.forms is "modal"), and otherwise goes to the form page.
34
+ */
35
+ export function RowActions({
36
+ resource,
37
+ id,
38
+ record,
39
+ relations = {},
40
+ listHref,
41
+ editHref,
42
+ detailHref,
43
+ canUpdate = true,
44
+ canDelete = true,
45
+ }: {
46
+ resource: Resource;
47
+ id: string;
48
+ /** The record itself, when the form opens in a dialog. */
49
+ record?: Record<string, unknown>;
50
+ relations?: FormRelations;
51
+ listHref: string;
52
+ editHref: string;
53
+ /** The record's own page. */
54
+ detailHref: string;
55
+ canUpdate?: boolean;
56
+ canDelete?: boolean;
57
+ }) {
58
+ const router = useRouter();
59
+ const [confirming, setConfirming] = useState(false);
60
+ const [editing, setEditing] = useState(false);
61
+ const [pending, startTransition] = useTransition();
62
+ const { name: resourceName, label } = resource;
63
+
64
+ function onDelete() {
65
+ startTransition(async () => {
66
+ const result = await deleteRecordAction(resourceName, id);
67
+ if (result.ok) {
68
+ toast.success(`${label} deleted.`);
69
+ setConfirming(false);
70
+ router.refresh();
71
+ } else {
72
+ toast.error(result.error);
73
+ }
74
+ });
75
+ }
76
+
77
+
78
+
79
+ return (
80
+ <>
81
+ <DropdownMenu>
82
+ <DropdownMenuTrigger asChild>
83
+ <Button variant="ghost" size="icon" aria-label="Row actions">
84
+ <MoreHorizontalIcon />
85
+ </Button>
86
+ </DropdownMenuTrigger>
87
+ <DropdownMenuContent align="end">
88
+ <DropdownMenuGroup>
89
+ <DropdownMenuItem asChild>
90
+ <Link href={detailHref}>
91
+ <ExternalLinkIcon />
92
+ Open
93
+ </Link>
94
+ </DropdownMenuItem>
95
+ </DropdownMenuGroup>
96
+ <DropdownMenuSeparator />
97
+ {canUpdate && (
98
+ <DropdownMenuGroup>
99
+ {record ? (
100
+ <DropdownMenuItem onSelect={() => setEditing(true)}>
101
+ <PencilIcon />
102
+ Edit
103
+ </DropdownMenuItem>
104
+ ) : (
105
+ <DropdownMenuItem asChild>
106
+ <Link href={editHref}>
107
+ <PencilIcon />
108
+ Edit
109
+ </Link>
110
+ </DropdownMenuItem>
111
+ )}
112
+ </DropdownMenuGroup>
113
+ )}
114
+ {canUpdate && canDelete && <DropdownMenuSeparator />}
115
+ {canDelete && (
116
+ <DropdownMenuGroup>
117
+ <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
118
+ <Trash2Icon />
119
+ Delete
120
+ </DropdownMenuItem>
121
+ </DropdownMenuGroup>
122
+ )}
123
+ </DropdownMenuContent>
124
+ </DropdownMenu>
125
+
126
+ {record && canUpdate && (
127
+ <ResourceFormDialog
128
+ resource={resource}
129
+ relations={relations}
130
+ listHref={listHref}
131
+ mode="edit"
132
+ id={id}
133
+ record={record}
134
+ open={editing}
135
+ onOpenChange={setEditing}
136
+ />
137
+ )}
138
+
139
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
140
+ <AlertDialogContent>
141
+ <AlertDialogHeader>
142
+ <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
143
+ <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
144
+ </AlertDialogHeader>
145
+ <AlertDialogFooter>
146
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
147
+ <Button variant="destructive" onClick={onDelete} disabled={pending}>
148
+ {pending && <Spinner data-icon="inline-start" />}
149
+ Delete
150
+ </Button>
151
+ </AlertDialogFooter>
152
+ </AlertDialogContent>
153
+ </AlertDialog>
154
+ </>
155
+ );
156
+ }
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { BookmarkPlusIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { saveViewAction } from "@/app/dashboard/actions";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
10
+ import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
11
+ import { Input } from "@/components/ui/input";
12
+ import { Spinner } from "@/components/ui/spinner";
13
+
14
+ /**
15
+ * Keep this search, these filters, this sort and these columns as a named view.
16
+ *
17
+ * Only offered once the table is actually filtered — saving "all of them" as a view is
18
+ * saving the list you're already on.
19
+ */
20
+ export function SaveViewButton({ resourceName, label }: { resourceName: string; label: string }) {
21
+ const params = useSearchParams();
22
+ const router = useRouter();
23
+ const [open, setOpen] = useState(false);
24
+ const [name, setName] = useState("");
25
+ const [pending, startTransition] = useTransition();
26
+
27
+ const query = new URLSearchParams(params.toString());
28
+ query.delete("page");
29
+ query.delete("cursor");
30
+ if ([...query.keys()].length === 0) return null;
31
+
32
+ const save = () => {
33
+ startTransition(async () => {
34
+ const result = await saveViewAction(resourceName, name, query.toString());
35
+ if (!result.ok) {
36
+ toast.error(result.error);
37
+ return;
38
+ }
39
+ setOpen(false);
40
+ setName("");
41
+ toast.success(`Saved "${name.trim()}" to the sidebar.`);
42
+ router.refresh();
43
+ });
44
+ };
45
+
46
+ return (
47
+ <>
48
+ <Button variant="outline" size="icon" aria-label="Save this view" onClick={() => setOpen(true)}>
49
+ <BookmarkPlusIcon />
50
+ </Button>
51
+
52
+ <Dialog open={open} onOpenChange={setOpen}>
53
+ <DialogContent className="sm:max-w-md">
54
+ <DialogHeader>
55
+ <DialogTitle>Save this view</DialogTitle>
56
+ <DialogDescription>It appears under {label.toLowerCase()} in the sidebar, for you.</DialogDescription>
57
+ </DialogHeader>
58
+ <Field>
59
+ <FieldLabel htmlFor="view-name">Name</FieldLabel>
60
+ <Input
61
+ id="view-name"
62
+ value={name}
63
+ onChange={(event) => setName(event.target.value)}
64
+ placeholder="Overdue invoices"
65
+ maxLength={60}
66
+ autoFocus
67
+ onKeyDown={(event) => {
68
+ if (event.key === "Enter" && name.trim()) save();
69
+ }}
70
+ />
71
+ <FieldDescription>Keeps the search, filters, sorting and columns you have now.</FieldDescription>
72
+ </Field>
73
+ <DialogFooter>
74
+ <Button variant="outline" onClick={() => setOpen(false)} disabled={pending}>
75
+ Cancel
76
+ </Button>
77
+ <Button onClick={save} disabled={pending || !name.trim()}>
78
+ {pending && <Spinner data-icon="inline-start" />}
79
+ Save view
80
+ </Button>
81
+ </DialogFooter>
82
+ </DialogContent>
83
+ </Dialog>
84
+ </>
85
+ );
86
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { KeyRoundIcon, LogOutIcon, MonitorSmartphoneIcon, ShieldCheckIcon, UserIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7
+ import {
8
+ DropdownMenuGroup,
9
+ DropdownMenuItem,
10
+ DropdownMenuLabel,
11
+ DropdownMenuSeparator,
12
+ } from "@/components/ui/dropdown-menu";
13
+
14
+ export interface DashboardUser {
15
+ name?: string | null;
16
+ email: string;
17
+ image?: string | null;
18
+ }
19
+
20
+ /** "Ada Lovelace" → "AL"; an address falls back to its first letter. */
21
+ export function initials(user: DashboardUser): string {
22
+ const source = user.name?.trim() || user.email;
23
+ const parts = source.split(/[\s@._-]+/).filter(Boolean);
24
+ return (parts.length > 1 ? `${parts[0]![0]}${parts[1]![0]}` : source.slice(0, 2)).toUpperCase();
25
+ }
26
+
27
+ export function UserAvatar({ user, className }: { user: DashboardUser; className?: string }) {
28
+ return (
29
+ <Avatar className={className}>
30
+ {user.image && <AvatarImage src={user.image} alt="" />}
31
+ <AvatarFallback className="bg-primary/10 text-xs font-medium text-primary">{initials(user)}</AvatarFallback>
32
+ </Avatar>
33
+ );
34
+ }
35
+
36
+ /**
37
+ * What's inside the account menu, wherever it's opened from — the header's avatar and
38
+ * the sidebar's footer both show this, so there's one list of account actions, not two.
39
+ */
40
+ export function UserMenuItems({ user }: { user: DashboardUser }) {
41
+ const signOut = async () => {
42
+ await authClient.signOut();
43
+ window.location.assign("/sign-in");
44
+ };
45
+
46
+ return (
47
+ <>
48
+ <DropdownMenuLabel className="flex items-center gap-2 font-normal">
49
+ <UserAvatar user={user} className="size-8" />
50
+ <span className="grid min-w-0">
51
+ <span className="truncate text-sm font-medium">{user.name || "Signed in"}</span>
52
+ <span className="truncate text-xs text-muted-foreground">{user.email}</span>
53
+ </span>
54
+ </DropdownMenuLabel>
55
+ <DropdownMenuSeparator />
56
+ <DropdownMenuGroup>
57
+ <DropdownMenuItem asChild>
58
+ <Link href="/dashboard/account">
59
+ <UserIcon />
60
+ Profile
61
+ </Link>
62
+ </DropdownMenuItem>
63
+ <DropdownMenuItem asChild>
64
+ <Link href="/dashboard/account/password">
65
+ <KeyRoundIcon />
66
+ Password
67
+ </Link>
68
+ </DropdownMenuItem>
69
+ <DropdownMenuItem asChild>
70
+ <Link href="/dashboard/account/security">
71
+ <ShieldCheckIcon />
72
+ Security
73
+ </Link>
74
+ </DropdownMenuItem>
75
+ <DropdownMenuItem asChild>
76
+ <Link href="/dashboard/account/sessions">
77
+ <MonitorSmartphoneIcon />
78
+ Devices
79
+ </Link>
80
+ </DropdownMenuItem>
81
+ </DropdownMenuGroup>
82
+ <DropdownMenuSeparator />
83
+ <DropdownMenuGroup>
84
+ <DropdownMenuItem variant="destructive" onSelect={signOut}>
85
+ <LogOutIcon />
86
+ Sign out
87
+ </DropdownMenuItem>
88
+ </DropdownMenuGroup>
89
+ </>
90
+ );
91
+ }
@@ -1,20 +1,23 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import {
4
- CircleCheckIcon,
5
- InfoIcon,
6
- Loader2Icon,
7
- OctagonXIcon,
8
- TriangleAlertIcon,
9
- } from "lucide-react"
10
- import { Toaster as Sonner, type ToasterProps } from "sonner"
3
+ import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
4
+ import { Toaster as Sonner, type ToasterProps } from "sonner";
11
5
 
12
- // The theme comes from the admin layout (cookie-backed), so no theme provider is needed.
6
+ /**
7
+ * Toasts in the app's theme.
8
+ *
9
+ * The colours come from the theme tokens (app/globals.css), so success is the theme's
10
+ * green, an error is its red, and the corner radius matches everything else — switch
11
+ * the theme and the toasts switch with it. Light or dark comes from the dashboard
12
+ * layout's cookie, so there's no theme provider in the tree.
13
+ */
13
14
  const Toaster = ({ theme = "system", ...props }: ToasterProps) => {
14
15
  return (
15
16
  <Sonner
16
17
  theme={theme as ToasterProps["theme"]}
17
18
  className="toaster group"
19
+ position="bottom-right"
20
+ closeButton
18
21
  icons={{
19
22
  success: <CircleCheckIcon className="size-4" />,
20
23
  info: <InfoIcon className="size-4" />,
@@ -22,17 +25,34 @@ const Toaster = ({ theme = "system", ...props }: ToasterProps) => {
22
25
  error: <OctagonXIcon className="size-4" />,
23
26
  loading: <Loader2Icon className="size-4 animate-spin" />,
24
27
  }}
28
+ toastOptions={{
29
+ classNames: {
30
+ // A tinted left edge and a matching icon: the tone reads at a glance without
31
+ // colouring the whole toast, which would fight the page behind it.
32
+ toast:
33
+ "group toast border-l-4 shadow-lg data-[type=success]:border-l-success data-[type=error]:border-l-danger data-[type=warning]:border-l-warning data-[type=info]:border-l-link",
34
+ icon: "group-data-[type=success]:text-success group-data-[type=error]:text-danger group-data-[type=warning]:text-warning group-data-[type=info]:text-link",
35
+ title: "text-sm font-medium",
36
+ description: "text-xs text-muted-foreground",
37
+ actionButton: "bg-primary text-primary-foreground",
38
+ cancelButton: "bg-muted text-muted-foreground",
39
+ },
40
+ }}
25
41
  style={
26
42
  {
27
43
  "--normal-bg": "var(--popover)",
28
44
  "--normal-text": "var(--popover-foreground)",
29
45
  "--normal-border": "var(--border)",
30
46
  "--border-radius": "var(--radius)",
47
+ "--success-bg": "var(--popover)",
48
+ "--success-text": "var(--popover-foreground)",
49
+ "--error-bg": "var(--popover)",
50
+ "--error-text": "var(--popover-foreground)",
31
51
  } as React.CSSProperties
32
52
  }
33
53
  {...props}
34
54
  />
35
- )
36
- }
55
+ );
56
+ };
37
57
 
38
- export { Toaster }
58
+ export { Toaster };
@@ -45,3 +45,25 @@ export const auditLog = sqliteTable(
45
45
  },
46
46
  (table) => [index("audit_log_created_at_idx").on(table.createdAt), index("audit_log_resource_idx").on(table.resource)],
47
47
  );
48
+
49
+ /**
50
+ * A filtered, sorted, column-picked view of a resource that someone wanted to keep, like
51
+ * "Overdue invoices". The query is the table's own URL query string, so saving a view is
52
+ * saving what's in the address bar — nothing about the table has to know views exist.
53
+ */
54
+ export const savedView = sqliteTable(
55
+ "saved_view",
56
+ {
57
+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
58
+ userId: text("user_id").notNull(),
59
+ /** The resource's descriptor name, e.g. "Invoice". */
60
+ resource: text("resource").notNull(),
61
+ name: text("name").notNull(),
62
+ /** "q=ada&filter[status]=lead&sort=-createdAt", without the leading "?". */
63
+ query: text("query").notNull(),
64
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
65
+ .notNull()
66
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
67
+ },
68
+ (table) => [index("saved_view_user_idx").on(table.userId, table.resource)],
69
+ );
@@ -1,8 +1,20 @@
1
+ import { headers } from "next/headers";
1
2
  import { can, type Policy } from "@flaredev/core";
2
3
  import type { Authorize } from "@flaredev/core/server";
3
4
  import { policies } from "@/policies";
4
5
  import { auth } from "./auth";
5
6
 
7
+ /**
8
+ * Who is making this request, for a resource's hooks (`hooks.beforeCreate` and friends
9
+ * in the descriptor). Reads the session the same way everything else does.
10
+ */
11
+ export async function currentUser() {
12
+ const session = await auth.api.getSession({ headers: await headers() });
13
+ if (!session) return null;
14
+ const { id, email } = session.user;
15
+ return { id, email, role: (session.user as { role?: string | null }).role ?? null };
16
+ }
17
+
6
18
  /**
7
19
  * Authorization for every generated resource API (`app/api/<resource>/...`).
8
20
  * Return a Response to deny the request; return nothing to allow it.