@flaredev/cli 0.12.2 → 0.14.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/dist/index.js +126 -57
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/app/dashboard/error.tsx +18 -5
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/roles/actions.ts +57 -0
- package/templates/app/app/dashboard/roles/page.tsx +56 -0
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -0
- package/templates/app/app/error.tsx +14 -5
- package/templates/app/components/account/sections.tsx +23 -3
- package/templates/app/components/auth/password-field.tsx +51 -32
- package/templates/app/components/auth/sign-in-flow.tsx +306 -306
- package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
- package/templates/app/components/dashboard/cell-value.ts +67 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
- package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
- package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
- package/templates/app/components/dashboard/import-dialog.tsx +1 -58
- package/templates/app/components/dashboard/resource-table.tsx +2 -0
- package/templates/app/components/dashboard/role-matrix.tsx +268 -0
- package/templates/app/components/dashboard/user-actions.tsx +217 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/components/ui/combobox.tsx +133 -0
- package/templates/app/db/flare-schema.ts +27 -1
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/api.ts +4 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +20 -5
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/permissions.ts +105 -0
- package/templates/app/lib/site.ts +2 -2
- package/templates/app/lib/stale-chunk.ts +50 -0
- package/templates/app/migrations/0000_init.sql +9 -0
- package/templates/app/migrations/meta/0000_snapshot.json +57 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +3 -1
- package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
- package/templates/locks/next/pnpm-lock.yaml +4 -4
- package/templates/next/README.md +1 -1
- package/templates/next/app/dashboard/layout.tsx +2 -2
- package/templates/next/app/dashboard/observability/page.tsx +156 -0
- package/templates/next/app/dashboard/page.tsx +87 -23
- package/templates/next/app/dashboard/users/page.tsx +51 -0
- package/templates/next/lib/auth.ts +3 -3
- package/templates/next/lib/dashboard-nav.ts +29 -0
- package/templates/next/lib/dashboard.ts +20 -5
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/lib/permissions.ts +100 -0
- package/templates/next/prisma/schema/base.prisma +37 -0
- package/templates/next/tsconfig.json +2 -2
- /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
- /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
- /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
- /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
- /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
- /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
- /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { CheckCircle2Icon, MinusIcon } from "lucide-react";
|
|
2
|
+
import { Badge } from "@/components/ui/badge";
|
|
3
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
5
|
+
import { LocalTime } from "./local-time";
|
|
6
|
+
import { UserAvatar } from "./user-menu";
|
|
7
|
+
import { UserRowMenu } from "./user-actions";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The accounts table, shared by both stacks: the page above it only decides where the
|
|
11
|
+
* rows come from (Prisma on Next.js, Drizzle on Cloudflare).
|
|
12
|
+
*/
|
|
13
|
+
export interface AccountRow {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
email: string;
|
|
17
|
+
image?: string | null;
|
|
18
|
+
/** The admin plugin's default role is "user"; null means the same. */
|
|
19
|
+
role?: string | null;
|
|
20
|
+
banned?: boolean | null;
|
|
21
|
+
emailVerified: boolean;
|
|
22
|
+
twoFactorEnabled?: boolean | null;
|
|
23
|
+
createdAt: Date;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Roles that stand out: everything else is the ordinary "user" and stays quiet. */
|
|
27
|
+
function roleTone(role: string): "default" | "secondary" {
|
|
28
|
+
return role === "admin" ? "default" : "secondary";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function UsersTable({ rows, total, currentUserId }: { rows: AccountRow[]; total: number; currentUserId: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<Card>
|
|
34
|
+
<CardHeader>
|
|
35
|
+
<CardTitle className="text-base">Accounts</CardTitle>
|
|
36
|
+
<CardDescription>
|
|
37
|
+
{total.toLocaleString()} {total === 1 ? "account" : "accounts"}
|
|
38
|
+
{total > rows.length ? `, newest ${rows.length} shown` : ""}.
|
|
39
|
+
</CardDescription>
|
|
40
|
+
</CardHeader>
|
|
41
|
+
<CardContent>
|
|
42
|
+
<div className="overflow-x-auto">
|
|
43
|
+
<Table>
|
|
44
|
+
<TableHeader>
|
|
45
|
+
<TableRow>
|
|
46
|
+
<TableHead>Who</TableHead>
|
|
47
|
+
<TableHead>Role</TableHead>
|
|
48
|
+
<TableHead>Verified</TableHead>
|
|
49
|
+
<TableHead>Two-factor</TableHead>
|
|
50
|
+
<TableHead className="text-right">Joined</TableHead>
|
|
51
|
+
<TableHead className="w-10" aria-label="Actions" />
|
|
52
|
+
</TableRow>
|
|
53
|
+
</TableHeader>
|
|
54
|
+
<TableBody>
|
|
55
|
+
{rows.map((account) => (
|
|
56
|
+
<TableRow key={account.id}>
|
|
57
|
+
<TableCell>
|
|
58
|
+
<div className="flex items-center gap-3">
|
|
59
|
+
<UserAvatar user={account} className="size-8 rounded-md" />
|
|
60
|
+
<div className="grid min-w-0 leading-tight">
|
|
61
|
+
<span className="truncate text-sm font-medium">{account.name || "—"}</span>
|
|
62
|
+
<span className="truncate text-xs text-muted-foreground">{account.email}</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</TableCell>
|
|
66
|
+
<TableCell>
|
|
67
|
+
{account.banned ? (
|
|
68
|
+
<Badge variant="destructive">banned</Badge>
|
|
69
|
+
) : (
|
|
70
|
+
<Badge variant={roleTone(account.role ?? "user")}>{account.role ?? "user"}</Badge>
|
|
71
|
+
)}
|
|
72
|
+
</TableCell>
|
|
73
|
+
<TableCell>
|
|
74
|
+
{account.emailVerified ? (
|
|
75
|
+
<CheckCircle2Icon aria-label="Email verified" className="size-4.5 text-success" />
|
|
76
|
+
) : (
|
|
77
|
+
<MinusIcon aria-label="Not verified" className="size-4.5 text-muted-foreground" />
|
|
78
|
+
)}
|
|
79
|
+
</TableCell>
|
|
80
|
+
<TableCell>
|
|
81
|
+
{account.twoFactorEnabled ? (
|
|
82
|
+
<CheckCircle2Icon aria-label="Two-factor on" className="size-4.5 text-success" />
|
|
83
|
+
) : (
|
|
84
|
+
<MinusIcon aria-label="Two-factor off" className="size-4.5 text-muted-foreground" />
|
|
85
|
+
)}
|
|
86
|
+
</TableCell>
|
|
87
|
+
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
88
|
+
<LocalTime value={account.createdAt} />
|
|
89
|
+
</TableCell>
|
|
90
|
+
<TableCell>
|
|
91
|
+
{/* Not on your own row: demoting or banning yourself is a lockout, not a feature. */}
|
|
92
|
+
{account.id !== currentUserId && (
|
|
93
|
+
<UserRowMenu userId={account.id} role={account.role ?? "user"} banned={Boolean(account.banned)} />
|
|
94
|
+
)}
|
|
95
|
+
</TableCell>
|
|
96
|
+
</TableRow>
|
|
97
|
+
))}
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>
|
|
100
|
+
</div>
|
|
101
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
102
|
+
A role is a name your <a className="underline underline-offset-2" href="https://flare-docs.codetotech.com/guides/roles-and-policies/" target="_blank" rel="noreferrer">policies</a> give
|
|
103
|
+
meaning: <code>npx flare gen policy Contact --roles admin,staff</code> decides what each role may do. The terminal does all
|
|
104
|
+
of this too — <code>npx flare user:add</code>, <code>npx flare user:role</code> — and writes the same rows.
|
|
105
|
+
</p>
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
export interface ComboboxOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
/** A second line, for options whose label is not enough on its own. */
|
|
14
|
+
hint?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A select you can type into.
|
|
19
|
+
*
|
|
20
|
+
* A plain `<select>` is fine for three options and miserable for thirty: you cannot
|
|
21
|
+
* search it, and on a touch device it becomes a native wheel. This is the pattern the
|
|
22
|
+
* relation field already used — a popover over a command list — made general, so every
|
|
23
|
+
* place in the app that chooses one thing from a list behaves the same way.
|
|
24
|
+
*
|
|
25
|
+
* `allowCustom` lets the typed text be the answer. That is for values which are not a
|
|
26
|
+
* fixed set — a role is just a string your policies name, so the list is a convenience
|
|
27
|
+
* rather than a constraint, and the field must not stop you inventing one.
|
|
28
|
+
*/
|
|
29
|
+
export function Combobox({
|
|
30
|
+
options,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
id,
|
|
34
|
+
name,
|
|
35
|
+
placeholder = "Select…",
|
|
36
|
+
searchPlaceholder = "Search…",
|
|
37
|
+
emptyMessage = "Nothing found.",
|
|
38
|
+
allowCustom = false,
|
|
39
|
+
customLabel = (typed: string) => `Use "${typed}"`,
|
|
40
|
+
disabled,
|
|
41
|
+
invalid,
|
|
42
|
+
className,
|
|
43
|
+
}: {
|
|
44
|
+
options: ComboboxOption[];
|
|
45
|
+
value: string;
|
|
46
|
+
onChange: (value: string) => void;
|
|
47
|
+
id?: string;
|
|
48
|
+
/** Mirrors the value into a hidden input, so plain `<form>` submission sees it. */
|
|
49
|
+
name?: string;
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
searchPlaceholder?: string;
|
|
52
|
+
emptyMessage?: string;
|
|
53
|
+
allowCustom?: boolean;
|
|
54
|
+
customLabel?: (typed: string) => string;
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
invalid?: boolean;
|
|
57
|
+
className?: string;
|
|
58
|
+
}) {
|
|
59
|
+
const [open, setOpen] = useState(false);
|
|
60
|
+
const [query, setQuery] = useState("");
|
|
61
|
+
|
|
62
|
+
const selected = options.find((option) => option.value === value);
|
|
63
|
+
// A custom value is shown as itself rather than falling back to the placeholder,
|
|
64
|
+
// which would make a perfectly good answer look like no answer.
|
|
65
|
+
const display = selected?.label ?? (value || null);
|
|
66
|
+
|
|
67
|
+
const typed = query.trim();
|
|
68
|
+
const canCreate = allowCustom && typed.length > 0 && !options.some((option) => option.value.toLowerCase() === typed.toLowerCase());
|
|
69
|
+
|
|
70
|
+
const choose = (next: string) => {
|
|
71
|
+
onChange(next);
|
|
72
|
+
setQuery("");
|
|
73
|
+
setOpen(false);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const filtered = useMemo(() => {
|
|
77
|
+
if (!typed) return options;
|
|
78
|
+
const needle = typed.toLowerCase();
|
|
79
|
+
return options.filter((option) => option.label.toLowerCase().includes(needle) || option.value.toLowerCase().includes(needle));
|
|
80
|
+
}, [options, typed]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{name && <input type="hidden" name={name} value={value} />}
|
|
85
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
86
|
+
<PopoverTrigger asChild>
|
|
87
|
+
<Button
|
|
88
|
+
id={id}
|
|
89
|
+
type="button"
|
|
90
|
+
variant="outline"
|
|
91
|
+
role="combobox"
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
aria-invalid={invalid || undefined}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
className={cn("w-full justify-between font-normal", !display && "text-muted-foreground", className)}
|
|
96
|
+
>
|
|
97
|
+
<span className="truncate">{display ?? placeholder}</span>
|
|
98
|
+
<ChevronsUpDownIcon className="opacity-50" />
|
|
99
|
+
</Button>
|
|
100
|
+
</PopoverTrigger>
|
|
101
|
+
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
102
|
+
<Command shouldFilter={false}>
|
|
103
|
+
<CommandInput placeholder={searchPlaceholder} value={query} onValueChange={setQuery} />
|
|
104
|
+
<CommandList>
|
|
105
|
+
{filtered.length === 0 && !canCreate && <CommandEmpty>{emptyMessage}</CommandEmpty>}
|
|
106
|
+
{filtered.length > 0 && (
|
|
107
|
+
<CommandGroup>
|
|
108
|
+
{filtered.map((option) => (
|
|
109
|
+
<CommandItem key={option.value} value={option.value} onSelect={() => choose(option.value)}>
|
|
110
|
+
<span className="flex min-w-0 flex-col">
|
|
111
|
+
<span className="truncate">{option.label}</span>
|
|
112
|
+
{option.hint && <span className="truncate text-xs text-muted-foreground">{option.hint}</span>}
|
|
113
|
+
</span>
|
|
114
|
+
<CheckIcon className={cn("ml-auto", option.value === value ? "opacity-100" : "opacity-0")} />
|
|
115
|
+
</CommandItem>
|
|
116
|
+
))}
|
|
117
|
+
</CommandGroup>
|
|
118
|
+
)}
|
|
119
|
+
{canCreate && (
|
|
120
|
+
<CommandGroup>
|
|
121
|
+
<CommandItem value={`__create__${typed}`} onSelect={() => choose(typed)}>
|
|
122
|
+
<PlusIcon />
|
|
123
|
+
{customLabel(typed)}
|
|
124
|
+
</CommandItem>
|
|
125
|
+
</CommandGroup>
|
|
126
|
+
)}
|
|
127
|
+
</CommandList>
|
|
128
|
+
</Command>
|
|
129
|
+
</PopoverContent>
|
|
130
|
+
</Popover>
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Roles a user can hold (`user.role` from Better Auth's admin plugin).
|
|
@@ -14,6 +14,32 @@ export const role = sqliteTable("role", {
|
|
|
14
14
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Permissions granted from the dashboard, on top of the policies in your code.
|
|
19
|
+
*
|
|
20
|
+
* A row says "this role may do this to this resource". It can only ever *widen* what
|
|
21
|
+
* `policies/*.policy.ts` already allows — see `withGrants` in @flaredev/core. A
|
|
22
|
+
* resource with no policy file is open to every signed-in user, and no row here changes
|
|
23
|
+
* that; the roles screen says so rather than pretending otherwise.
|
|
24
|
+
*/
|
|
25
|
+
export const rolePermission = sqliteTable(
|
|
26
|
+
"role_permission",
|
|
27
|
+
{
|
|
28
|
+
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
29
|
+
/** The role's name, as in the `role` table and `user.role`. */
|
|
30
|
+
role: text("role").notNull(),
|
|
31
|
+
/** The resource's descriptor name, e.g. "Invoice". */
|
|
32
|
+
resource: text("resource").notNull(),
|
|
33
|
+
/** "read", "create", "update" or "delete". */
|
|
34
|
+
action: text("action").notNull(),
|
|
35
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
36
|
+
.notNull()
|
|
37
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
|
|
38
|
+
},
|
|
39
|
+
// One row per cell of the matrix: ticking a box twice is the same tick.
|
|
40
|
+
(table) => [uniqueIndex("role_permission_unique").on(table.role, table.resource, table.action)],
|
|
41
|
+
);
|
|
42
|
+
|
|
17
43
|
/**
|
|
18
44
|
* Who changed what, and when. Written by the dashboard's actions (lib/audit.ts) for
|
|
19
45
|
* every create, update, delete and import, and read by /dashboard/observability.
|
package/templates/app/lib/api.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ResourceAction } from "@/lib/resource";
|
|
|
6
6
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
7
7
|
import type { Authorize } from "@/lib/resource/handlers";
|
|
8
8
|
import { policies } from "@/policies";
|
|
9
|
+
import { effectivePolicy } from "./permissions";
|
|
9
10
|
import { userFromApiKey } from "./api-keys";
|
|
10
11
|
import { auth } from "./auth";
|
|
11
12
|
|
|
@@ -60,7 +61,9 @@ export const authorize: Authorize = async ({ request, resource, action }) => {
|
|
|
60
61
|
if (!session && !viaKey) return problemResponse(401, "Sign in required.", PROBLEM_CODES.unauthenticated);
|
|
61
62
|
|
|
62
63
|
const role = session ? ((session.user as { role?: string | null }).role ?? null) : viaKey!.role;
|
|
63
|
-
|
|
64
|
+
// The effective policy: your policy file, widened by whatever the dashboard's Roles
|
|
65
|
+
// screen granted. The API and the admin read the same one, so they cannot drift.
|
|
66
|
+
if (!can(await effectivePolicy(resource.name), role, action)) {
|
|
64
67
|
return problemResponse(403, `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.`, PROBLEM_CODES.forbidden);
|
|
65
68
|
}
|
|
66
69
|
};
|
|
@@ -194,7 +194,7 @@ async function promoteFirstUser() {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
export const auth = betterAuth({
|
|
197
|
-
appName: "
|
|
197
|
+
appName: "__APP_TITLE__",
|
|
198
198
|
secret: env.BETTER_AUTH_SECRET,
|
|
199
199
|
// Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
|
|
200
200
|
// this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
|
|
@@ -265,7 +265,7 @@ export const auth = betterAuth({
|
|
|
265
265
|
}),
|
|
266
266
|
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
267
267
|
twoFactor({
|
|
268
|
-
issuer: "
|
|
268
|
+
issuer: "__APP_TITLE__",
|
|
269
269
|
otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
|
|
270
270
|
// With only email codes switched on there's no authenticator app to confirm first.
|
|
271
271
|
skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
|
|
@@ -273,7 +273,7 @@ export const auth = betterAuth({
|
|
|
273
273
|
// accounts that do have one must still give it.
|
|
274
274
|
allowPasswordless: true,
|
|
275
275
|
}),
|
|
276
|
-
passkey({ rpName: "
|
|
276
|
+
passkey({ rpName: "__APP_TITLE__" }),
|
|
277
277
|
// Must stay last: lets server actions set auth cookies.
|
|
278
278
|
nextCookies(),
|
|
279
279
|
],
|
|
@@ -6,6 +6,7 @@ import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
|
6
6
|
import { createResourceStore, drizzleRows, type ResourceStore } from "@/lib/resource";
|
|
7
7
|
import { getDb } from "@/db";
|
|
8
8
|
import { policies } from "@/policies";
|
|
9
|
+
import { effectivePolicy } from "./permissions";
|
|
9
10
|
import { resourceTables } from "@/resources/server";
|
|
10
11
|
import { currentUser } from "./api";
|
|
11
12
|
import { auth } from "./auth";
|
|
@@ -18,6 +19,13 @@ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
|
|
|
18
19
|
*/
|
|
19
20
|
export const ADMIN_ROLES = ["admin", "staff"];
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The policy in your code, before the dashboard's grants.
|
|
24
|
+
*
|
|
25
|
+
* The store takes this one, because the half of a policy the store enforces is `own` —
|
|
26
|
+
* per-record ownership — which is a code decision and not something the roles screen
|
|
27
|
+
* can change. Role checks go through {@link effectivePolicy} instead.
|
|
28
|
+
*/
|
|
21
29
|
export function policyFor(resourceName: string): Policy | undefined {
|
|
22
30
|
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
23
31
|
}
|
|
@@ -26,7 +34,9 @@ export async function dashboardSession() {
|
|
|
26
34
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
27
35
|
if (!session) return { session: null, role: null, allowed: false } as const;
|
|
28
36
|
const role = (session.user as { role?: string | null }).role ?? null;
|
|
29
|
-
const readable = allResources().
|
|
37
|
+
const readable = (await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)))).some((policy) =>
|
|
38
|
+
can(policy, role, "read"),
|
|
39
|
+
);
|
|
30
40
|
return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -45,7 +55,7 @@ export async function requireDashboard(returnTo = "/dashboard") {
|
|
|
45
55
|
*/
|
|
46
56
|
export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
47
57
|
const { session, role } = await requireDashboard(resourcePath(resource));
|
|
48
|
-
const policy =
|
|
58
|
+
const policy = await effectivePolicy(resource.name);
|
|
49
59
|
if (!can(policy, role, action)) {
|
|
50
60
|
redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
|
|
51
61
|
}
|
|
@@ -55,7 +65,7 @@ export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
|
55
65
|
/** What the current user may do with a resource, for hiding actions they can't use. */
|
|
56
66
|
export async function adminPermissions(resourceName: string) {
|
|
57
67
|
const { role } = await dashboardSession();
|
|
58
|
-
return allowedActions(
|
|
68
|
+
return allowedActions(await effectivePolicy(resourceName), role);
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -167,7 +177,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
|
|
|
167
177
|
return stats;
|
|
168
178
|
},
|
|
169
179
|
["flare", "resource-stats"],
|
|
170
|
-
|
|
180
|
+
// TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
|
|
181
|
+
// counts, dashboards". These are those counts — and `flare seed:resource` writes
|
|
182
|
+
// from outside the app, where no tag revalidates them, so at medium the overview
|
|
183
|
+
// said "0 records" for five minutes after seeding a thousand.
|
|
184
|
+
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
171
185
|
);
|
|
172
186
|
return read(name, field, days, limit ?? undefined);
|
|
173
187
|
}
|
|
@@ -191,7 +205,8 @@ export function allResources(): Resource[] {
|
|
|
191
205
|
/** Resources the current user may read, for the sidebar and dashboard. */
|
|
192
206
|
export async function visibleResources(): Promise<Resource[]> {
|
|
193
207
|
const { role } = await dashboardSession();
|
|
194
|
-
|
|
208
|
+
const policies = await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)));
|
|
209
|
+
return allResources().filter((resource, index) => can(policies[index], role, "read"));
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
export function resourcePath(resource: Resource, ...parts: string[]) {
|
|
@@ -10,7 +10,7 @@ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from
|
|
|
10
10
|
*/
|
|
11
11
|
export const mailer = createMailer({
|
|
12
12
|
apiKey: env.RESEND_API_KEY,
|
|
13
|
-
from: env.MAIL_FROM || "
|
|
13
|
+
from: env.MAIL_FROM || "__APP_TITLE__ <onboarding@resend.dev>",
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -31,6 +31,6 @@ export async function sendTransactionalEmail({
|
|
|
31
31
|
idempotencyKey,
|
|
32
32
|
...content
|
|
33
33
|
}: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
|
|
34
|
-
const { html, text } = renderTransactionalEmail({ appName: "
|
|
34
|
+
const { html, text } = renderTransactionalEmail({ appName: "__APP_TITLE__", ...content });
|
|
35
35
|
return mailer.send({ to, subject, html, text, idempotencyKey });
|
|
36
36
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { revalidateTag } from "next/cache";
|
|
2
|
+
import { and, asc, eq } from "drizzle-orm";
|
|
3
|
+
import { withGrants, type PermissionGrant, type Policy, type PolicyAction } from "@flaredev/core";
|
|
4
|
+
import { getDb } from "@/db";
|
|
5
|
+
import { role as roleTable, rolePermission } from "@/db/flare-schema";
|
|
6
|
+
import { user } from "@/db/auth-schema";
|
|
7
|
+
import { policies } from "@/policies";
|
|
8
|
+
import { cached, TTL } from "./cache";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Permissions managed from the dashboard, on top of the policies in your code.
|
|
12
|
+
*
|
|
13
|
+
* `policies/*.policy.ts` is the floor and this only ever widens it — the reasoning is
|
|
14
|
+
* in `withGrants` (@flaredev/core), and the short version is that a row in a table must
|
|
15
|
+
* never be able to lock an app out or quietly restrict something the code left open.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is cached under one tag and revalidated on every write, so a role
|
|
18
|
+
* change takes effect on the editor's next request rather than in a minute's time.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const GRANTS_TAG = "flare/permissions";
|
|
22
|
+
|
|
23
|
+
/** The four things a role can be granted. "list" is read, so it is not one of them. */
|
|
24
|
+
export const GRANTABLE: Exclude<PolicyAction, "list">[] = ["read", "create", "update", "delete"];
|
|
25
|
+
|
|
26
|
+
const readGrants = cached(
|
|
27
|
+
async (): Promise<PermissionGrant[]> => {
|
|
28
|
+
const rows = await getDb()
|
|
29
|
+
.select({ role: rolePermission.role, resource: rolePermission.resource, action: rolePermission.action })
|
|
30
|
+
.from(rolePermission);
|
|
31
|
+
return rows as PermissionGrant[];
|
|
32
|
+
},
|
|
33
|
+
["flare", "permissions"],
|
|
34
|
+
{ tags: [GRANTS_TAG], revalidate: TTL.medium },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** Every grant in the database. */
|
|
38
|
+
export function allGrants(): Promise<PermissionGrant[]> {
|
|
39
|
+
return readGrants();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The policy in your code, before the dashboard's grants. */
|
|
43
|
+
export function codePolicy(resourceName: string): Policy | undefined {
|
|
44
|
+
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The policy actually enforced for a resource: the file, widened by the grants.
|
|
49
|
+
*
|
|
50
|
+
* Undefined when there is no policy file, which means the resource is open to every
|
|
51
|
+
* signed-in user — and grants on it do nothing. The roles screen says so.
|
|
52
|
+
*/
|
|
53
|
+
export async function effectivePolicy(resourceName: string): Promise<Policy | undefined> {
|
|
54
|
+
return withGrants(codePolicy(resourceName), resourceName, await allGrants());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Roles that exist, newest last. Registering one is what makes it offerable in the UI. */
|
|
58
|
+
export async function listRoles(): Promise<{ name: string; label: string }[]> {
|
|
59
|
+
return getDb().select({ name: roleTable.name, label: roleTable.label }).from(roleTable).orderBy(asc(roleTable.createdAt));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Roles that accounts actually hold, registered or not.
|
|
64
|
+
*
|
|
65
|
+
* `flare user:role someone@example.com auditor` is a complete way to create a role —
|
|
66
|
+
* the string on the account is the role. If this screen only listed the `role` table it
|
|
67
|
+
* would hide the ones already in use, and you could not grant anything to them. So the
|
|
68
|
+
* two lists are merged, and holding a role is enough to appear.
|
|
69
|
+
*/
|
|
70
|
+
export async function rolesInUse(): Promise<string[]> {
|
|
71
|
+
const rows = await getDb().selectDistinct({ role: user.role }).from(user);
|
|
72
|
+
return rows.map((row) => row.role).filter((role): role is string => Boolean(role));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function createRole(name: string, label: string): Promise<void> {
|
|
76
|
+
await getDb().insert(roleTable).values({ name, label }).onConflictDoUpdate({ target: roleTable.name, set: { label } });
|
|
77
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Forget a role.
|
|
82
|
+
*
|
|
83
|
+
* Its grants go with it — a grant naming a role nobody holds is noise that will outlive
|
|
84
|
+
* everyone who remembers what it was for. Accounts keep the role string they have;
|
|
85
|
+
* `can()` simply stops finding grants for it, and the policy file still decides.
|
|
86
|
+
*/
|
|
87
|
+
export async function deleteRole(name: string): Promise<void> {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
await db.delete(rolePermission).where(eq(rolePermission.role, name));
|
|
90
|
+
await db.delete(roleTable).where(eq(roleTable.name, name));
|
|
91
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Tick or untick one cell of the matrix. */
|
|
95
|
+
export async function setGrant(role: string, resource: string, action: string, granted: boolean): Promise<void> {
|
|
96
|
+
const db = getDb();
|
|
97
|
+
if (granted) {
|
|
98
|
+
await db.insert(rolePermission).values({ role, resource, action }).onConflictDoNothing();
|
|
99
|
+
} else {
|
|
100
|
+
await db
|
|
101
|
+
.delete(rolePermission)
|
|
102
|
+
.where(and(eq(rolePermission.role, role), eq(rolePermission.resource, resource), eq(rolePermission.action, action)));
|
|
103
|
+
}
|
|
104
|
+
revalidateTag(GRANTS_TAG, { expire: 0 });
|
|
105
|
+
}
|
|
@@ -5,9 +5,9 @@ import type { ThemeName } from "./theme";
|
|
|
5
5
|
* the page titles read from here. Edit freely.
|
|
6
6
|
*/
|
|
7
7
|
export const site = {
|
|
8
|
-
name: "
|
|
8
|
+
name: "__APP_TITLE__",
|
|
9
9
|
tagline: "The fastest way to run your business",
|
|
10
|
-
description: "
|
|
10
|
+
description: "__APP_TITLE__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
|
|
11
11
|
/**
|
|
12
12
|
* The look of the whole app: sign-in screens, dashboard, admin and home page.
|
|
13
13
|
* One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling "the code in this tab is out of date" apart from a real failure.
|
|
3
|
+
*
|
|
4
|
+
* A browser that has loaded one build and is handed another — you updated the app and
|
|
5
|
+
* it rebuilt, or you started a different Flare app on the same localhost port — can end
|
|
6
|
+
* up executing a module graph that no longer matches what the server is serving. The
|
|
7
|
+
* bundler says so in its own words:
|
|
8
|
+
*
|
|
9
|
+
* "... was instantiated because it was required from module <file>, but the module
|
|
10
|
+
* factory is not available."
|
|
11
|
+
*
|
|
12
|
+
* It reads like a crash in your code, and it is not: nothing is wrong with the app, the
|
|
13
|
+
* tab is simply holding stale JavaScript. It matters that the error boundary knows the
|
|
14
|
+
* difference, because the usual remedy — React's `reset()`, which re-renders the
|
|
15
|
+
* segment without a page load — cannot fix this one. The stale modules are still in
|
|
16
|
+
* memory afterwards. Only a real reload replaces them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Phrases every bundler in this family uses for "the code in this tab is out of date". */
|
|
20
|
+
const STALE_CHUNK = [
|
|
21
|
+
"module factory is not available",
|
|
22
|
+
"chunkloaderror",
|
|
23
|
+
"loading chunk",
|
|
24
|
+
"loading css chunk",
|
|
25
|
+
"failed to fetch dynamically imported module",
|
|
26
|
+
"error loading dynamically imported module",
|
|
27
|
+
"importing a module script failed",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Whether this error means the tab's JavaScript is stale rather than the app is broken. */
|
|
31
|
+
export function isStaleChunkError(error: unknown): boolean {
|
|
32
|
+
if (!error) return false;
|
|
33
|
+
const name = (error as { name?: unknown }).name;
|
|
34
|
+
if (typeof name === "string" && name.toLowerCase() === "chunkloaderror") return true;
|
|
35
|
+
const message = (error as { message?: unknown }).message;
|
|
36
|
+
if (typeof message !== "string") return false;
|
|
37
|
+
const haystack = message.toLowerCase();
|
|
38
|
+
return STALE_CHUNK.some((phrase) => haystack.includes(phrase));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reload the page, bypassing the cached modules.
|
|
43
|
+
*
|
|
44
|
+
* `location.reload()` is enough for the common case, and is what the bundler's own
|
|
45
|
+
* advice amounts to. It is called from a click handler so the browser treats it as a
|
|
46
|
+
* user-initiated navigation rather than a redirect loop.
|
|
47
|
+
*/
|
|
48
|
+
export function hardReload(): void {
|
|
49
|
+
if (typeof window !== "undefined") window.location.reload();
|
|
50
|
+
}
|
|
@@ -108,6 +108,15 @@ CREATE TABLE `role` (
|
|
|
108
108
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
|
109
109
|
);
|
|
110
110
|
--> statement-breakpoint
|
|
111
|
+
CREATE TABLE `role_permission` (
|
|
112
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
113
|
+
`role` text NOT NULL,
|
|
114
|
+
`resource` text NOT NULL,
|
|
115
|
+
`action` text NOT NULL,
|
|
116
|
+
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
|
117
|
+
);
|
|
118
|
+
--> statement-breakpoint
|
|
119
|
+
CREATE UNIQUE INDEX `role_permission_unique` ON `role_permission` (`role`,`resource`,`action`);--> statement-breakpoint
|
|
111
120
|
CREATE TABLE `saved_view` (
|
|
112
121
|
`id` text PRIMARY KEY NOT NULL,
|
|
113
122
|
`user_id` text NOT NULL,
|