@flaredev/cli 0.1.1 → 0.2.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/README.md +47 -47
- package/dist/index.js +373 -169
- package/package.json +3 -2
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +38 -0
- package/templates/app/app/dashboard/layout.tsx +39 -0
- package/templates/app/app/dashboard/page.tsx +42 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/components/account/security-settings.tsx +525 -0
- package/templates/app/components/admin/fields/country-field.tsx +152 -0
- package/templates/app/components/admin/fields/field-widget.tsx +186 -128
- package/templates/app/components/admin/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/admin/fields/phone-field.tsx +86 -0
- package/templates/app/components/admin/resource-nav.tsx +120 -109
- package/templates/app/components/admin/resource-table.tsx +31 -1
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/password-forms.tsx +79 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +315 -0
- package/templates/app/components/auth/sign-up-form.tsx +101 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +124 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/dashboard/dashboard-nav.tsx +35 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/lib/admin-nav.ts +1 -1
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +33 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +81 -6
- package/templates/app/lib/site.ts +47 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +32 -1
- package/templates/app/migrations/meta/0000_snapshot.json +215 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/components/auth-form.tsx +0 -120
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The auth screens' controls. They read the theme tokens (--brand, --button-radius,
|
|
6
|
+
* --radius, --font-theme), so the same markup takes each theme's shape and colour.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const AuthInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(function AuthInput({ className, ...props }, ref) {
|
|
10
|
+
return (
|
|
11
|
+
<input
|
|
12
|
+
ref={ref}
|
|
13
|
+
className={cn(
|
|
14
|
+
"h-11 w-full rounded-[calc(var(--radius)-2px)] border border-border bg-surface px-3.5 text-[15px] text-foreground outline-none transition-[border-color,box-shadow]",
|
|
15
|
+
"placeholder:text-foreground-muted/80 focus-visible:border-brand focus-visible:ring-[3px] focus-visible:ring-brand/20",
|
|
16
|
+
"aria-invalid:border-danger aria-invalid:ring-danger/15 disabled:opacity-60",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function AuthLabel({ className, ...props }: React.ComponentProps<"label">) {
|
|
25
|
+
return <label className={cn("text-sm font-medium text-foreground", className)} {...props} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PrimaryButton({ className, pending, children, ...props }: React.ComponentProps<"button"> & { pending?: boolean }) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
className={cn(
|
|
32
|
+
"relative inline-flex h-11 w-full items-center justify-center gap-2 rounded-[var(--button-radius)] bg-brand px-4 text-[15px] font-medium text-brand-foreground",
|
|
33
|
+
"[background-image:var(--brand-gradient,none)] transition-[filter,opacity] hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-60",
|
|
34
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
disabled={pending || props.disabled}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
{pending && <span className="size-4 animate-spin rounded-full border-2 border-current border-r-transparent" aria-hidden="true" />}
|
|
41
|
+
{children}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SecondaryButton({ className, ...props }: React.ComponentProps<"button">) {
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
className={cn(
|
|
50
|
+
"inline-flex h-11 w-full items-center justify-center gap-2.5 rounded-[var(--button-radius)] border border-border bg-surface px-4 text-[15px] font-medium text-foreground",
|
|
51
|
+
"transition-colors hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-60",
|
|
52
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
type="button"
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function TextButton({ className, ...props }: React.ComponentProps<"button">) {
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
className={cn("text-sm font-medium text-link underline-offset-4 hover:underline focus-visible:underline focus-visible:outline-none disabled:opacity-60", className)}
|
|
66
|
+
{...props}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Divider({ children = "or" }: { children?: React.ReactNode }) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex items-center gap-3 text-xs text-foreground-muted uppercase" role="separator">
|
|
74
|
+
<span className="h-px flex-1 bg-border" />
|
|
75
|
+
{children}
|
|
76
|
+
<span className="h-px flex-1 bg-border" />
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function FormMessage({ tone = "error", children }: { tone?: "error" | "info" | "success"; children: React.ReactNode }) {
|
|
82
|
+
if (!children) return null;
|
|
83
|
+
return (
|
|
84
|
+
<p
|
|
85
|
+
role={tone === "error" ? "alert" : "status"}
|
|
86
|
+
className={cn(
|
|
87
|
+
"rounded-[calc(var(--radius)-2px)] px-3 py-2.5 text-sm",
|
|
88
|
+
tone === "error" && "bg-danger/10 text-danger",
|
|
89
|
+
tone === "info" && "bg-surface-muted text-foreground",
|
|
90
|
+
tone === "success" && "bg-success/10 text-success",
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</p>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface NavLink {
|
|
8
|
+
href: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The dashboard's section links, with the current one marked. */
|
|
13
|
+
export function DashboardNav({ links }: { links: NavLink[] }) {
|
|
14
|
+
const pathname = usePathname();
|
|
15
|
+
return (
|
|
16
|
+
<nav aria-label="Dashboard" className="-mb-px flex gap-1 overflow-x-auto">
|
|
17
|
+
{links.map((link) => {
|
|
18
|
+
const current = link.href === "/dashboard" ? pathname === "/dashboard" : pathname.startsWith(link.href);
|
|
19
|
+
return (
|
|
20
|
+
<Link
|
|
21
|
+
key={link.href}
|
|
22
|
+
href={link.href}
|
|
23
|
+
aria-current={current ? "page" : undefined}
|
|
24
|
+
className={cn(
|
|
25
|
+
"border-b-2 px-3 py-3 text-sm font-medium whitespace-nowrap text-foreground-muted transition-colors hover:text-foreground",
|
|
26
|
+
current ? "border-brand text-foreground" : "border-transparent",
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
{link.label}
|
|
30
|
+
</Link>
|
|
31
|
+
);
|
|
32
|
+
})}
|
|
33
|
+
</nav>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { site } from "@/lib/site";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A drawing of the app in CSS, not a screenshot, so it takes the theme's colours and
|
|
5
|
+
* type. Replace it with a real screenshot of your product when you have one.
|
|
6
|
+
*/
|
|
7
|
+
export function ProductPreview() {
|
|
8
|
+
const rows = [
|
|
9
|
+
["Northwind", "Active", "$4,200"],
|
|
10
|
+
["Brightline", "Trial", "$1,150"],
|
|
11
|
+
["Vandelay", "Active", "$9,800"],
|
|
12
|
+
["Globex", "Paused", "$640"],
|
|
13
|
+
];
|
|
14
|
+
const bars = [38, 52, 44, 68, 57, 76, 71, 88, 80, 94, 86, 100];
|
|
15
|
+
return (
|
|
16
|
+
<div aria-hidden="true" className="relative mx-auto w-full max-w-5xl">
|
|
17
|
+
<div className="absolute -inset-x-10 -top-10 -bottom-20 -z-10 rounded-[3rem] bg-gradient-to-b from-brand/15 via-brand/5 to-transparent blur-2xl" />
|
|
18
|
+
<div className="overflow-hidden rounded-[calc(var(--radius)+10px)] border border-border bg-surface shadow-2xl shadow-foreground/10">
|
|
19
|
+
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
|
20
|
+
<span className="size-3 rounded-full bg-border" />
|
|
21
|
+
<span className="size-3 rounded-full bg-border" />
|
|
22
|
+
<span className="size-3 rounded-full bg-border" />
|
|
23
|
+
<span className="ml-3 h-6 flex-1 rounded-md bg-surface-muted" />
|
|
24
|
+
</div>
|
|
25
|
+
<div className="grid grid-cols-[180px_1fr] max-sm:grid-cols-1">
|
|
26
|
+
<div className="flex flex-col gap-1.5 border-r border-border bg-surface-muted/60 p-4 max-sm:hidden">
|
|
27
|
+
<span className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
|
28
|
+
<span className="size-5 rounded-md bg-brand [background-image:var(--brand-gradient,none)]" />
|
|
29
|
+
{site.name}
|
|
30
|
+
</span>
|
|
31
|
+
{["Overview", "Customers", "Invoices", "Reports", "Settings"].map((item, index) => (
|
|
32
|
+
<span key={item} className={`rounded-md px-2.5 py-1.5 text-xs ${index === 0 ? "bg-surface font-medium text-foreground shadow-sm" : "text-foreground-muted"}`}>
|
|
33
|
+
{item}
|
|
34
|
+
</span>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
<div className="flex flex-col gap-5 p-6">
|
|
38
|
+
<div className="grid grid-cols-3 gap-3">
|
|
39
|
+
{[
|
|
40
|
+
["Revenue", "$48,290", "+12%"],
|
|
41
|
+
["Customers", "1,284", "+8%"],
|
|
42
|
+
["Churn", "1.9%", "-0.4%"],
|
|
43
|
+
].map(([label, value, change]) => (
|
|
44
|
+
<div key={label} className="rounded-[var(--radius)] border border-border p-3">
|
|
45
|
+
<p className="text-[11px] text-foreground-muted">{label}</p>
|
|
46
|
+
<p className="mt-1 text-lg font-semibold tabular-nums">{value}</p>
|
|
47
|
+
<p className="text-[11px] text-success">{change}</p>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flex h-28 items-end gap-1.5 rounded-[var(--radius)] border border-border p-3">
|
|
52
|
+
{bars.map((height, index) => (
|
|
53
|
+
<span key={index} className="flex-1 rounded-t-sm bg-brand/80 [background-image:var(--brand-gradient,none)]" style={{ height: `${height}%`, opacity: 0.35 + index * 0.055 }} />
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="rounded-[var(--radius)] border border-border">
|
|
57
|
+
{rows.map(([name, status, amount]) => (
|
|
58
|
+
<div key={name} className="flex items-center justify-between border-b border-border px-3 py-2 text-xs last:border-b-0">
|
|
59
|
+
<span className="font-medium">{name}</span>
|
|
60
|
+
<span className="rounded-full bg-surface-muted px-2 py-0.5 text-foreground-muted">{status}</span>
|
|
61
|
+
<span className="tabular-nums">{amount}</span>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { BrandMark } from "@/components/auth/auth-shell";
|
|
3
|
+
import { site } from "@/lib/site";
|
|
4
|
+
|
|
5
|
+
/** The public site's top bar. Static on purpose: it doesn't read the session, so the home page can be cached. */
|
|
6
|
+
export function SiteHeader() {
|
|
7
|
+
return (
|
|
8
|
+
<header className="sticky top-0 z-30 border-b border-border/60 bg-background/80 backdrop-blur-md">
|
|
9
|
+
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-6 px-6">
|
|
10
|
+
<Link href="/" className="flex items-center gap-2.5 font-semibold">
|
|
11
|
+
<BrandMark link={false} className="[&_span_span]:size-8 [&_span_span]:text-base" />
|
|
12
|
+
{site.name}
|
|
13
|
+
</Link>
|
|
14
|
+
<nav aria-label="Main" className="hidden items-center gap-7 text-sm text-foreground-muted md:flex">
|
|
15
|
+
<a href="#features" className="hover:text-foreground">
|
|
16
|
+
Features
|
|
17
|
+
</a>
|
|
18
|
+
<a href="#customers" className="hover:text-foreground">
|
|
19
|
+
Customers
|
|
20
|
+
</a>
|
|
21
|
+
<a href={site.links.support} className="hover:text-foreground">
|
|
22
|
+
Contact
|
|
23
|
+
</a>
|
|
24
|
+
</nav>
|
|
25
|
+
<div className="flex items-center gap-2">
|
|
26
|
+
<Link href="/sign-in" className="rounded-[var(--button-radius)] px-3 py-2 text-sm font-medium text-foreground-muted hover:text-foreground">
|
|
27
|
+
Log in
|
|
28
|
+
</Link>
|
|
29
|
+
<Link
|
|
30
|
+
href="/sign-up"
|
|
31
|
+
className="rounded-[var(--button-radius)] bg-brand px-4 py-2 text-sm font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
|
|
32
|
+
>
|
|
33
|
+
Sign up
|
|
34
|
+
</Link>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</header>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -10,7 +10,7 @@ export function SignOutButton() {
|
|
|
10
10
|
await authClient.signOut();
|
|
11
11
|
window.location.assign("/sign-in");
|
|
12
12
|
}}
|
|
13
|
-
className="rounded-
|
|
13
|
+
className="rounded-[var(--button-radius)] border border-border bg-surface px-3 py-1.5 text-sm font-medium hover:bg-surface-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
|
14
14
|
>
|
|
15
15
|
Sign out
|
|
16
16
|
</button>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { CheckIcon } from "lucide-react"
|
|
5
|
+
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
|
|
8
|
+
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
|
9
|
+
return (
|
|
10
|
+
<CheckboxPrimitive.Root
|
|
11
|
+
data-slot="checkbox"
|
|
12
|
+
className={cn(
|
|
13
|
+
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
|
|
19
|
+
<CheckIcon className="size-3.5" />
|
|
20
|
+
</CheckboxPrimitive.Indicator>
|
|
21
|
+
</CheckboxPrimitive.Root>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { Checkbox }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { CircleIcon } from "lucide-react"
|
|
5
|
+
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
|
|
8
|
+
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
|
9
|
+
return <RadioGroupPrimitive.Root data-slot="radio-group" className={cn("grid gap-3", className)} {...props} />
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
|
13
|
+
return (
|
|
14
|
+
<RadioGroupPrimitive.Item
|
|
15
|
+
data-slot="radio-group-item"
|
|
16
|
+
className={cn(
|
|
17
|
+
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
|
18
|
+
className
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
<RadioGroupPrimitive.Indicator data-slot="radio-group-indicator" className="relative flex items-center justify-center">
|
|
23
|
+
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
|
|
24
|
+
</RadioGroupPrimitive.Indicator>
|
|
25
|
+
</RadioGroupPrimitive.Item>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { RadioGroup, RadioGroupItem }
|
|
@@ -1,118 +1,167 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Better Auth tables (user, session, account, verification), including the admin
|
|
3
|
-
* plugin's columns (user.role/banned/ban_reason/ban_expires, session.impersonated_by)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.notNull(),
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
mode: "timestamp_ms",
|
|
70
|
-
}),
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.notNull(),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Better Auth tables (user, session, account, verification), including the admin
|
|
3
|
+
* plugin's columns (user.role/banned/ban_reason/ban_expires, session.impersonated_by),
|
|
4
|
+
* the two-factor plugin (user.two_factor_enabled, two_factor) and passkeys (passkey).
|
|
5
|
+
* Every sign-in method's tables exist whether or not lib/auth-config.ts switches it on.
|
|
6
|
+
* Generated with `auth generate --adapter drizzle --dialect sqlite` (better-auth 1.7.5).
|
|
7
|
+
* Re-generate if you add Better Auth plugins that extend the schema.
|
|
8
|
+
*/
|
|
9
|
+
import { relations, sql } from "drizzle-orm";
|
|
10
|
+
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|
11
|
+
|
|
12
|
+
export const user = sqliteTable("user", {
|
|
13
|
+
id: text("id").primaryKey(),
|
|
14
|
+
name: text("name").notNull(),
|
|
15
|
+
email: text("email").notNull().unique(),
|
|
16
|
+
emailVerified: integer("email_verified", { mode: "boolean" })
|
|
17
|
+
.default(false)
|
|
18
|
+
.notNull(),
|
|
19
|
+
image: text("image"),
|
|
20
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
21
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
22
|
+
.notNull(),
|
|
23
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
24
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
25
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
26
|
+
.notNull(),
|
|
27
|
+
role: text("role"),
|
|
28
|
+
banned: integer("banned", { mode: "boolean" }).default(false),
|
|
29
|
+
banReason: text("ban_reason"),
|
|
30
|
+
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
|
|
31
|
+
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).default(false),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const session = sqliteTable(
|
|
35
|
+
"session",
|
|
36
|
+
{
|
|
37
|
+
id: text("id").primaryKey(),
|
|
38
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
39
|
+
token: text("token").notNull().unique(),
|
|
40
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
41
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
42
|
+
.notNull(),
|
|
43
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
44
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
45
|
+
.notNull(),
|
|
46
|
+
ipAddress: text("ip_address"),
|
|
47
|
+
userAgent: text("user_agent"),
|
|
48
|
+
userId: text("user_id")
|
|
49
|
+
.notNull()
|
|
50
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
51
|
+
impersonatedBy: text("impersonated_by"),
|
|
52
|
+
},
|
|
53
|
+
(table) => [index("session_userId_idx").on(table.userId)],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const account = sqliteTable(
|
|
57
|
+
"account",
|
|
58
|
+
{
|
|
59
|
+
id: text("id").primaryKey(),
|
|
60
|
+
accountId: text("account_id").notNull(),
|
|
61
|
+
providerId: text("provider_id").notNull(),
|
|
62
|
+
userId: text("user_id")
|
|
63
|
+
.notNull()
|
|
64
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
65
|
+
accessToken: text("access_token"),
|
|
66
|
+
refreshToken: text("refresh_token"),
|
|
67
|
+
idToken: text("id_token"),
|
|
68
|
+
accessTokenExpiresAt: integer("access_token_expires_at", {
|
|
69
|
+
mode: "timestamp_ms",
|
|
70
|
+
}),
|
|
71
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
|
72
|
+
mode: "timestamp_ms",
|
|
73
|
+
}),
|
|
74
|
+
scope: text("scope"),
|
|
75
|
+
password: text("password"),
|
|
76
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
77
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
78
|
+
.notNull(),
|
|
79
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
80
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
81
|
+
.notNull(),
|
|
82
|
+
},
|
|
83
|
+
(table) => [index("account_userId_idx").on(table.userId)],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
export const verification = sqliteTable(
|
|
87
|
+
"verification",
|
|
88
|
+
{
|
|
89
|
+
id: text("id").primaryKey(),
|
|
90
|
+
identifier: text("identifier").notNull(),
|
|
91
|
+
value: text("value").notNull(),
|
|
92
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
93
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
94
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
95
|
+
.notNull(),
|
|
96
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
|
97
|
+
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
98
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
99
|
+
.notNull(),
|
|
100
|
+
},
|
|
101
|
+
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
export const twoFactor = sqliteTable(
|
|
105
|
+
"two_factor",
|
|
106
|
+
{
|
|
107
|
+
id: text("id").primaryKey(),
|
|
108
|
+
secret: text("secret").notNull(),
|
|
109
|
+
backupCodes: text("backup_codes").notNull(),
|
|
110
|
+
userId: text("user_id")
|
|
111
|
+
.notNull()
|
|
112
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
113
|
+
verified: integer("verified", { mode: "boolean" }).default(true),
|
|
114
|
+
failedVerificationCount: integer("failed_verification_count").default(0),
|
|
115
|
+
lockedUntil: integer("locked_until", { mode: "timestamp_ms" }),
|
|
116
|
+
},
|
|
117
|
+
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
export const passkey = sqliteTable(
|
|
121
|
+
"passkey",
|
|
122
|
+
{
|
|
123
|
+
id: text("id").primaryKey(),
|
|
124
|
+
name: text("name"),
|
|
125
|
+
publicKey: text("public_key").notNull(),
|
|
126
|
+
userId: text("user_id")
|
|
127
|
+
.notNull()
|
|
128
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
129
|
+
credentialID: text("credential_id").notNull(),
|
|
130
|
+
counter: integer("counter").notNull(),
|
|
131
|
+
deviceType: text("device_type").notNull(),
|
|
132
|
+
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
|
133
|
+
transports: text("transports"),
|
|
134
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }),
|
|
135
|
+
aaguid: text("aaguid"),
|
|
136
|
+
},
|
|
137
|
+
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
export const userRelations = relations(user, ({ many }) => ({
|
|
141
|
+
sessions: many(session),
|
|
142
|
+
accounts: many(account),
|
|
143
|
+
twoFactors: many(twoFactor),
|
|
144
|
+
passkeys: many(passkey),
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
|
148
|
+
user: one(user, { fields: [twoFactor.userId], references: [user.id] }),
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
|
152
|
+
user: one(user, { fields: [passkey.userId], references: [user.id] }),
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
export const sessionRelations = relations(session, ({ one }) => ({
|
|
156
|
+
user: one(user, {
|
|
157
|
+
fields: [session.userId],
|
|
158
|
+
references: [user.id],
|
|
159
|
+
}),
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
export const accountRelations = relations(account, ({ one }) => ({
|
|
163
|
+
user: one(user, {
|
|
164
|
+
fields: [account.userId],
|
|
165
|
+
references: [user.id],
|
|
166
|
+
}),
|
|
167
|
+
}));
|
|
@@ -12,4 +12,4 @@ export const generatedAdminLinks: AdminLink[] = [];
|
|
|
12
12
|
// generated:end
|
|
13
13
|
|
|
14
14
|
/** Links shown under "Platform" in the admin sidebar. */
|
|
15
|
-
export const adminLinks: AdminLink[] = [...generatedAdminLinks];
|
|
15
|
+
export const adminLinks: AdminLink[] = [...generatedAdminLinks, { label: "API reference", href: "/api/reference", icon: "book" }];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API reference at /api/reference (Scalar), built from the OpenAPI document at
|
|
3
|
+
* /api/openapi.json. Both are generated from resources/ and policies/ on every
|
|
4
|
+
* request, so they always match the API.
|
|
5
|
+
*/
|
|
6
|
+
export const apiDocs = {
|
|
7
|
+
title: "__APP_NAME__ API",
|
|
8
|
+
description: "",
|
|
9
|
+
version: "1.0.0",
|
|
10
|
+
/**
|
|
11
|
+
* "role": a signed-in user sees the endpoints their role may call (admins see
|
|
12
|
+
* everything), a visitor only the sign-in endpoints.
|
|
13
|
+
* "public": everyone sees every endpoint (requests still need the right role).
|
|
14
|
+
*/
|
|
15
|
+
visibility: "role" as "role" | "public",
|
|
16
|
+
};
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
+
import { passkeyClient } from "@better-auth/passkey/client";
|
|
2
|
+
import { emailOTPClient, magicLinkClient, twoFactorClient } from "better-auth/client/plugins";
|
|
1
3
|
import { createAuthClient } from "better-auth/react";
|
|
2
4
|
|
|
3
5
|
/** Browser-side auth client. Same origin as the app, so no baseURL is needed. */
|
|
4
|
-
export const authClient = createAuthClient(
|
|
6
|
+
export const authClient = createAuthClient({
|
|
7
|
+
plugins: [
|
|
8
|
+
magicLinkClient(),
|
|
9
|
+
emailOTPClient(),
|
|
10
|
+
passkeyClient(),
|
|
11
|
+
// A password sign-in that needs a second factor continues on /two-factor.
|
|
12
|
+
twoFactorClient({
|
|
13
|
+
onTwoFactorRedirect: () => {
|
|
14
|
+
const next = new URLSearchParams(window.location.search).get("next");
|
|
15
|
+
window.location.href = `/two-factor${next ? `?next=${encodeURIComponent(next)}` : ""}`;
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
],
|
|
19
|
+
});
|