@flaredev/cli 0.12.1 → 0.13.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 +114 -25
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/_gitignore +7 -0
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +13 -10
- package/templates/app/components/dashboard/user-actions.tsx +171 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +5 -1
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/site.ts +2 -2
- package/templates/app/vite.config.ts +3 -1
- package/templates/locks/next/package-lock.json +92 -91
- package/templates/locks/next/pnpm-lock.yaml +84 -93
- package/templates/locks/next/yarn.lock +83 -95
- 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 +5 -1
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/prisma/schema/base.prisma +6 -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,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
|
+
}
|
|
@@ -30,7 +30,7 @@ function applyTheme(theme: Theme) {
|
|
|
30
30
|
if (theme !== "system") root.classList.add(theme);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function DashboardHeader({ user, initialTheme, notices = [] }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[] }) {
|
|
33
|
+
export function DashboardHeader({ user, initialTheme, notices = [], isAdmin = false }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[]; isAdmin?: boolean }) {
|
|
34
34
|
const [theme, setTheme] = useState<Theme>(initialTheme);
|
|
35
35
|
const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
36
36
|
const needsAttention = notices.some((notice) => notice.tone === "warning");
|
|
@@ -106,7 +106,7 @@ export function DashboardHeader({ user, initialTheme, notices = [] }: { user: Da
|
|
|
106
106
|
</Button>
|
|
107
107
|
</DropdownMenuTrigger>
|
|
108
108
|
<DropdownMenuContent align="end" className="w-60">
|
|
109
|
-
<UserMenuItems user={user} />
|
|
109
|
+
<UserMenuItems user={user} isAdmin={isAdmin} />
|
|
110
110
|
</DropdownMenuContent>
|
|
111
111
|
</DropdownMenu>
|
|
112
112
|
</div>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
5
|
-
import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
|
|
5
|
+
import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon, UsersIcon } from "lucide-react";
|
|
6
6
|
import { ChevronsUpDownIcon } from "lucide-react";
|
|
7
7
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
8
8
|
import {
|
|
@@ -56,8 +56,11 @@ function byGroup(resources: NavResource[]): [string, NavResource[]][] {
|
|
|
56
56
|
/** Sections every dashboard has, whatever resources the app defines. */
|
|
57
57
|
const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
|
|
58
58
|
// Security has its own page once `flare gen security` runs, which adds its link to
|
|
59
|
-
// lib/dashboard-nav.ts; observability
|
|
60
|
-
const ADMIN_LINKS = [
|
|
59
|
+
// lib/dashboard-nav.ts; users and observability ship with every app.
|
|
60
|
+
const ADMIN_LINKS = [
|
|
61
|
+
{ href: "/dashboard/users", label: "Users", icon: UsersIcon },
|
|
62
|
+
{ href: "/dashboard/observability", label: "Observability", icon: ActivityIcon },
|
|
63
|
+
];
|
|
61
64
|
|
|
62
65
|
export function DashboardSidebar({
|
|
63
66
|
appName,
|
|
@@ -113,8 +116,8 @@ export function DashboardSidebar({
|
|
|
113
116
|
</SidebarGroup>
|
|
114
117
|
|
|
115
118
|
{byGroup(resources).map(([heading, group]) => (
|
|
116
|
-
<SidebarGroup key={heading} className="px-2 py-1">
|
|
117
|
-
<SidebarGroupLabel className="mt-1 h-6">{heading}</SidebarGroupLabel>
|
|
119
|
+
<SidebarGroup key={heading} className="border-t px-2 py-1">
|
|
120
|
+
<SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">{heading}</SidebarGroupLabel>
|
|
118
121
|
<SidebarGroupContent>
|
|
119
122
|
<SidebarMenu>
|
|
120
123
|
{group.map((resource) => {
|
|
@@ -149,8 +152,8 @@ export function DashboardSidebar({
|
|
|
149
152
|
</SidebarGroup>
|
|
150
153
|
))}
|
|
151
154
|
|
|
152
|
-
<SidebarGroup className="px-2 py-1">
|
|
153
|
-
<SidebarGroupLabel className="mt-1 h-6">Manage</SidebarGroupLabel>
|
|
155
|
+
<SidebarGroup className="border-t px-2 py-1">
|
|
156
|
+
<SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">Manage</SidebarGroupLabel>
|
|
154
157
|
<SidebarGroupContent>
|
|
155
158
|
<SidebarMenu>
|
|
156
159
|
{manage.map((link) => (
|
|
@@ -168,8 +171,8 @@ export function DashboardSidebar({
|
|
|
168
171
|
</SidebarGroup>
|
|
169
172
|
|
|
170
173
|
{links.length > 0 && (
|
|
171
|
-
<SidebarGroup className="px-2 py-1">
|
|
172
|
-
<SidebarGroupLabel className="mt-1 h-6">Platform</SidebarGroupLabel>
|
|
174
|
+
<SidebarGroup className="border-t px-2 py-1">
|
|
175
|
+
<SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">Platform</SidebarGroupLabel>
|
|
173
176
|
<SidebarGroupContent>
|
|
174
177
|
<SidebarMenu>
|
|
175
178
|
{links.map((link) => {
|
|
@@ -217,7 +220,7 @@ export function DashboardSidebar({
|
|
|
217
220
|
</SidebarMenuButton>
|
|
218
221
|
</DropdownMenuTrigger>
|
|
219
222
|
<DropdownMenuContent side="top" align="start" className="w-60">
|
|
220
|
-
<UserMenuItems user={user} />
|
|
223
|
+
<UserMenuItems user={user} isAdmin={isAdmin} />
|
|
221
224
|
</DropdownMenuContent>
|
|
222
225
|
</DropdownMenu>
|
|
223
226
|
</SidebarMenuItem>
|
|
@@ -0,0 +1,171 @@
|
|
|
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 { Input } from "@/components/ui/input";
|
|
27
|
+
import { Label } from "@/components/ui/label";
|
|
28
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
29
|
+
import { createUserAction, setBannedAction, setRoleAction, type UserActionResult } from "@/app/dashboard/users/actions";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The management side of the users page: make an account, change a role, ban.
|
|
33
|
+
*
|
|
34
|
+
* Everything here calls the same Better Auth admin API the CLI uses, so an account made
|
|
35
|
+
* from this dialog and one made with `flare user:add` are indistinguishable.
|
|
36
|
+
*
|
|
37
|
+
* The well-known roles get one-click entries; anything else is typed, because a role is
|
|
38
|
+
* just a string your policies name — inventing one here is how you "add a role".
|
|
39
|
+
*/
|
|
40
|
+
const KNOWN_ROLES = ["admin", "staff", "user"];
|
|
41
|
+
|
|
42
|
+
export function CreateUserButton() {
|
|
43
|
+
const router = useRouter();
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const [pending, startTransition] = useTransition();
|
|
46
|
+
|
|
47
|
+
const submit = (form: FormData) => {
|
|
48
|
+
startTransition(async () => {
|
|
49
|
+
const result = await createUserAction({
|
|
50
|
+
name: String(form.get("name") ?? ""),
|
|
51
|
+
email: String(form.get("email") ?? ""),
|
|
52
|
+
password: String(form.get("password") ?? ""),
|
|
53
|
+
role: String(form.get("role") ?? "user"),
|
|
54
|
+
});
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
toast.error(result.error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
toast.success("Account created. They can sign in now.");
|
|
60
|
+
setOpen(false);
|
|
61
|
+
router.refresh();
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
67
|
+
<DialogTrigger asChild>
|
|
68
|
+
<Button>
|
|
69
|
+
<UserPlusIcon data-icon="inline-start" />
|
|
70
|
+
New user
|
|
71
|
+
</Button>
|
|
72
|
+
</DialogTrigger>
|
|
73
|
+
<DialogContent className="sm:max-w-md">
|
|
74
|
+
<DialogHeader>
|
|
75
|
+
<DialogTitle>New user</DialogTitle>
|
|
76
|
+
<DialogDescription>Usable immediately — send them the password and have them change it.</DialogDescription>
|
|
77
|
+
</DialogHeader>
|
|
78
|
+
<form action={submit} className="flex flex-col gap-4">
|
|
79
|
+
<div className="flex flex-col gap-2">
|
|
80
|
+
<Label htmlFor="new-user-name">Name</Label>
|
|
81
|
+
<Input id="new-user-name" name="name" required autoComplete="off" />
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex flex-col gap-2">
|
|
84
|
+
<Label htmlFor="new-user-email">Email</Label>
|
|
85
|
+
<Input id="new-user-email" name="email" type="email" required autoComplete="off" />
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex flex-col gap-2">
|
|
88
|
+
<Label htmlFor="new-user-password">Password</Label>
|
|
89
|
+
<Input id="new-user-password" name="password" type="text" required minLength={8} autoComplete="off" />
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex flex-col gap-2">
|
|
92
|
+
<Label htmlFor="new-user-role">Role</Label>
|
|
93
|
+
<Input id="new-user-role" name="role" defaultValue="user" list="known-roles" />
|
|
94
|
+
<datalist id="known-roles">
|
|
95
|
+
{KNOWN_ROLES.map((role) => (
|
|
96
|
+
<option key={role} value={role} />
|
|
97
|
+
))}
|
|
98
|
+
</datalist>
|
|
99
|
+
<p className="text-xs text-muted-foreground">Type anything: a new role exists the moment a policy names it.</p>
|
|
100
|
+
</div>
|
|
101
|
+
<DialogFooter>
|
|
102
|
+
<Button type="submit" disabled={pending}>
|
|
103
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
104
|
+
Create account
|
|
105
|
+
</Button>
|
|
106
|
+
</DialogFooter>
|
|
107
|
+
</form>
|
|
108
|
+
</DialogContent>
|
|
109
|
+
</Dialog>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function UserRowMenu({ userId, role, banned }: { userId: string; role: string; banned: boolean }) {
|
|
114
|
+
const router = useRouter();
|
|
115
|
+
const [pending, startTransition] = useTransition();
|
|
116
|
+
|
|
117
|
+
const run = (work: () => Promise<UserActionResult>, done: string) => {
|
|
118
|
+
startTransition(async () => {
|
|
119
|
+
const result = await work();
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
toast.error(result.error);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
toast.success(done);
|
|
125
|
+
router.refresh();
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const setRole = (next: string) => run(() => setRoleAction({ userId, role: next }), `Role is now ${next}.`);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<DropdownMenu>
|
|
133
|
+
<DropdownMenuTrigger asChild>
|
|
134
|
+
<Button variant="ghost" size="icon" aria-label="Manage this account" disabled={pending}>
|
|
135
|
+
{pending ? <Spinner /> : <MoreHorizontalIcon />}
|
|
136
|
+
</Button>
|
|
137
|
+
</DropdownMenuTrigger>
|
|
138
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
139
|
+
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
|
140
|
+
<ShieldIcon className="size-3.5" />
|
|
141
|
+
Role
|
|
142
|
+
</DropdownMenuLabel>
|
|
143
|
+
<DropdownMenuGroup>
|
|
144
|
+
{KNOWN_ROLES.map((option) => (
|
|
145
|
+
<DropdownMenuItem key={option} disabled={option === role} onSelect={() => setRole(option)}>
|
|
146
|
+
{option}
|
|
147
|
+
</DropdownMenuItem>
|
|
148
|
+
))}
|
|
149
|
+
<DropdownMenuItem
|
|
150
|
+
onSelect={() => {
|
|
151
|
+
const next = window.prompt("Role (lowercase letters, digits, _ or -):", role);
|
|
152
|
+
if (next && next !== role) setRole(next);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
Something else…
|
|
156
|
+
</DropdownMenuItem>
|
|
157
|
+
</DropdownMenuGroup>
|
|
158
|
+
<DropdownMenuSeparator />
|
|
159
|
+
<DropdownMenuGroup>
|
|
160
|
+
<DropdownMenuItem
|
|
161
|
+
variant={banned ? undefined : "destructive"}
|
|
162
|
+
onSelect={() => run(() => setBannedAction({ userId, banned: !banned }), banned ? "Unbanned — they can sign in again." : "Banned. Their data stays; their way in doesn't.")}
|
|
163
|
+
>
|
|
164
|
+
<BanIcon />
|
|
165
|
+
{banned ? "Unban" : "Ban"}
|
|
166
|
+
</DropdownMenuItem>
|
|
167
|
+
</DropdownMenuGroup>
|
|
168
|
+
</DropdownMenuContent>
|
|
169
|
+
</DropdownMenu>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -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/users">
|
|
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
|
</>
|
|
@@ -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
|
+
}
|
|
@@ -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
|
],
|
|
@@ -167,7 +167,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
|
|
|
167
167
|
return stats;
|
|
168
168
|
},
|
|
169
169
|
["flare", "resource-stats"],
|
|
170
|
-
|
|
170
|
+
// TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
|
|
171
|
+
// counts, dashboards". These are those counts — and `flare seed:resource` writes
|
|
172
|
+
// from outside the app, where no tag revalidates them, so at medium the overview
|
|
173
|
+
// said "0 records" for five minutes after seeding a thousand.
|
|
174
|
+
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
171
175
|
);
|
|
172
176
|
return read(name, field, days, limit ?? undefined);
|
|
173
177
|
}
|
|
@@ -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
|
}
|
|
@@ -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)
|
|
@@ -7,7 +7,9 @@ export default defineConfig(({ mode }) => ({
|
|
|
7
7
|
// FLARE_THEME (shell or .env) picks the theme for this build; see lib/theme.ts.
|
|
8
8
|
define: { __FLARE_THEME__: JSON.stringify(loadEnv(mode, process.cwd(), "FLARE_").FLARE_THEME ?? "") },
|
|
9
9
|
// `flare dev --tunnel` serves the dev server on a https://*.trycloudflare.com URL.
|
|
10
|
-
|
|
10
|
+
// Port 3000 rather than Vite's 5173, so both stacks answer where everything printed
|
|
11
|
+
// and documented says they do — the Next.js stack is 3000 and cannot move.
|
|
12
|
+
server: { port: 3000, allowedHosts: [".trycloudflare.com"] },
|
|
11
13
|
optimizeDeps: {
|
|
12
14
|
/*
|
|
13
15
|
* Packages that ship their own "use client" components are left out of dependency
|