@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
|
@@ -31,6 +31,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
|
|
|
31
31
|
import { Spinner } from "@/components/ui/spinner";
|
|
32
32
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
33
33
|
import { toCsv } from "@/lib/csv";
|
|
34
|
+
import { matchOption, squash, toValue } from "./cell-value";
|
|
34
35
|
import { Dropzone } from "./dropzone";
|
|
35
36
|
import { downloadCsv } from "./table-selection";
|
|
36
37
|
|
|
@@ -142,66 +143,8 @@ function parseCsv(text: string): string[][] {
|
|
|
142
143
|
|
|
143
144
|
const csvCell = (value: string) => (/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value);
|
|
144
145
|
|
|
145
|
-
const squash = (text: string) => text.toLowerCase().replace(/[\s_-]+/g, "");
|
|
146
|
-
|
|
147
|
-
/** A field must be mapped when nothing else can supply it: required, with no default. */
|
|
148
146
|
const needsColumn = (def: StoredField) => def.required && !("default" in def && def.default !== undefined);
|
|
149
147
|
|
|
150
|
-
/** An option from a cell holding either the stored value or the label shown in the admin. */
|
|
151
|
-
function matchOption(def: EnumField | MultiSelectField, text: string): string {
|
|
152
|
-
const match = def.options.find(
|
|
153
|
-
(option) => option === text || squash(option) === squash(text) || squash(optionLabel(def, option)) === squash(text),
|
|
154
|
-
);
|
|
155
|
-
return match ?? text;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* A cell as the value its field expects. Text that doesn't convert is passed through
|
|
160
|
-
* unchanged so validation reports it against the field, rather than the import guessing.
|
|
161
|
-
*/
|
|
162
|
-
function toValue(def: StoredField, raw: string): unknown {
|
|
163
|
-
const text = raw.trim();
|
|
164
|
-
if (text === "") return undefined;
|
|
165
|
-
switch (def.kind) {
|
|
166
|
-
case "int":
|
|
167
|
-
case "float": {
|
|
168
|
-
const number = Number(text);
|
|
169
|
-
return Number.isFinite(number) ? number : text;
|
|
170
|
-
}
|
|
171
|
-
case "boolean": {
|
|
172
|
-
const lower = text.toLowerCase();
|
|
173
|
-
if (["true", "yes", "1"].includes(lower)) return true;
|
|
174
|
-
if (["false", "no", "0"].includes(lower)) return false;
|
|
175
|
-
return text;
|
|
176
|
-
}
|
|
177
|
-
case "enum":
|
|
178
|
-
return matchOption(def, text);
|
|
179
|
-
case "multiselect":
|
|
180
|
-
return text
|
|
181
|
-
.split(/[;,]/)
|
|
182
|
-
.map((part) => part.trim())
|
|
183
|
-
.filter(Boolean)
|
|
184
|
-
.map((part) => matchOption(def, part));
|
|
185
|
-
case "tags":
|
|
186
|
-
// Semicolons first so a tag may contain a comma: "a, b; c" is two tags.
|
|
187
|
-
return text
|
|
188
|
-
.split(text.includes(";") ? ";" : ",")
|
|
189
|
-
.map((part) => part.trim())
|
|
190
|
-
.filter(Boolean);
|
|
191
|
-
case "json":
|
|
192
|
-
try {
|
|
193
|
-
return JSON.parse(text);
|
|
194
|
-
} catch {
|
|
195
|
-
// Handed over as typed: the validator reports it against the row and the column,
|
|
196
|
-
// which is more use than this throwing halfway through a file.
|
|
197
|
-
return text;
|
|
198
|
-
}
|
|
199
|
-
default:
|
|
200
|
-
// Dates keep their text: the descriptor's schema decides which formats it takes.
|
|
201
|
-
return text;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
148
|
/** A column per field, matched on header text; anything unrecognised starts as Skip. */
|
|
206
149
|
function guessMapping(fields: MappedField[], headers: string[]): Record<string, string> {
|
|
207
150
|
const taken = new Set<number>();
|
|
@@ -25,6 +25,7 @@ import { TrashToggle } from "./trash-toggle";
|
|
|
25
25
|
import { isInlineEditable } from "./editable";
|
|
26
26
|
import { InlineCell } from "./inline-cell";
|
|
27
27
|
import type { RelationMeta } from "./fields/field-widget";
|
|
28
|
+
import { BulkAddSheet } from "./bulk-add-sheet";
|
|
28
29
|
import { ImportDialog } from "./import-dialog";
|
|
29
30
|
import { LocalTime } from "./local-time";
|
|
30
31
|
import { hrefWith, toSearchParams, type SearchParams } from "./query";
|
|
@@ -125,6 +126,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
125
126
|
<ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
|
|
126
127
|
<ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
|
|
127
128
|
{permissions.create && <ImportDialog resource={forClient} />}
|
|
129
|
+
{permissions.create && <BulkAddSheet resource={forClient} label={resource.label} />}
|
|
128
130
|
{permissions.create &&
|
|
129
131
|
(overlayForms ? (
|
|
130
132
|
<NewRecordButton resource={forClient} relations={relations} listHref={basePath} omit={omit} />
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState, useTransition } from "react";
|
|
5
|
+
import { LockIcon, PlusIcon, ShieldIcon, Trash2Icon, UnlockIcon } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
10
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
DialogHeader,
|
|
17
|
+
DialogTitle,
|
|
18
|
+
DialogTrigger,
|
|
19
|
+
} from "@/components/ui/dialog";
|
|
20
|
+
import { Input } from "@/components/ui/input";
|
|
21
|
+
import { Label } from "@/components/ui/label";
|
|
22
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
23
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
24
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
25
|
+
import { createRoleAction, deleteRoleAction, setGrantAction } from "@/app/dashboard/roles/actions";
|
|
26
|
+
|
|
27
|
+
export interface MatrixResource {
|
|
28
|
+
name: string;
|
|
29
|
+
pluralLabel: string;
|
|
30
|
+
/** Whether a policy file restricts it. Without one, every signed-in user may use it. */
|
|
31
|
+
restricted: boolean;
|
|
32
|
+
/** Roles the policy *file* gives, per action — these are ticked and locked. */
|
|
33
|
+
fromCode: Record<string, string[]>;
|
|
34
|
+
/** Roles the dashboard has granted, per action — these are the editable ticks. */
|
|
35
|
+
granted: Record<string, string[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MatrixRole {
|
|
39
|
+
name: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ACTIONS = ["read", "create", "update", "delete"] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Who may do what, as a grid.
|
|
47
|
+
*
|
|
48
|
+
* Two kinds of tick, and the difference is the point. A **locked** tick comes from
|
|
49
|
+
* `policies/<resource>.policy.ts` and cannot be changed here — the file is the floor,
|
|
50
|
+
* and a screen that appeared to remove access the code grants would be lying. An
|
|
51
|
+
* **open** tick is a grant in the database, and is yours to set.
|
|
52
|
+
*
|
|
53
|
+
* A resource with no policy file is open to every signed-in user. Ticking boxes on it
|
|
54
|
+
* would change nothing, so the row says so and points at the command that restricts it,
|
|
55
|
+
* rather than offering controls that quietly do nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function RoleMatrix({ roles, resources, adminRoles }: { roles: MatrixRole[]; resources: MatrixResource[]; adminRoles: string[] }) {
|
|
58
|
+
const router = useRouter();
|
|
59
|
+
const [pending, startTransition] = useTransition();
|
|
60
|
+
const [selected, setSelected] = useState(roles[0]?.name ?? "");
|
|
61
|
+
const [open, setOpen] = useState(false);
|
|
62
|
+
|
|
63
|
+
const role = roles.find((candidate) => candidate.name === selected);
|
|
64
|
+
|
|
65
|
+
const run = (work: () => Promise<{ ok: boolean; error?: string }>, done?: string) =>
|
|
66
|
+
startTransition(async () => {
|
|
67
|
+
const result = await work();
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
toast.error(result.error ?? "That didn't work.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (done) toast.success(done);
|
|
73
|
+
router.refresh();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const addRole = (form: FormData) => {
|
|
77
|
+
const name = String(form.get("name") ?? "");
|
|
78
|
+
run(async () => {
|
|
79
|
+
const result = await createRoleAction({ name, label: String(form.get("label") ?? "") });
|
|
80
|
+
if (result.ok) {
|
|
81
|
+
setSelected(name.trim().toLowerCase());
|
|
82
|
+
setOpen(false);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}, "Role created.");
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex flex-col gap-6">
|
|
90
|
+
<Card>
|
|
91
|
+
<CardHeader>
|
|
92
|
+
<CardTitle className="text-base">Roles</CardTitle>
|
|
93
|
+
<CardDescription>
|
|
94
|
+
A role is a name your policies give meaning. Assign one to somebody from{" "}
|
|
95
|
+
<a href="/dashboard/users" className="underline underline-offset-2">
|
|
96
|
+
Users
|
|
97
|
+
</a>
|
|
98
|
+
.
|
|
99
|
+
</CardDescription>
|
|
100
|
+
</CardHeader>
|
|
101
|
+
<CardContent className="flex flex-wrap items-center gap-2">
|
|
102
|
+
{roles.map((candidate) => (
|
|
103
|
+
<Button
|
|
104
|
+
key={candidate.name}
|
|
105
|
+
variant={candidate.name === selected ? "default" : "outline"}
|
|
106
|
+
size="sm"
|
|
107
|
+
onClick={() => setSelected(candidate.name)}
|
|
108
|
+
>
|
|
109
|
+
<ShieldIcon data-icon="inline-start" />
|
|
110
|
+
{candidate.label}
|
|
111
|
+
</Button>
|
|
112
|
+
))}
|
|
113
|
+
|
|
114
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
115
|
+
<DialogTrigger asChild>
|
|
116
|
+
<Button variant="outline" size="sm">
|
|
117
|
+
<PlusIcon data-icon="inline-start" />
|
|
118
|
+
New role
|
|
119
|
+
</Button>
|
|
120
|
+
</DialogTrigger>
|
|
121
|
+
<DialogContent className="sm:max-w-sm">
|
|
122
|
+
<DialogHeader>
|
|
123
|
+
<DialogTitle>New role</DialogTitle>
|
|
124
|
+
<DialogDescription>It exists as soon as you name it; what it may do is the grid below.</DialogDescription>
|
|
125
|
+
</DialogHeader>
|
|
126
|
+
<form action={addRole} className="flex flex-col gap-4">
|
|
127
|
+
<div className="flex flex-col gap-2">
|
|
128
|
+
<Label htmlFor="role-name">Name</Label>
|
|
129
|
+
<Input id="role-name" name="name" placeholder="support-agent" required autoComplete="off" />
|
|
130
|
+
<p className="text-xs text-muted-foreground">Lowercase letters, digits, _ or -. This is what `user.role` holds.</p>
|
|
131
|
+
</div>
|
|
132
|
+
<div className="flex flex-col gap-2">
|
|
133
|
+
<Label htmlFor="role-label">Label</Label>
|
|
134
|
+
<Input id="role-label" name="label" placeholder="Support agent" autoComplete="off" />
|
|
135
|
+
</div>
|
|
136
|
+
<DialogFooter>
|
|
137
|
+
<Button type="submit" disabled={pending}>
|
|
138
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
139
|
+
Create role
|
|
140
|
+
</Button>
|
|
141
|
+
</DialogFooter>
|
|
142
|
+
</form>
|
|
143
|
+
</DialogContent>
|
|
144
|
+
</Dialog>
|
|
145
|
+
|
|
146
|
+
{role && !adminRoles.includes(role.name) && (
|
|
147
|
+
<Button
|
|
148
|
+
variant="ghost"
|
|
149
|
+
size="sm"
|
|
150
|
+
className="ml-auto text-destructive"
|
|
151
|
+
disabled={pending}
|
|
152
|
+
onClick={() =>
|
|
153
|
+
run(async () => {
|
|
154
|
+
const result = await deleteRoleAction(role.name);
|
|
155
|
+
if (result.ok) setSelected(roles.find((other) => other.name !== role.name)?.name ?? "");
|
|
156
|
+
return result;
|
|
157
|
+
}, `Deleted ${role.label}.`)
|
|
158
|
+
}
|
|
159
|
+
>
|
|
160
|
+
<Trash2Icon data-icon="inline-start" />
|
|
161
|
+
Delete {role.label}
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
|
|
167
|
+
{role && (
|
|
168
|
+
<Card>
|
|
169
|
+
<CardHeader>
|
|
170
|
+
<CardTitle className="text-base">What {role.label} may do</CardTitle>
|
|
171
|
+
<CardDescription>
|
|
172
|
+
A locked tick comes from the resource's policy file and can only be changed there. Everything else is yours.
|
|
173
|
+
</CardDescription>
|
|
174
|
+
</CardHeader>
|
|
175
|
+
<CardContent>
|
|
176
|
+
<div className="overflow-x-auto">
|
|
177
|
+
<Table>
|
|
178
|
+
<TableHeader>
|
|
179
|
+
<TableRow>
|
|
180
|
+
<TableHead>Resource</TableHead>
|
|
181
|
+
{ACTIONS.map((action) => (
|
|
182
|
+
<TableHead key={action} className="w-24 text-center capitalize">
|
|
183
|
+
{action}
|
|
184
|
+
</TableHead>
|
|
185
|
+
))}
|
|
186
|
+
</TableRow>
|
|
187
|
+
</TableHeader>
|
|
188
|
+
<TableBody>
|
|
189
|
+
{resources.map((resource) => (
|
|
190
|
+
<TableRow key={resource.name}>
|
|
191
|
+
<TableCell>
|
|
192
|
+
<div className="flex flex-col">
|
|
193
|
+
<span className="font-medium">{resource.pluralLabel}</span>
|
|
194
|
+
{!resource.restricted && (
|
|
195
|
+
<span className="text-xs text-muted-foreground">
|
|
196
|
+
Open to everyone signed in — <code>flare gen policy {resource.name} --roles admin</code> to restrict it.
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</TableCell>
|
|
201
|
+
{ACTIONS.map((action) => {
|
|
202
|
+
const inCode = (resource.fromCode[action] ?? []).some((named) => named === role.name || named === "*");
|
|
203
|
+
const isGranted = (resource.granted[action] ?? []).includes(role.name);
|
|
204
|
+
if (!resource.restricted) {
|
|
205
|
+
return (
|
|
206
|
+
<TableCell key={action} className="text-center">
|
|
207
|
+
<Tooltip>
|
|
208
|
+
<TooltipTrigger asChild>
|
|
209
|
+
<span className="inline-flex text-muted-foreground">
|
|
210
|
+
<UnlockIcon className="size-4" aria-label="No policy: everyone signed in may do this" />
|
|
211
|
+
</span>
|
|
212
|
+
</TooltipTrigger>
|
|
213
|
+
<TooltipContent>This resource has no policy, so every signed-in user may do this.</TooltipContent>
|
|
214
|
+
</Tooltip>
|
|
215
|
+
</TableCell>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (inCode) {
|
|
219
|
+
return (
|
|
220
|
+
<TableCell key={action} className="text-center">
|
|
221
|
+
<Tooltip>
|
|
222
|
+
<TooltipTrigger asChild>
|
|
223
|
+
<span className="inline-flex text-success">
|
|
224
|
+
<LockIcon className="size-4" aria-label="Granted by the policy file" />
|
|
225
|
+
</span>
|
|
226
|
+
</TooltipTrigger>
|
|
227
|
+
<TooltipContent>
|
|
228
|
+
policies/{resource.name.toLowerCase()}.policy.ts grants this. Change it there.
|
|
229
|
+
</TooltipContent>
|
|
230
|
+
</Tooltip>
|
|
231
|
+
</TableCell>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return (
|
|
235
|
+
<TableCell key={action} className="text-center">
|
|
236
|
+
<Checkbox
|
|
237
|
+
checked={isGranted}
|
|
238
|
+
disabled={pending}
|
|
239
|
+
aria-label={`${role.label} may ${action} ${resource.pluralLabel.toLowerCase()}`}
|
|
240
|
+
onCheckedChange={(next) =>
|
|
241
|
+
run(() =>
|
|
242
|
+
setGrantAction({ role: role.name, resource: resource.name, action, granted: next === true }),
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
/>
|
|
246
|
+
</TableCell>
|
|
247
|
+
);
|
|
248
|
+
})}
|
|
249
|
+
</TableRow>
|
|
250
|
+
))}
|
|
251
|
+
</TableBody>
|
|
252
|
+
</Table>
|
|
253
|
+
</div>
|
|
254
|
+
<p className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
255
|
+
<span className="inline-flex items-center gap-1.5">
|
|
256
|
+
<LockIcon className="size-3.5 text-success" /> from the policy file
|
|
257
|
+
</span>
|
|
258
|
+
<span className="inline-flex items-center gap-1.5">
|
|
259
|
+
<UnlockIcon className="size-3.5" /> no policy — open to everyone signed in
|
|
260
|
+
</span>
|
|
261
|
+
<Badge variant="secondary">Grants widen a policy; they never narrow one.</Badge>
|
|
262
|
+
</p>
|
|
263
|
+
</CardContent>
|
|
264
|
+
</Card>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState, useTransition } from "react";
|
|
5
|
+
import { BanIcon, MoreHorizontalIcon, ShieldIcon, UserPlusIcon } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
DialogTrigger,
|
|
16
|
+
} from "@/components/ui/dialog";
|
|
17
|
+
import {
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuGroup,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuLabel,
|
|
23
|
+
DropdownMenuSeparator,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from "@/components/ui/dropdown-menu";
|
|
26
|
+
import { Combobox } from "@/components/ui/combobox";
|
|
27
|
+
import { Input } from "@/components/ui/input";
|
|
28
|
+
import { Label } from "@/components/ui/label";
|
|
29
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
30
|
+
import { createUserAction, setBannedAction, setRoleAction, type UserActionResult } from "@/app/dashboard/users/actions";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The management side of the users page: make an account, change a role, ban.
|
|
34
|
+
*
|
|
35
|
+
* Everything here calls the same Better Auth admin API the CLI uses, so an account made
|
|
36
|
+
* from this dialog and one made with `flare user:add` are indistinguishable.
|
|
37
|
+
*
|
|
38
|
+
* The well-known roles get one-click entries; anything else is typed, because a role is
|
|
39
|
+
* just a string your policies name — inventing one here is how you "add a role".
|
|
40
|
+
*/
|
|
41
|
+
const KNOWN_ROLES = ["admin", "staff", "user"];
|
|
42
|
+
|
|
43
|
+
export function CreateUserButton() {
|
|
44
|
+
const router = useRouter();
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
const [role, setRole] = useState("user");
|
|
47
|
+
const [pending, startTransition] = useTransition();
|
|
48
|
+
|
|
49
|
+
const submit = (form: FormData) => {
|
|
50
|
+
startTransition(async () => {
|
|
51
|
+
const result = await createUserAction({
|
|
52
|
+
name: String(form.get("name") ?? ""),
|
|
53
|
+
email: String(form.get("email") ?? ""),
|
|
54
|
+
password: String(form.get("password") ?? ""),
|
|
55
|
+
role: String(form.get("role") ?? "user"),
|
|
56
|
+
});
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
toast.error(result.error);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
toast.success("Account created. They can sign in now.");
|
|
62
|
+
setOpen(false);
|
|
63
|
+
router.refresh();
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
69
|
+
<DialogTrigger asChild>
|
|
70
|
+
<Button>
|
|
71
|
+
<UserPlusIcon data-icon="inline-start" />
|
|
72
|
+
New user
|
|
73
|
+
</Button>
|
|
74
|
+
</DialogTrigger>
|
|
75
|
+
<DialogContent className="sm:max-w-md">
|
|
76
|
+
<DialogHeader>
|
|
77
|
+
<DialogTitle>New user</DialogTitle>
|
|
78
|
+
<DialogDescription>Usable immediately — send them the password and have them change it.</DialogDescription>
|
|
79
|
+
</DialogHeader>
|
|
80
|
+
<form action={submit} className="flex flex-col gap-4">
|
|
81
|
+
<div className="flex flex-col gap-2">
|
|
82
|
+
<Label htmlFor="new-user-name">Name</Label>
|
|
83
|
+
<Input id="new-user-name" name="name" required autoComplete="off" />
|
|
84
|
+
</div>
|
|
85
|
+
<div className="flex flex-col gap-2">
|
|
86
|
+
<Label htmlFor="new-user-email">Email</Label>
|
|
87
|
+
<Input id="new-user-email" name="email" type="email" required autoComplete="off" />
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex flex-col gap-2">
|
|
90
|
+
<Label htmlFor="new-user-password">Password</Label>
|
|
91
|
+
<Input id="new-user-password" name="password" type="text" required minLength={8} autoComplete="off" />
|
|
92
|
+
</div>
|
|
93
|
+
<div className="flex flex-col gap-2">
|
|
94
|
+
<Label htmlFor="new-user-role">Role</Label>
|
|
95
|
+
{/* A combobox rather than a datalist: browsers render datalists
|
|
96
|
+
inconsistently, and the arrow on one that does nothing reads as a broken
|
|
97
|
+
control. allowCustom because a role is a string your policies name — the
|
|
98
|
+
list is a convenience, not the set of legal answers. */}
|
|
99
|
+
<Combobox
|
|
100
|
+
id="new-user-role"
|
|
101
|
+
name="role"
|
|
102
|
+
value={role}
|
|
103
|
+
onChange={setRole}
|
|
104
|
+
options={KNOWN_ROLES.map((option) => ({ value: option, label: option }))}
|
|
105
|
+
placeholder="Choose a role"
|
|
106
|
+
searchPlaceholder="Search or type a new role…"
|
|
107
|
+
allowCustom
|
|
108
|
+
customLabel={(typed) => `Create the role "${typed}"`}
|
|
109
|
+
/>
|
|
110
|
+
<p className="text-xs text-muted-foreground">Type anything: a new role exists the moment a policy names it.</p>
|
|
111
|
+
</div>
|
|
112
|
+
<DialogFooter>
|
|
113
|
+
<Button type="submit" disabled={pending}>
|
|
114
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
115
|
+
Create account
|
|
116
|
+
</Button>
|
|
117
|
+
</DialogFooter>
|
|
118
|
+
</form>
|
|
119
|
+
</DialogContent>
|
|
120
|
+
</Dialog>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function UserRowMenu({ userId, role, banned }: { userId: string; role: string; banned: boolean }) {
|
|
125
|
+
const router = useRouter();
|
|
126
|
+
const [pending, startTransition] = useTransition();
|
|
127
|
+
const [picking, setPicking] = useState(false);
|
|
128
|
+
const [draft, setDraft] = useState(role);
|
|
129
|
+
|
|
130
|
+
const run = (work: () => Promise<UserActionResult>, done: string) => {
|
|
131
|
+
startTransition(async () => {
|
|
132
|
+
const result = await work();
|
|
133
|
+
if (!result.ok) {
|
|
134
|
+
toast.error(result.error);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
toast.success(done);
|
|
138
|
+
router.refresh();
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const setRole = (next: string) => run(() => setRoleAction({ userId, role: next }), `Role is now ${next}.`);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<DropdownMenu>
|
|
146
|
+
<DropdownMenuTrigger asChild>
|
|
147
|
+
<Button variant="ghost" size="icon" aria-label="Manage this account" disabled={pending}>
|
|
148
|
+
{pending ? <Spinner /> : <MoreHorizontalIcon />}
|
|
149
|
+
</Button>
|
|
150
|
+
</DropdownMenuTrigger>
|
|
151
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
152
|
+
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
|
153
|
+
<ShieldIcon className="size-3.5" />
|
|
154
|
+
Role
|
|
155
|
+
</DropdownMenuLabel>
|
|
156
|
+
<DropdownMenuGroup>
|
|
157
|
+
{KNOWN_ROLES.map((option) => (
|
|
158
|
+
<DropdownMenuItem key={option} disabled={option === role} onSelect={() => setRole(option)}>
|
|
159
|
+
{option}
|
|
160
|
+
</DropdownMenuItem>
|
|
161
|
+
))}
|
|
162
|
+
{/* A dialog with the same combobox, not window.prompt: a browser prompt
|
|
163
|
+
cannot show the roles that already exist, and is the one piece of UI in
|
|
164
|
+
the app that looks like it belongs to 1998. */}
|
|
165
|
+
<DropdownMenuItem
|
|
166
|
+
onSelect={(event) => {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
setDraft(role);
|
|
169
|
+
setPicking(true);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
Something else…
|
|
173
|
+
</DropdownMenuItem>
|
|
174
|
+
</DropdownMenuGroup>
|
|
175
|
+
<DropdownMenuSeparator />
|
|
176
|
+
<DropdownMenuGroup>
|
|
177
|
+
<DropdownMenuItem
|
|
178
|
+
variant={banned ? undefined : "destructive"}
|
|
179
|
+
onSelect={() => run(() => setBannedAction({ userId, banned: !banned }), banned ? "Unbanned — they can sign in again." : "Banned. Their data stays; their way in doesn't.")}
|
|
180
|
+
>
|
|
181
|
+
<BanIcon />
|
|
182
|
+
{banned ? "Unban" : "Ban"}
|
|
183
|
+
</DropdownMenuItem>
|
|
184
|
+
</DropdownMenuGroup>
|
|
185
|
+
</DropdownMenuContent>
|
|
186
|
+
|
|
187
|
+
<Dialog open={picking} onOpenChange={setPicking}>
|
|
188
|
+
<DialogContent className="sm:max-w-sm">
|
|
189
|
+
<DialogHeader>
|
|
190
|
+
<DialogTitle>Change role</DialogTitle>
|
|
191
|
+
<DialogDescription>Pick one that exists, or type a new one — a role exists the moment a policy names it.</DialogDescription>
|
|
192
|
+
</DialogHeader>
|
|
193
|
+
<Combobox
|
|
194
|
+
value={draft}
|
|
195
|
+
onChange={setDraft}
|
|
196
|
+
options={KNOWN_ROLES.map((option) => ({ value: option, label: option }))}
|
|
197
|
+
placeholder="Choose a role"
|
|
198
|
+
searchPlaceholder="Search or type a new role…"
|
|
199
|
+
allowCustom
|
|
200
|
+
customLabel={(typed) => `Create the role "${typed}"`}
|
|
201
|
+
/>
|
|
202
|
+
<DialogFooter>
|
|
203
|
+
<Button
|
|
204
|
+
disabled={pending || !draft.trim() || draft === role}
|
|
205
|
+
onClick={() => {
|
|
206
|
+
setPicking(false);
|
|
207
|
+
setRole(draft.trim());
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
Change role
|
|
211
|
+
</Button>
|
|
212
|
+
</DialogFooter>
|
|
213
|
+
</DialogContent>
|
|
214
|
+
</Dialog>
|
|
215
|
+
</DropdownMenu>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import {
|
|
4
|
+
import { LogOutIcon, ShieldIcon, UserCircleIcon } from "lucide-react";
|
|
5
5
|
import { authClient } from "@/lib/auth-client";
|
|
6
6
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
7
7
|
import {
|
|
@@ -36,8 +36,12 @@ export function UserAvatar({ user, className }: { user: DashboardUser; className
|
|
|
36
36
|
/**
|
|
37
37
|
* What's inside the account menu, wherever it's opened from — the header's avatar and
|
|
38
38
|
* the sidebar's footer both show this, so there's one list of account actions, not two.
|
|
39
|
+
*
|
|
40
|
+
* Three entries, not a sitemap. Password, security and devices are tabs inside Account
|
|
41
|
+
* settings, so linking each of them here was the same door four times. "Manage roles"
|
|
42
|
+
* appears for the people who can use it.
|
|
39
43
|
*/
|
|
40
|
-
export function UserMenuItems({ user }: { user: DashboardUser }) {
|
|
44
|
+
export function UserMenuItems({ user, isAdmin = false }: { user: DashboardUser; isAdmin?: boolean }) {
|
|
41
45
|
const signOut = async () => {
|
|
42
46
|
await authClient.signOut();
|
|
43
47
|
window.location.assign("/sign-in");
|
|
@@ -56,34 +60,24 @@ export function UserMenuItems({ user }: { user: DashboardUser }) {
|
|
|
56
60
|
<DropdownMenuGroup>
|
|
57
61
|
<DropdownMenuItem asChild>
|
|
58
62
|
<Link href="/dashboard/account">
|
|
59
|
-
<
|
|
60
|
-
|
|
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
|
|
63
|
+
<UserCircleIcon />
|
|
64
|
+
Account settings
|
|
79
65
|
</Link>
|
|
80
66
|
</DropdownMenuItem>
|
|
67
|
+
{isAdmin && (
|
|
68
|
+
<DropdownMenuItem asChild>
|
|
69
|
+
<Link href="/dashboard/roles">
|
|
70
|
+
<ShieldIcon />
|
|
71
|
+
Manage roles
|
|
72
|
+
</Link>
|
|
73
|
+
</DropdownMenuItem>
|
|
74
|
+
)}
|
|
81
75
|
</DropdownMenuGroup>
|
|
82
76
|
<DropdownMenuSeparator />
|
|
83
77
|
<DropdownMenuGroup>
|
|
84
78
|
<DropdownMenuItem variant="destructive" onSelect={signOut}>
|
|
85
79
|
<LogOutIcon />
|
|
86
|
-
|
|
80
|
+
Log out
|
|
87
81
|
</DropdownMenuItem>
|
|
88
82
|
</DropdownMenuGroup>
|
|
89
83
|
</>
|