@flaredev/cli 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +498 -220
- package/package.json +2 -2
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -33
- package/templates/app/app/dashboard/account/password/page.tsx +22 -0
- package/templates/app/app/dashboard/account/security/page.tsx +34 -0
- package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
- package/templates/app/app/dashboard/actions.ts +73 -5
- package/templates/app/app/dashboard/layout.tsx +28 -4
- package/templates/app/app/dashboard/observability/page.tsx +4 -3
- package/templates/app/app/dashboard/page.tsx +2 -2
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/fields/file-field.tsx +310 -302
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +62 -9
- package/templates/app/components/dashboard/inline-cell.tsx +165 -0
- package/templates/app/components/dashboard/local-time.tsx +77 -0
- package/templates/app/components/dashboard/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +203 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form.tsx +165 -165
- package/templates/app/components/dashboard/resource-stats.tsx +6 -11
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +408 -331
- package/templates/app/components/dashboard/row-actions.tsx +156 -144
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +22 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/dashboard.ts +165 -138
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +11 -0
- package/templates/app/migrations/meta/0000_snapshot.json +63 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- 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 {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
record
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 { ClientResource } 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: ClientResource;
|
|
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'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
|
-
|
|
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
|
-
|
|
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
|
+
);
|
package/templates/app/lib/api.ts
CHANGED
|
@@ -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.
|