@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
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
-
import { count, eq } from "drizzle-orm";
|
|
3
|
-
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
|
|
2
|
+
import { count, eq, gte } from "drizzle-orm";
|
|
3
|
+
import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon, UsersIcon } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
6
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
8
7
|
import { resourceIcon } from "@/components/dashboard/resource-icon";
|
|
9
8
|
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
10
9
|
import { getDb } from "@/db";
|
|
11
|
-
import { passkey } from "@/db/auth-schema";
|
|
10
|
+
import { passkey, user as userTable } from "@/db/auth-schema";
|
|
12
11
|
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
13
|
-
import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
12
|
+
import { ADMIN_ROLES, adminPermissions, dashboardSession, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
14
13
|
import { requireSession } from "@/lib/session";
|
|
15
14
|
|
|
16
15
|
export const metadata = { title: "Dashboard" };
|
|
17
16
|
|
|
17
|
+
/** The three commands that turn an empty app into one with data, with the stack's real steps. */
|
|
18
|
+
const FIRST_RESOURCE_STEPS = [
|
|
19
|
+
{ command: "npx flare gen resource Contact --fields 'name:string, email:email!'", note: "the table, API and admin screens" },
|
|
20
|
+
{ command: "npx flare migrate", note: "create its table" },
|
|
21
|
+
{ command: "npx flare seed:resource Contact 1000", note: "fill it with sample rows" },
|
|
22
|
+
];
|
|
23
|
+
|
|
18
24
|
/** The first thing you see when you sign in: how much data there is, and what's left to set up. */
|
|
19
25
|
export default async function DashboardPage() {
|
|
20
26
|
const { user } = await requireSession("/dashboard");
|
|
27
|
+
const { role } = await dashboardSession();
|
|
28
|
+
const isAdmin = role !== null && ADMIN_ROLES.includes(role);
|
|
21
29
|
const resources = await visibleResources();
|
|
30
|
+
const week = new Date(Date.now() - 7 * 86_400_000);
|
|
22
31
|
|
|
23
32
|
const cards = await Promise.all(
|
|
24
33
|
resources.map(async (resource) => {
|
|
@@ -28,16 +37,46 @@ export default async function DashboardPage() {
|
|
|
28
37
|
}),
|
|
29
38
|
);
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// The one table every app has from its first sign-in. Admin only, because it links to
|
|
41
|
+
// the accounts page, which is.
|
|
42
|
+
const db = getDb();
|
|
43
|
+
const [people, peopleThisWeek] = isAdmin
|
|
44
|
+
? await Promise.all([
|
|
45
|
+
db
|
|
46
|
+
.select({ n: count() })
|
|
47
|
+
.from(userTable)
|
|
48
|
+
.then(([row]) => row?.n ?? 0),
|
|
49
|
+
db
|
|
50
|
+
.select({ n: count() })
|
|
51
|
+
.from(userTable)
|
|
52
|
+
.where(gte(userTable.createdAt, week))
|
|
53
|
+
.then(([row]) => row?.n ?? 0),
|
|
54
|
+
])
|
|
55
|
+
: [0, 0];
|
|
56
|
+
|
|
57
|
+
const stats: Stat[] = [
|
|
58
|
+
...(isAdmin
|
|
59
|
+
? [
|
|
60
|
+
{
|
|
61
|
+
label: "Users",
|
|
62
|
+
value: people,
|
|
63
|
+
hint: peopleThisWeek > 0 ? `${peopleThisWeek.toLocaleString()} this week` : "none this week",
|
|
64
|
+
icon: UsersIcon,
|
|
65
|
+
href: "/dashboard/users",
|
|
66
|
+
} satisfies Stat,
|
|
67
|
+
]
|
|
68
|
+
: []),
|
|
69
|
+
...cards.slice(0, isAdmin ? 3 : 4).map(({ resource, total, recent }) => ({
|
|
70
|
+
label: resource.pluralLabel,
|
|
71
|
+
value: total,
|
|
72
|
+
hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
|
|
73
|
+
change: trend(recent.current, recent.previous),
|
|
74
|
+
icon: resourceIcon(resource.icon),
|
|
75
|
+
href: resourcePath(resource),
|
|
76
|
+
})),
|
|
77
|
+
];
|
|
39
78
|
|
|
40
|
-
const [{ passkeys } = { passkeys: 0 }] = await
|
|
79
|
+
const [{ passkeys } = { passkeys: 0 }] = await db.select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
|
|
41
80
|
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
42
81
|
const steps = [
|
|
43
82
|
{ done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
|
|
@@ -50,31 +89,67 @@ export default async function DashboardPage() {
|
|
|
50
89
|
<>
|
|
51
90
|
<PageHeader
|
|
52
91
|
title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
|
|
53
|
-
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource
|
|
92
|
+
description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource and this page fills in."}
|
|
54
93
|
/>
|
|
55
94
|
|
|
56
95
|
<StatCards stats={stats} />
|
|
57
96
|
|
|
58
97
|
{resources.length === 0 && (
|
|
59
|
-
<
|
|
60
|
-
<
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
</
|
|
66
|
-
</
|
|
67
|
-
|
|
98
|
+
<Card>
|
|
99
|
+
<CardHeader>
|
|
100
|
+
<CardTitle className="text-base">Add your first resource</CardTitle>
|
|
101
|
+
<CardDescription>
|
|
102
|
+
A resource is a table, its API and its admin screens, generated from one line. Three commands from a terminal in this
|
|
103
|
+
app:
|
|
104
|
+
</CardDescription>
|
|
105
|
+
</CardHeader>
|
|
106
|
+
<CardContent className="flex flex-col">
|
|
107
|
+
{FIRST_RESOURCE_STEPS.map((step, index) => (
|
|
108
|
+
<div key={step.command} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b py-3 last:border-b-0 last:pb-0">
|
|
109
|
+
<span className="grid size-6 shrink-0 translate-y-0.5 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
|
110
|
+
{index + 1}
|
|
111
|
+
</span>
|
|
112
|
+
<code className="rounded bg-muted px-2 py-1 font-mono text-[13px]">{step.command}</code>
|
|
113
|
+
<span className="text-xs text-muted-foreground">{step.note}</span>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
117
|
+
Then refresh this page: the sidebar, these cards and the admin screens are already wired to it.
|
|
118
|
+
</p>
|
|
119
|
+
</CardContent>
|
|
120
|
+
</Card>
|
|
68
121
|
)}
|
|
69
122
|
|
|
70
123
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
71
|
-
{cards.length > 0 && (
|
|
124
|
+
{(cards.length > 0 || isAdmin) && (
|
|
72
125
|
<Card>
|
|
73
126
|
<CardHeader>
|
|
74
127
|
<CardTitle className="text-base">Your data</CardTitle>
|
|
75
|
-
<CardDescription>Every
|
|
128
|
+
<CardDescription>Every table in the app, and who it belongs to.</CardDescription>
|
|
76
129
|
</CardHeader>
|
|
77
130
|
<CardContent className="flex flex-col">
|
|
131
|
+
{isAdmin && (
|
|
132
|
+
<div className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
|
|
133
|
+
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
|
134
|
+
<UsersIcon className="size-4.5" />
|
|
135
|
+
</span>
|
|
136
|
+
<div className="flex min-w-0 flex-col">
|
|
137
|
+
<Link href="/dashboard/users" className="truncate text-sm font-medium hover:underline">
|
|
138
|
+
Users
|
|
139
|
+
</Link>
|
|
140
|
+
<span className="text-xs text-muted-foreground">
|
|
141
|
+
{people.toLocaleString()} {people === 1 ? "account" : "accounts"}
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="ml-auto flex items-center gap-1">
|
|
145
|
+
<Button variant="ghost" size="icon" aria-label="Open users" asChild>
|
|
146
|
+
<Link href="/dashboard/users">
|
|
147
|
+
<ArrowRightIcon />
|
|
148
|
+
</Link>
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
78
153
|
{cards.map(({ resource, total, permissions }) => {
|
|
79
154
|
const Icon = resourceIcon(resource.icon);
|
|
80
155
|
return (
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { createRole, deleteRole, setGrant } from "@/lib/permissions";
|
|
5
|
+
import { ADMIN_ROLES, dashboardSession } from "@/lib/dashboard";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Changing who may do what.
|
|
9
|
+
*
|
|
10
|
+
* Admin only, and deliberately not policy-driven: these actions decide what the
|
|
11
|
+
* policies themselves allow, so gating them on a policy would be circular. ADMIN_ROLES
|
|
12
|
+
* (lib/dashboard.ts) is the one list that governs the app's own settings.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type RoleActionResult = { ok: true } | { ok: false; error: string };
|
|
16
|
+
|
|
17
|
+
const ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
18
|
+
const ACTIONS = new Set(["read", "create", "update", "delete"]);
|
|
19
|
+
|
|
20
|
+
async function requireAdmin(): Promise<RoleActionResult | undefined> {
|
|
21
|
+
const { session, role } = await dashboardSession();
|
|
22
|
+
if (!session) return { ok: false, error: "You're signed out." };
|
|
23
|
+
if (!role || !ADMIN_ROLES.includes(role)) return { ok: false, error: "Only an admin can change roles." };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function createRoleAction(input: { name: string; label: string }): Promise<RoleActionResult> {
|
|
27
|
+
const denied = await requireAdmin();
|
|
28
|
+
if (denied) return denied;
|
|
29
|
+
const name = input.name.trim().toLowerCase();
|
|
30
|
+
if (!ROLE_NAME.test(name)) {
|
|
31
|
+
return { ok: false, error: "A role is lowercase letters, digits, _ or -, starting with a letter — e.g. support-agent." };
|
|
32
|
+
}
|
|
33
|
+
await createRole(name, input.label.trim() || name);
|
|
34
|
+
revalidatePath("/dashboard/roles");
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function deleteRoleAction(name: string): Promise<RoleActionResult> {
|
|
39
|
+
const denied = await requireAdmin();
|
|
40
|
+
if (denied) return denied;
|
|
41
|
+
// Not admin: the role this screen is gated on cannot be removed from this screen,
|
|
42
|
+
// which is the one mistake here that cannot be undone from the dashboard.
|
|
43
|
+
if (ADMIN_ROLES.includes(name)) return { ok: false, error: `"${name}" runs this screen, so it can't be deleted here.` };
|
|
44
|
+
await deleteRole(name);
|
|
45
|
+
revalidatePath("/dashboard/roles");
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function setGrantAction(input: { role: string; resource: string; action: string; granted: boolean }): Promise<RoleActionResult> {
|
|
50
|
+
const denied = await requireAdmin();
|
|
51
|
+
if (denied) return denied;
|
|
52
|
+
if (!ROLE_NAME.test(input.role)) return { ok: false, error: `"${input.role}" is not a role name.` };
|
|
53
|
+
if (!ACTIONS.has(input.action)) return { ok: false, error: `"${input.action}" is not an action.` };
|
|
54
|
+
await setGrant(input.role, input.resource, input.action, input.granted);
|
|
55
|
+
revalidatePath("/dashboard/roles");
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { grantIndex } from "@flaredev/core";
|
|
3
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
4
|
+
import { RoleMatrix, type MatrixResource } from "@/components/dashboard/role-matrix";
|
|
5
|
+
import { ADMIN_ROLES, allResources, dashboardSession } from "@/lib/dashboard";
|
|
6
|
+
import { allGrants, codePolicy, GRANTABLE, listRoles, rolesInUse } from "@/lib/permissions";
|
|
7
|
+
|
|
8
|
+
export const metadata = { title: "Roles & permissions" };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Who may do what, per role.
|
|
12
|
+
*
|
|
13
|
+
* Admin only, and not governed by a resource policy: this screen decides what the
|
|
14
|
+
* policies allow, so gating it on one would be circular. It reads the policy files and
|
|
15
|
+
* the grants together, and shows which ticks came from which — a tick from a file is
|
|
16
|
+
* locked, because the file is the floor and the dashboard only widens it.
|
|
17
|
+
*/
|
|
18
|
+
export default async function RolesPage() {
|
|
19
|
+
const { session, role } = await dashboardSession();
|
|
20
|
+
if (!session) redirect("/sign-in?next=/dashboard/roles");
|
|
21
|
+
if (!role || !ADMIN_ROLES.includes(role)) redirect("/dashboard");
|
|
22
|
+
|
|
23
|
+
const [roles, grants, inUse] = await Promise.all([listRoles(), allGrants(), rolesInUse()]);
|
|
24
|
+
const index = grantIndex(grants);
|
|
25
|
+
|
|
26
|
+
// Every role an account could hold, not only the registered ones: the admin plugin's
|
|
27
|
+
// default is "user", and a role assigned with `flare user:role` before anybody opened
|
|
28
|
+
// this screen exists whether or not it was registered here. Showing it is how you
|
|
29
|
+
// discover you have one.
|
|
30
|
+
const known = new Map(roles.map((entry) => [entry.name, entry]));
|
|
31
|
+
for (const name of [...ADMIN_ROLES, "user", ...inUse]) {
|
|
32
|
+
if (!known.has(name)) known.set(name, { name, label: name });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const resources: MatrixResource[] = allResources().map((resource) => {
|
|
36
|
+
const policy = codePolicy(resource.name);
|
|
37
|
+
const fromCode: Record<string, string[]> = {};
|
|
38
|
+
const granted: Record<string, string[]> = {};
|
|
39
|
+
for (const action of GRANTABLE) {
|
|
40
|
+
fromCode[action] = policy ? policy[action] : [];
|
|
41
|
+
granted[action] = [...(index.get(`${resource.name}.${action}`) ?? [])];
|
|
42
|
+
}
|
|
43
|
+
return { name: resource.name, pluralLabel: resource.pluralLabel, restricted: Boolean(policy), fromCode, granted };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<PageHeader
|
|
49
|
+
title="Roles & permissions"
|
|
50
|
+
description="What each role may do, per resource. Policy files set the floor; what you tick here widens it."
|
|
51
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Roles & permissions" }]}
|
|
52
|
+
/>
|
|
53
|
+
<RoleMatrix roles={[...known.values()]} resources={resources} adminRoles={ADMIN_ROLES} />
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
import { revalidatePath } from "next/cache";
|
|
5
|
+
import { auth } from "@/lib/auth";
|
|
6
|
+
import { dashboardSession } from "@/lib/dashboard";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Managing accounts, through Better Auth's admin API.
|
|
10
|
+
*
|
|
11
|
+
* The same `admin` plugin that gives users a role also enforces who may call these:
|
|
12
|
+
* only roles in its `adminRoles` (see lib/auth.ts — "admin" out of the box). The check
|
|
13
|
+
* here is only for a clean sentence; the API would refuse anyway, which means these
|
|
14
|
+
* actions are exactly as powerful as the `flare user:add` / `flare user:role` commands
|
|
15
|
+
* and no more.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type UserActionResult = { ok: true } | { ok: false; error: string };
|
|
19
|
+
|
|
20
|
+
function failed(error: unknown): UserActionResult {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
// Better Auth's refusal for a non-admin caller, said in this page's words.
|
|
23
|
+
if (/not.*(allowed|permitted)|unauthorized|forbidden/i.test(message)) {
|
|
24
|
+
return { ok: false, error: "Only an admin can manage accounts." };
|
|
25
|
+
}
|
|
26
|
+
return { ok: false, error: message || "That didn't work." };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function signedIn(): Promise<UserActionResult | undefined> {
|
|
30
|
+
const { session } = await dashboardSession();
|
|
31
|
+
if (!session) return { ok: false, error: "You're signed out." };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Make an account, exactly as `flare user:add` would: usable immediately. */
|
|
35
|
+
export async function createUserAction(input: { name: string; email: string; password: string; role: string }): Promise<UserActionResult> {
|
|
36
|
+
const out = await signedIn();
|
|
37
|
+
if (out) return out;
|
|
38
|
+
const role = input.role.trim() || "user";
|
|
39
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(role)) {
|
|
40
|
+
return { ok: false, error: "A role is lowercase letters, digits, _ or -, e.g. admin or support-agent." };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await auth.api.createUser({
|
|
44
|
+
body: { name: input.name.trim(), email: input.email.trim(), password: input.password, role: role as "user" },
|
|
45
|
+
headers: await headers(),
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return failed(error);
|
|
49
|
+
}
|
|
50
|
+
revalidatePath("/dashboard/users");
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Change what someone is allowed to do. Takes effect on their next request. */
|
|
55
|
+
export async function setRoleAction(input: { userId: string; role: string }): Promise<UserActionResult> {
|
|
56
|
+
const out = await signedIn();
|
|
57
|
+
if (out) return out;
|
|
58
|
+
const role = input.role.trim();
|
|
59
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(role)) {
|
|
60
|
+
return { ok: false, error: "A role is lowercase letters, digits, _ or -, e.g. admin or support-agent." };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await auth.api.setRole({ body: { userId: input.userId, role: role as "user" }, headers: await headers() });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return failed(error);
|
|
66
|
+
}
|
|
67
|
+
revalidatePath("/dashboard/users");
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A banned account keeps its data and loses its way in; unban restores it. */
|
|
72
|
+
export async function setBannedAction(input: { userId: string; banned: boolean }): Promise<UserActionResult> {
|
|
73
|
+
const out = await signedIn();
|
|
74
|
+
if (out) return out;
|
|
75
|
+
try {
|
|
76
|
+
if (input.banned) {
|
|
77
|
+
await auth.api.banUser({ body: { userId: input.userId }, headers: await headers() });
|
|
78
|
+
} else {
|
|
79
|
+
await auth.api.unbanUser({ body: { userId: input.userId }, headers: await headers() });
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return failed(error);
|
|
83
|
+
}
|
|
84
|
+
revalidatePath("/dashboard/users");
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { count, desc } from "drizzle-orm";
|
|
3
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
4
|
+
import { CreateUserButton } from "@/components/dashboard/user-actions";
|
|
5
|
+
import { UsersTable } from "@/components/dashboard/users-table";
|
|
6
|
+
import { getDb } from "@/db";
|
|
7
|
+
import { user } from "@/db/auth-schema";
|
|
8
|
+
import { ADMIN_ROLES, dashboardSession } from "@/lib/dashboard";
|
|
9
|
+
|
|
10
|
+
export const metadata = { title: "Users" };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Everyone with an account — there from the first sign-in, before any resource exists.
|
|
14
|
+
*
|
|
15
|
+
* Admin only: the resource policies decide who sees data, but the account list is about
|
|
16
|
+
* the people themselves, so it follows ADMIN_ROLES rather than any one policy.
|
|
17
|
+
*/
|
|
18
|
+
export default async function UsersPage() {
|
|
19
|
+
const { session, role } = await dashboardSession();
|
|
20
|
+
if (!session) redirect("/sign-in?next=/dashboard/users");
|
|
21
|
+
if (!role || !ADMIN_ROLES.includes(role)) redirect("/dashboard");
|
|
22
|
+
|
|
23
|
+
const db = getDb();
|
|
24
|
+
const [rows, [{ total } = { total: 0 }]] = await Promise.all([
|
|
25
|
+
db
|
|
26
|
+
.select({
|
|
27
|
+
id: user.id,
|
|
28
|
+
name: user.name,
|
|
29
|
+
email: user.email,
|
|
30
|
+
image: user.image,
|
|
31
|
+
role: user.role,
|
|
32
|
+
banned: user.banned,
|
|
33
|
+
emailVerified: user.emailVerified,
|
|
34
|
+
twoFactorEnabled: user.twoFactorEnabled,
|
|
35
|
+
createdAt: user.createdAt,
|
|
36
|
+
})
|
|
37
|
+
.from(user)
|
|
38
|
+
.orderBy(desc(user.createdAt))
|
|
39
|
+
.limit(200),
|
|
40
|
+
db.select({ total: count() }).from(user),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<PageHeader
|
|
46
|
+
title="Users"
|
|
47
|
+
description="Everyone who can sign in, newest first — and what each of them is allowed to be."
|
|
48
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Users" }]}
|
|
49
|
+
actions={<CreateUserButton />}
|
|
50
|
+
/>
|
|
51
|
+
<UsersTable rows={rows} total={total} currentUserId={session.user.id} />
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
|
|
6
|
+
import { hardReload, isStaleChunkError } from "@/lib/stale-chunk";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The public error boundary.
|
|
@@ -11,6 +12,10 @@ import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
|
|
|
11
12
|
* needs somewhere to hand `reset`, which re-renders the segment without a full page
|
|
12
13
|
* load, and that is usually all a transient failure needs.
|
|
13
14
|
*
|
|
15
|
+
* Usually, but not always: a tab holding stale JavaScript throws too, and `reset` can
|
|
16
|
+
* never fix that one — it re-renders the same stale modules. So that case is told
|
|
17
|
+
* apart and offered a reload, which does fix it. See lib/stale-chunk.ts.
|
|
18
|
+
*
|
|
14
19
|
* `error.digest` is the only detail shown. The message itself is deliberately not:
|
|
15
20
|
* in production it is replaced by React anyway, and in development the overlay is
|
|
16
21
|
* more use than anything printed here.
|
|
@@ -21,17 +26,21 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
|
|
|
21
26
|
console.error(error);
|
|
22
27
|
}, [error]);
|
|
23
28
|
|
|
29
|
+
const stale = isStaleChunkError(error);
|
|
30
|
+
|
|
24
31
|
return (
|
|
25
32
|
<main className="mx-auto flex min-h-[70vh] max-w-2xl flex-col items-center justify-center gap-6 px-6 py-20 text-center">
|
|
26
33
|
<div className="flex size-14 items-center justify-center rounded-2xl border border-border bg-surface">
|
|
27
34
|
<TriangleAlertIcon className="size-6 text-danger" aria-hidden="true" />
|
|
28
35
|
</div>
|
|
29
36
|
<div className="flex flex-col gap-3">
|
|
30
|
-
<h1 className="text-3xl text-balance md:text-4xl">Something went wrong</h1>
|
|
37
|
+
<h1 className="text-3xl text-balance md:text-4xl">{stale ? "This tab is running old code" : "Something went wrong"}</h1>
|
|
31
38
|
<p className="text-foreground-muted text-pretty">
|
|
32
|
-
|
|
39
|
+
{stale
|
|
40
|
+
? "Nothing is wrong with the app. This tab loaded one build and the server is serving another — usually after an update, or after starting a different app on this port. Reloading fixes it."
|
|
41
|
+
: "This one is on us. Trying again often works; if it doesn't, the error has been logged."}
|
|
33
42
|
</p>
|
|
34
|
-
{error.digest && (
|
|
43
|
+
{!stale && error.digest && (
|
|
35
44
|
<p className="font-mono text-xs text-foreground-muted">
|
|
36
45
|
Reference: <span className="select-all">{error.digest}</span>
|
|
37
46
|
</p>
|
|
@@ -40,11 +49,11 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
|
|
|
40
49
|
<div className="flex flex-wrap justify-center gap-3">
|
|
41
50
|
<button
|
|
42
51
|
type="button"
|
|
43
|
-
onClick={reset}
|
|
52
|
+
onClick={stale ? hardReload : reset}
|
|
44
53
|
className="inline-flex h-11 items-center gap-2 rounded-[var(--button-radius)] bg-brand px-5 font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
|
|
45
54
|
>
|
|
46
55
|
<RefreshCwIcon className="size-4" aria-hidden="true" />
|
|
47
|
-
Try again
|
|
56
|
+
{stale ? "Reload the page" : "Try again"}
|
|
48
57
|
</button>
|
|
49
58
|
<Link href="/" className="inline-flex h-11 items-center rounded-[var(--button-radius)] border border-border px-5 font-medium hover:bg-accent">
|
|
50
59
|
Back home
|
|
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|
|
5
5
|
import { renderSVG } from "uqr";
|
|
6
6
|
import { CheckIcon, CopyIcon, FingerprintIcon, LaptopIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react";
|
|
7
7
|
import { AuthInput, AuthLabel, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "@/components/auth/ui";
|
|
8
|
-
import { PasswordField } from "@/components/auth/password-field";
|
|
8
|
+
import { PasswordField, PasswordInput } from "@/components/auth/password-field";
|
|
9
9
|
import { authErrorMessage } from "@/components/auth/sign-in-flow";
|
|
10
10
|
import { PROVIDER_LABELS, ProviderIcon } from "@/components/auth/provider-icons";
|
|
11
11
|
import { authClient } from "@/lib/auth-client";
|
|
@@ -50,7 +50,7 @@ function PasswordConfirm({ value, onChange, id }: { value: string; onChange: (va
|
|
|
50
50
|
return (
|
|
51
51
|
<div className="flex flex-col gap-2">
|
|
52
52
|
<AuthLabel htmlFor={id}>Your password</AuthLabel>
|
|
53
|
-
<
|
|
53
|
+
<PasswordInput id={id} autoComplete="current-password" value={value} onChange={(event) => onChange(event.target.value)} required />
|
|
54
54
|
</div>
|
|
55
55
|
);
|
|
56
56
|
}
|
|
@@ -459,6 +459,26 @@ function describeDevice(agent?: string | null): string {
|
|
|
459
459
|
return system ? `${browser} on ${system}` : browser;
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Where a session came from, in words rather than a number nobody can read.
|
|
464
|
+
*
|
|
465
|
+
* Better Auth records whatever address the request arrived from, and on a machine
|
|
466
|
+
* talking to itself that is the loopback — which IPv6 writes in full as
|
|
467
|
+
* 0000:0000:0000:0000:0000:0000:0000:0001. Printing that in a list headed "Sign out of
|
|
468
|
+
* devices you don't recognise" is worse than printing nothing: it is unreadable, it is
|
|
469
|
+
* identical on every row, and it looks like a bug. Say "this computer" instead, which
|
|
470
|
+
* is both true and useful.
|
|
471
|
+
*/
|
|
472
|
+
function describeOrigin(ip?: string | null): string | null {
|
|
473
|
+
if (!ip) return null;
|
|
474
|
+
const address = ip.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
475
|
+
// Every spelling of "no address" and "here": IPv4 and IPv6 loopback, the unspecified
|
|
476
|
+
// address, and the IPv4-mapped forms a dual-stack listener reports.
|
|
477
|
+
if (/^(::1?|0+(:0+)*|127(\.\d+){3}|::ffff:127(\.\d+){3}|localhost)$/.test(address)) return "this computer";
|
|
478
|
+
if (/^(10|127)\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^fe80:/.test(address)) return `${ip} (local network)`;
|
|
479
|
+
return ip;
|
|
480
|
+
}
|
|
481
|
+
|
|
462
482
|
export function Sessions() {
|
|
463
483
|
const [list, setList] = useState<SessionRow[] | null>(null);
|
|
464
484
|
const [current, setCurrent] = useState<string | null>(null);
|
|
@@ -488,7 +508,7 @@ export function Sessions() {
|
|
|
488
508
|
<span className="flex-1">
|
|
489
509
|
<span className="font-medium">{describeDevice(item.userAgent)}</span>
|
|
490
510
|
<span className="block text-xs text-foreground-muted">
|
|
491
|
-
{item.ipAddress
|
|
511
|
+
{describeOrigin(item.ipAddress) ?? "Unknown location"}
|
|
492
512
|
{item.updatedAt && `, last active ${new Date(item.updatedAt).toLocaleString()}`}
|
|
493
513
|
</span>
|
|
494
514
|
</span>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useId, useMemo, useState } from "react";
|
|
3
|
+
import { forwardRef, useId, useMemo, useState } from "react";
|
|
4
4
|
import { CheckIcon, EyeIcon, EyeOffIcon } from "lucide-react";
|
|
5
5
|
import { checkPassword, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@/lib/password-rules";
|
|
6
6
|
import { cn } from "@/lib/utils";
|
|
@@ -22,6 +22,40 @@ interface Props {
|
|
|
22
22
|
avoid?: (string | null | undefined)[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A password input with the eye that reveals what you typed.
|
|
27
|
+
*
|
|
28
|
+
* The one place that knows how to do this, because a password field people cannot see
|
|
29
|
+
* into is where typos go to hide — and a form that offers the eye in one place and not
|
|
30
|
+
* the next teaches people not to trust it. {@link PasswordField} wraps this with the
|
|
31
|
+
* rules and the strength meter; forms that only need the box use it directly.
|
|
32
|
+
*/
|
|
33
|
+
export const PasswordInput = forwardRef<HTMLInputElement, Omit<React.ComponentProps<typeof AuthInput>, "type">>(
|
|
34
|
+
function PasswordInput({ className, ...props }, ref) {
|
|
35
|
+
const [visible, setVisible] = useState(false);
|
|
36
|
+
return (
|
|
37
|
+
<div className="relative">
|
|
38
|
+
<AuthInput ref={ref} {...props} type={visible ? "text" : "password"} className={cn("pr-11", className)} />
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => setVisible(!visible)}
|
|
42
|
+
aria-pressed={visible}
|
|
43
|
+
aria-controls={props.id}
|
|
44
|
+
aria-label={visible ? "Hide password" : "Show password"}
|
|
45
|
+
title={visible ? "Hide password" : "Show password"}
|
|
46
|
+
// Not in the tab order: the input it belongs to is, and a keyboard user
|
|
47
|
+
// tabbing from the password to a toggle before the submit button is a worse
|
|
48
|
+
// path than the one they expect.
|
|
49
|
+
tabIndex={-1}
|
|
50
|
+
className="absolute inset-y-0 right-0 grid w-11 place-items-center text-foreground-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
|
51
|
+
>
|
|
52
|
+
{visible ? <EyeOffIcon className="size-4.5" /> : <EyeIcon className="size-4.5" />}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
25
59
|
const BAR_COLOURS = ["bg-danger", "bg-danger", "bg-warning", "bg-success/70", "bg-success"];
|
|
26
60
|
|
|
27
61
|
/**
|
|
@@ -46,43 +80,28 @@ export function PasswordField({
|
|
|
46
80
|
const generatedId = useId();
|
|
47
81
|
const fieldId = id ?? generatedId;
|
|
48
82
|
const hintId = `${fieldId}-hint`;
|
|
49
|
-
const [visible, setVisible] = useState(false);
|
|
50
83
|
const check = useMemo(() => checkPassword(value, avoid), [value, avoid.join("\u0000")]);
|
|
51
84
|
const started = value.length > 0;
|
|
52
85
|
|
|
53
86
|
return (
|
|
54
87
|
<div className="flex flex-col gap-2">
|
|
55
88
|
<AuthLabel htmlFor={fieldId}>{label}</AuthLabel>
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
className={cn("pr-11", showRules && check.valid && "border-success/60")}
|
|
73
|
-
/>
|
|
74
|
-
<button
|
|
75
|
-
type="button"
|
|
76
|
-
onClick={() => setVisible(!visible)}
|
|
77
|
-
aria-pressed={visible}
|
|
78
|
-
aria-controls={fieldId}
|
|
79
|
-
aria-label={visible ? "Hide password" : "Show password"}
|
|
80
|
-
title={visible ? "Hide password" : "Show password"}
|
|
81
|
-
className="absolute inset-y-0 right-0 grid w-11 place-items-center text-foreground-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
|
82
|
-
>
|
|
83
|
-
{visible ? <EyeOffIcon className="size-4.5" /> : <EyeIcon className="size-4.5" />}
|
|
84
|
-
</button>
|
|
85
|
-
</div>
|
|
89
|
+
<PasswordInput
|
|
90
|
+
id={fieldId}
|
|
91
|
+
name={name}
|
|
92
|
+
autoComplete={autoComplete}
|
|
93
|
+
autoFocus={autoFocus}
|
|
94
|
+
required={required}
|
|
95
|
+
placeholder={placeholder}
|
|
96
|
+
value={value}
|
|
97
|
+
onChange={(event) => onChange(event.target.value)}
|
|
98
|
+
minLength={autoComplete === "new-password" ? PASSWORD_MIN_LENGTH : undefined}
|
|
99
|
+
maxLength={PASSWORD_MAX_LENGTH}
|
|
100
|
+
aria-describedby={showRules ? hintId : undefined}
|
|
101
|
+
// A styling hook, so the field can go green without any state plumbing.
|
|
102
|
+
data-has-passed-validation={showRules && check.valid ? "" : undefined}
|
|
103
|
+
className={cn(showRules && check.valid && "border-success/60")}
|
|
104
|
+
/>
|
|
86
105
|
|
|
87
106
|
{showRules && (
|
|
88
107
|
<div id={hintId} className="flex flex-col gap-2">
|