@flaredev/cli 0.4.1 → 0.5.1
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 +698 -341
- package/package.json +2 -2
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/record-actions.tsx +7 -7
- package/templates/app/components/dashboard/record-detail.tsx +1 -1
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +129 -33
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table.tsx +4 -4
- package/templates/app/components/dashboard/row-actions.tsx +3 -3
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { prisma } from "@/lib/db";
|
|
3
|
+
import { dashboardSession } from "./dashboard";
|
|
4
|
+
|
|
5
|
+
export type AuditAction = "create" | "update" | "delete" | "import" | "bulk-delete";
|
|
6
|
+
|
|
7
|
+
export interface AuditEntry {
|
|
8
|
+
action: AuditAction;
|
|
9
|
+
resource: string;
|
|
10
|
+
recordId?: string | null;
|
|
11
|
+
recordLabel?: string | null;
|
|
12
|
+
changes?: Record<string, unknown> | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Record a change in the audit log.
|
|
17
|
+
*
|
|
18
|
+
* Never throws: an audit trail that can take the write down with it is worse than one
|
|
19
|
+
* with a gap, and the caller has already changed the database by the time we're here.
|
|
20
|
+
*/
|
|
21
|
+
export async function recordAudit(entry: AuditEntry): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
const { session } = await dashboardSession();
|
|
24
|
+
const headerList = await headers();
|
|
25
|
+
await prisma.auditLog.create({
|
|
26
|
+
data: {
|
|
27
|
+
action: entry.action,
|
|
28
|
+
resource: entry.resource,
|
|
29
|
+
recordId: entry.recordId ?? null,
|
|
30
|
+
recordLabel: entry.recordLabel ?? null,
|
|
31
|
+
userId: session?.user.id ?? null,
|
|
32
|
+
userEmail: session?.user.email ?? null,
|
|
33
|
+
changes: (entry.changes ?? null) as never,
|
|
34
|
+
// Vercel puts the caller's address here; Cloudflare uses cf-connecting-ip.
|
|
35
|
+
ip: headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
// Deliberately quiet.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Which fields moved, and from what to what. Only the differences, never the whole record. */
|
|
44
|
+
export function diffFields(before: Record<string, unknown> | null | undefined, after: Record<string, unknown>): Record<string, { from: unknown; to: unknown }> {
|
|
45
|
+
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
|
46
|
+
for (const [key, value] of Object.entries(after)) {
|
|
47
|
+
if (key === "updatedAt" || key === "createdAt") continue;
|
|
48
|
+
const previous = before?.[key];
|
|
49
|
+
const same = JSON.stringify(previous ?? null) === JSON.stringify(value ?? null);
|
|
50
|
+
if (!same) changes[key] = { from: previous ?? null, to: value ?? null };
|
|
51
|
+
}
|
|
52
|
+
return changes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AuditRow {
|
|
56
|
+
id: string;
|
|
57
|
+
action: string;
|
|
58
|
+
resource: string;
|
|
59
|
+
recordId: string | null;
|
|
60
|
+
recordLabel: string | null;
|
|
61
|
+
userEmail: string | null;
|
|
62
|
+
changes: Record<string, unknown> | null;
|
|
63
|
+
createdAt: Date;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The most recent entries, newest first. */
|
|
67
|
+
export async function recentAudit(limit = 25, resource?: string): Promise<AuditRow[]> {
|
|
68
|
+
const rows = await prisma.auditLog.findMany({
|
|
69
|
+
where: resource ? { resource } : undefined,
|
|
70
|
+
orderBy: { createdAt: "desc" },
|
|
71
|
+
take: limit,
|
|
72
|
+
});
|
|
73
|
+
return rows as unknown as AuditRow[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How many changes were recorded in the last `days` days, for the observability summary. */
|
|
77
|
+
export async function auditCount(days = 7): Promise<number> {
|
|
78
|
+
const since = new Date(Date.now() - days * 86_400_000);
|
|
79
|
+
return prisma.auditLog.count({ where: { createdAt: { gte: since } } });
|
|
80
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { prisma } from "@/lib/db";
|
|
2
|
+
import { sendTransactionalEmail } from "./mail";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every email the sign-in flows send. Change the wording here; the layout comes from
|
|
6
|
+
* lib/mail.ts. Without RESEND_API_KEY these print to the console, links and codes included.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ignore = "If you didn't ask for this, you can ignore this email.";
|
|
10
|
+
|
|
11
|
+
/** Accounts with two-factor sign-in never get a one-step email sign-in (it would skip the second factor). */
|
|
12
|
+
async function usesTwoFactor(email: string): Promise<boolean> {
|
|
13
|
+
const row = await prisma.user.findUnique({ where: { email: email.toLowerCase() }, select: { twoFactorEnabled: true } });
|
|
14
|
+
return Boolean(row?.twoFactorEnabled);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function sendTwoFactorNotice(email: string) {
|
|
18
|
+
await sendTransactionalEmail({
|
|
19
|
+
to: email,
|
|
20
|
+
subject: "Sign in with your password or a passkey",
|
|
21
|
+
heading: "Email sign-in is off for your account",
|
|
22
|
+
paragraphs: [
|
|
23
|
+
"Someone asked to sign in to your account by email. Your account uses two-factor authentication, so we don't send sign-in links or codes.",
|
|
24
|
+
"Sign in with your password and second factor, or with a passkey.",
|
|
25
|
+
],
|
|
26
|
+
footer: ignore,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function sendMagicLink({ email, url }: { email: string; url: string }) {
|
|
31
|
+
if (await usesTwoFactor(email)) return sendTwoFactorNotice(email);
|
|
32
|
+
await sendTransactionalEmail({
|
|
33
|
+
to: email,
|
|
34
|
+
subject: "Your sign-in link",
|
|
35
|
+
heading: "Sign in",
|
|
36
|
+
paragraphs: ["Use this link to sign in. It works once and expires in 5 minutes."],
|
|
37
|
+
action: { label: "Sign in", url },
|
|
38
|
+
footer: ignore,
|
|
39
|
+
preheader: "Your sign-in link",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function sendEmailCode({ email, otp, type }: { email: string; otp: string; type: "sign-in" | "email-verification" | "forget-password" | "change-email" }) {
|
|
44
|
+
if (type === "sign-in" && (await usesTwoFactor(email))) return sendTwoFactorNotice(email);
|
|
45
|
+
const purpose = {
|
|
46
|
+
"sign-in": ["Your sign-in code", "Enter this code to sign in."],
|
|
47
|
+
"email-verification": ["Verify your email", "Enter this code to verify your email address."],
|
|
48
|
+
"forget-password": ["Reset your password", "Enter this code to choose a new password."],
|
|
49
|
+
"change-email": ["Confirm your new email", "Enter this code to confirm your new email address."],
|
|
50
|
+
}[type];
|
|
51
|
+
await sendTransactionalEmail({
|
|
52
|
+
to: email,
|
|
53
|
+
subject: `${purpose[0]}: ${otp}`,
|
|
54
|
+
heading: otp,
|
|
55
|
+
paragraphs: [purpose[1], "It expires in 5 minutes."],
|
|
56
|
+
footer: ignore,
|
|
57
|
+
preheader: purpose[0],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function sendTwoFactorCode({ user, otp }: { user: { email: string }; otp: string }) {
|
|
62
|
+
await sendTransactionalEmail({
|
|
63
|
+
to: user.email,
|
|
64
|
+
subject: `Your verification code: ${otp}`,
|
|
65
|
+
heading: otp,
|
|
66
|
+
paragraphs: ["Enter this code to finish signing in. It expires in 5 minutes."],
|
|
67
|
+
footer: "If you didn't just sign in, change your password: someone else knows it.",
|
|
68
|
+
preheader: "Your verification code",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function sendPasswordReset({ user, url }: { user: { email: string }; url: string }) {
|
|
73
|
+
await sendTransactionalEmail({
|
|
74
|
+
to: user.email,
|
|
75
|
+
subject: "Reset your password",
|
|
76
|
+
heading: "Reset your password",
|
|
77
|
+
paragraphs: ["Use this link to choose a new password. It expires in 1 hour."],
|
|
78
|
+
action: { label: "Choose a new password", url },
|
|
79
|
+
footer: ignore,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function sendVerificationEmail({ user, url }: { user: { email: string }; url: string }) {
|
|
84
|
+
await sendTransactionalEmail({
|
|
85
|
+
to: user.email,
|
|
86
|
+
subject: "Verify your email",
|
|
87
|
+
heading: "Verify your email",
|
|
88
|
+
paragraphs: ["Confirm this is your email address."],
|
|
89
|
+
action: { label: "Verify email", url },
|
|
90
|
+
footer: ignore,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { after } from "next/server";
|
|
2
|
+
import { passkey } from "@better-auth/passkey";
|
|
3
|
+
import { prismaAdapter } from "@better-auth/prisma-adapter";
|
|
4
|
+
import { betterAuth } from "better-auth";
|
|
5
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
6
|
+
import { nextCookies } from "better-auth/next-js";
|
|
7
|
+
import { admin } from "better-auth/plugins/admin";
|
|
8
|
+
import { emailOTP } from "better-auth/plugins/email-otp";
|
|
9
|
+
import { magicLink } from "better-auth/plugins/magic-link";
|
|
10
|
+
import { twoFactor } from "better-auth/plugins/two-factor";
|
|
11
|
+
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
12
|
+
import { prisma } from "@/lib/db";
|
|
13
|
+
import { authConfig, type SocialProvider } from "./auth-config";
|
|
14
|
+
import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
|
|
15
|
+
import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
|
|
16
|
+
|
|
17
|
+
const vars = process.env as Record<string, string | undefined>;
|
|
18
|
+
|
|
19
|
+
/** Optional: set when serving from a custom domain (e.g. https://example.com). */
|
|
20
|
+
const configuredURL = vars.BETTER_AUTH_URL;
|
|
21
|
+
|
|
22
|
+
/** A provider's credentials from the environment, when both are set. */
|
|
23
|
+
function credentials(prefix: string) {
|
|
24
|
+
const clientId = vars[`${prefix}_CLIENT_ID`];
|
|
25
|
+
const clientSecret = vars[`${prefix}_CLIENT_SECRET`];
|
|
26
|
+
return clientId && clientSecret ? { clientId, clientSecret } : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const providerSettings: Record<SocialProvider, Record<string, string> | undefined> = {
|
|
30
|
+
google: credentials("GOOGLE"),
|
|
31
|
+
github: credentials("GITHUB"),
|
|
32
|
+
// Apple's client secret is a JWT you generate from your key (see the auth guide).
|
|
33
|
+
apple: credentials("APPLE") && { ...credentials("APPLE")!, ...(vars.APPLE_APP_BUNDLE_ID ? { appBundleIdentifier: vars.APPLE_APP_BUNDLE_ID } : {}) },
|
|
34
|
+
// "common" accepts personal and work accounts; set a tenant id to allow only your organisation.
|
|
35
|
+
microsoft: credentials("MICROSOFT") && { ...credentials("MICROSOFT")!, tenantId: vars.MICROSOFT_TENANT_ID || "common" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Social providers switched on in lib/auth-config.ts that also have credentials. */
|
|
39
|
+
const socialProviders = Object.fromEntries(
|
|
40
|
+
authConfig.social.flatMap((provider) => (providerSettings[provider] ? [[provider, providerSettings[provider]]] : [])),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Endpoints of methods switched off in lib/auth-config.ts: refused, not just hidden. */
|
|
44
|
+
function disabledPath(path: string): boolean {
|
|
45
|
+
if (!authConfig.magicLink && (path === "/sign-in/magic-link" || path === "/magic-link/verify")) return true;
|
|
46
|
+
if (!authConfig.emailOtp && (path.startsWith("/email-otp/") || path === "/sign-in/email-otp" || path === "/forget-password/email-otp")) return true;
|
|
47
|
+
if (!authConfig.passkeys && path.startsWith("/passkey/")) return true;
|
|
48
|
+
const { authenticator, email } = authConfig.twoFactor;
|
|
49
|
+
if (!authenticator && !email && path.startsWith("/two-factor/")) return true;
|
|
50
|
+
if (!authenticator && (path === "/two-factor/get-totp-uri" || path === "/two-factor/verify-totp")) return true;
|
|
51
|
+
if (!email && (path === "/two-factor/send-otp" || path === "/two-factor/verify-otp")) return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where a new password arrives, and what it's called in the body. */
|
|
56
|
+
const NEW_PASSWORD_FIELDS: Record<string, string> = {
|
|
57
|
+
"/sign-up/email": "password",
|
|
58
|
+
"/reset-password": "newPassword",
|
|
59
|
+
"/change-password": "newPassword",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same password rules the sign-up and reset forms show as you type
|
|
64
|
+
* (lib/password-rules.ts), enforced here so the API agrees with the screens.
|
|
65
|
+
* Checked on the way in, before the password is hashed or stored.
|
|
66
|
+
*/
|
|
67
|
+
async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
|
|
68
|
+
const field = NEW_PASSWORD_FIELDS[path];
|
|
69
|
+
if (!field) return;
|
|
70
|
+
const password = body?.[field];
|
|
71
|
+
if (typeof password !== "string") return;
|
|
72
|
+
|
|
73
|
+
if (password.length > PASSWORD_MAX_LENGTH) {
|
|
74
|
+
throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
|
|
75
|
+
}
|
|
76
|
+
const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
|
|
77
|
+
if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
|
|
78
|
+
|
|
79
|
+
// A password in a public breach list is already guessed, however strong it looks.
|
|
80
|
+
if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
|
|
81
|
+
throw new APIError("BAD_REQUEST", {
|
|
82
|
+
message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const auth = betterAuth({
|
|
88
|
+
appName: "__APP_NAME__",
|
|
89
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
90
|
+
// Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
|
|
91
|
+
// this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
|
|
92
|
+
// deployed app never sees a trycloudflare.com host and the allowlist can't be spoofed.
|
|
93
|
+
baseURL: configuredURL || {
|
|
94
|
+
allowedHosts: ["localhost:*", "127.0.0.1:*", "*.trycloudflare.com", "__APP_NAME__.*.workers.dev"],
|
|
95
|
+
},
|
|
96
|
+
// Sign in with Apple posts back from Apple's own origin.
|
|
97
|
+
trustedOrigins: authConfig.social.includes("apple") ? ["https://appleid.apple.com"] : [],
|
|
98
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
99
|
+
socialProviders,
|
|
100
|
+
account: {
|
|
101
|
+
// Signing in with Google as ada@example.com joins Ada's existing account (the provider has verified the address).
|
|
102
|
+
accountLinking: { enabled: true, trustedProviders: ["google", "github", "apple", "microsoft"] },
|
|
103
|
+
},
|
|
104
|
+
emailAndPassword: {
|
|
105
|
+
enabled: true,
|
|
106
|
+
requireEmailVerification: authConfig.requireEmailVerification,
|
|
107
|
+
// PBKDF2 via WebCrypto: the default scrypt exceeds Workers CPU limits.
|
|
108
|
+
password: { hash: hashPassword, verify: verifyPassword },
|
|
109
|
+
sendResetPassword: ({ user, url }) => sendPasswordReset({ user, url }),
|
|
110
|
+
resetPasswordTokenExpiresIn: 60 * 60,
|
|
111
|
+
// A password reset signs every other device out.
|
|
112
|
+
revokeSessionsOnPasswordReset: true,
|
|
113
|
+
},
|
|
114
|
+
emailVerification: {
|
|
115
|
+
sendVerificationEmail: ({ user, url }) => sendVerificationEmail({ user, url }),
|
|
116
|
+
sendOnSignUp: true,
|
|
117
|
+
autoSignInAfterVerification: true,
|
|
118
|
+
},
|
|
119
|
+
advanced: {
|
|
120
|
+
// Let emails and other deferred work finish after the response is sent.
|
|
121
|
+
// Next's equivalent of waitUntil: work that outlives the response, so sending a
|
|
122
|
+
// verification email doesn't hold the sign-up request open.
|
|
123
|
+
backgroundTasks: { handler: (promise: Promise<unknown>) => after(() => promise) },
|
|
124
|
+
},
|
|
125
|
+
hooks: {
|
|
126
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
127
|
+
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
128
|
+
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
plugins: [
|
|
132
|
+
// Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
|
|
133
|
+
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
|
134
|
+
magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
|
|
135
|
+
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
136
|
+
twoFactor({
|
|
137
|
+
issuer: "__APP_NAME__",
|
|
138
|
+
otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
|
|
139
|
+
// With only email codes switched on there's no authenticator app to confirm first.
|
|
140
|
+
skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
|
|
141
|
+
// People who signed up with a social account have no password to confirm with;
|
|
142
|
+
// accounts that do have one must still give it.
|
|
143
|
+
allowPasswordless: true,
|
|
144
|
+
}),
|
|
145
|
+
passkey({ rpName: "__APP_NAME__" }),
|
|
146
|
+
// Must stay last: lets server actions set auth cookies.
|
|
147
|
+
nextCookies(),
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export type Session = typeof auth.$Infer.Session;
|
|
152
|
+
|
|
153
|
+
/** OAuth providers switched on and configured, e.g. ["github"], for the sign-in buttons. */
|
|
154
|
+
export function enabledSocialProviders(): SocialProvider[] {
|
|
155
|
+
return Object.keys(socialProviders) as SocialProvider[];
|
|
156
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { notFound, redirect } from "next/navigation";
|
|
3
|
+
import { allowedActions, can, columnName, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
|
+
import { createResourceStore, prismaRows, type PrismaDelegate, type ResourceStore } from "@flaredev/core/server";
|
|
5
|
+
import { prisma } from "@/lib/db";
|
|
6
|
+
import { policies } from "@/policies";
|
|
7
|
+
import { resourceTables } from "@/resources/server";
|
|
8
|
+
import { currentUser } from "./api";
|
|
9
|
+
import { auth } from "./auth";
|
|
10
|
+
import { cached, resourceTag, revalidateResource, TTL } from "./cache";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The dashboard's view of a resource, on the Next.js stack.
|
|
14
|
+
*
|
|
15
|
+
* The same shape as the Cloudflare one — same exports, same behaviour — over Prisma and
|
|
16
|
+
* Postgres instead of Drizzle and D1. The store itself is shared: only where its rows
|
|
17
|
+
* come from differs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const ADMIN_ROLES = ["admin", "staff"];
|
|
21
|
+
|
|
22
|
+
export function policyFor(resourceName: string): Policy | undefined {
|
|
23
|
+
return (policies as Record<string, Policy | undefined>)[resourceName];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function dashboardSession() {
|
|
27
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
28
|
+
if (!session) return { session: null, role: null, allowed: false } as const;
|
|
29
|
+
const role = (session.user as { role?: string | null }).role ?? null;
|
|
30
|
+
const readable = allResources().some((resource) => can(policyFor(resource.name), role, "read"));
|
|
31
|
+
return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
|
|
35
|
+
export async function requireDashboard(returnTo = "/dashboard") {
|
|
36
|
+
const { session, role, allowed } = await dashboardSession();
|
|
37
|
+
if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
|
|
38
|
+
if (!allowed) redirect("/?error=forbidden");
|
|
39
|
+
return { session, role };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* For resource pages: also requires the policy to allow `action` on this resource.
|
|
44
|
+
* Sends the user somewhere they can actually go — the list when they may read it,
|
|
45
|
+
* the dashboard when they may not.
|
|
46
|
+
*/
|
|
47
|
+
export async function requireAccess(resource: Resource, action: PolicyAction) {
|
|
48
|
+
const { session, role } = await requireDashboard(resourcePath(resource));
|
|
49
|
+
const policy = policyFor(resource.name);
|
|
50
|
+
if (!can(policy, role, action)) {
|
|
51
|
+
redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
|
|
52
|
+
}
|
|
53
|
+
return { session, role };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What the current user may do with a resource, for hiding actions they can't use. */
|
|
57
|
+
export async function adminPermissions(resourceName: string) {
|
|
58
|
+
const { role } = await dashboardSession();
|
|
59
|
+
return allowedActions(policyFor(resourceName), role);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const stores = new Map<string, ResourceStore>();
|
|
63
|
+
|
|
64
|
+
/** CRUD store for a resource by name (404 for unknown names). */
|
|
65
|
+
export function dashboardStore(name: string): ResourceStore {
|
|
66
|
+
const entry = (resourceTables as unknown as Record<string, { resource: Resource; delegate: PrismaDelegate } | undefined>)[name];
|
|
67
|
+
if (!entry) notFound();
|
|
68
|
+
let store = stores.get(name);
|
|
69
|
+
if (!store) {
|
|
70
|
+
store = createResourceStore({
|
|
71
|
+
resource: entry.resource,
|
|
72
|
+
rows: prismaRows(entry.delegate, prisma),
|
|
73
|
+
onChange: revalidateResource,
|
|
74
|
+
currentUser,
|
|
75
|
+
});
|
|
76
|
+
stores.set(name, store);
|
|
77
|
+
}
|
|
78
|
+
return store;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every number the stats above a resource's table need, in one query.
|
|
83
|
+
*
|
|
84
|
+
* The total, how many arrived in the last week and the week before, and how many hold
|
|
85
|
+
* each value of a status field — one grouped scan, summed up here. Written as SQL
|
|
86
|
+
* because Prisma's aggregation API can't express "count these rows, and also count the
|
|
87
|
+
* subset of them created since Tuesday" in a single round trip.
|
|
88
|
+
*
|
|
89
|
+
* The table and column names come from the descriptor, never from a request, and are
|
|
90
|
+
* quoted; nothing a visitor types reaches this.
|
|
91
|
+
*/
|
|
92
|
+
export interface ResourceStats {
|
|
93
|
+
total: number;
|
|
94
|
+
/** Created in the last `days` days, and in the `days` before that. */
|
|
95
|
+
current: number;
|
|
96
|
+
previous: number;
|
|
97
|
+
/** Rows per value of the grouped field, when one was asked for. */
|
|
98
|
+
values: Record<string, number>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
|
|
102
|
+
const read = cached(
|
|
103
|
+
async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
|
|
104
|
+
const entry = (resourceTables as unknown as Record<string, { resource: Resource } | undefined>)[resourceName];
|
|
105
|
+
if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
|
|
106
|
+
|
|
107
|
+
const table = `"${entry.resource.table}"`;
|
|
108
|
+
const grouped = key && entry.resource.fields[key] ? `"${columnName(key)}"` : undefined;
|
|
109
|
+
const window = windowDays * 86_400_000;
|
|
110
|
+
const since = new Date(Date.now() - window).toISOString();
|
|
111
|
+
const before = new Date(Date.now() - window * 2).toISOString();
|
|
112
|
+
|
|
113
|
+
const select = [
|
|
114
|
+
"count(*)::int as total",
|
|
115
|
+
`count(*) filter (where "created_at" >= '${since}')::int as current`,
|
|
116
|
+
`count(*) filter (where "created_at" >= '${before}' and "created_at" < '${since}')::int as previous`,
|
|
117
|
+
...(grouped ? [`${grouped} as value`] : []),
|
|
118
|
+
].join(", ");
|
|
119
|
+
const query = `select ${select} from ${table}${grouped ? ` group by ${grouped}` : ""}`;
|
|
120
|
+
|
|
121
|
+
const rows = (await prisma.$queryRawUnsafe(query)) as { total: number; current: number; previous: number; value?: unknown }[];
|
|
122
|
+
const stats: ResourceStats = { total: 0, current: 0, previous: 0, values: {} };
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
stats.total += Number(row.total ?? 0);
|
|
125
|
+
stats.current += Number(row.current ?? 0);
|
|
126
|
+
stats.previous += Number(row.previous ?? 0);
|
|
127
|
+
if (grouped) stats.values[String(row.value ?? "")] = Number(row.total ?? 0);
|
|
128
|
+
}
|
|
129
|
+
return stats;
|
|
130
|
+
},
|
|
131
|
+
["flare", "resource-stats"],
|
|
132
|
+
{ tags: [resourceTag(name)], revalidate: TTL.medium },
|
|
133
|
+
);
|
|
134
|
+
return read(name, field, days);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** How many records a resource has. One field of {@link resourceStats}. */
|
|
138
|
+
export async function recordCount(name: string): Promise<number> {
|
|
139
|
+
return (await resourceStats(name)).total;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Percentage change between two periods, or undefined when there's nothing to compare. */
|
|
143
|
+
export function trend(current: number, previous: number): number | undefined {
|
|
144
|
+
if (previous === 0) return current === 0 ? undefined : 100;
|
|
145
|
+
return ((current - previous) / previous) * 100;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every resource descriptor (unfiltered): navigation uses `visibleResources()` instead. */
|
|
149
|
+
export function allResources(): Resource[] {
|
|
150
|
+
return Object.values(resourceTables as unknown as Record<string, { resource: Resource }>).map((entry) => entry.resource);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Resources the current user may read, for the sidebar and dashboard. */
|
|
154
|
+
export async function visibleResources(): Promise<Resource[]> {
|
|
155
|
+
const { role } = await dashboardSession();
|
|
156
|
+
return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resourcePath(resource: Resource, ...parts: string[]) {
|
|
160
|
+
return ["/dashboard", resource.slug, ...parts].join("/");
|
|
161
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
2
|
+
import { PrismaClient } from "@/lib/generated/prisma/client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Prisma client, one per process.
|
|
6
|
+
*
|
|
7
|
+
* Prisma 7 needs a driver adapter rather than a connection string in the schema. This
|
|
8
|
+
* one is the ordinary Postgres driver, which talks to anything that speaks Postgres:
|
|
9
|
+
* Neon's pooled endpoint, Supabase, RDS, or the container you run while developing.
|
|
10
|
+
* Next runs on Node, so there is nothing to gain from a serverless HTTP driver and a
|
|
11
|
+
* great deal to lose — `@prisma/adapter-neon` can't reach a database on localhost.
|
|
12
|
+
*
|
|
13
|
+
* Next reloads modules on every edit in development, and a fresh client each time would
|
|
14
|
+
* exhaust the connection limit within a few saves; hence the global.
|
|
15
|
+
*/
|
|
16
|
+
const makeClient = () => new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
|
17
|
+
|
|
18
|
+
const globalForPrisma = globalThis as unknown as { prisma?: ReturnType<typeof makeClient> };
|
|
19
|
+
|
|
20
|
+
export const prisma = globalForPrisma.prisma ?? makeClient();
|
|
21
|
+
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
|
22
|
+
|
|
23
|
+
/** Named to match the Cloudflare stack, so code that takes a database reads the same. */
|
|
24
|
+
export const getDb = () => prisma;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createMailer, renderTransactionalEmail, type TransactionalEmail } from "@flaredev/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transactional email via Resend.
|
|
5
|
+
*
|
|
6
|
+
* Without RESEND_API_KEY (e.g. local dev) emails are printed to the console
|
|
7
|
+
* instead of sent. MAIL_FROM must use a domain verified in Resend. The fallback
|
|
8
|
+
* sandbox sender only delivers to your own Resend account address.
|
|
9
|
+
*/
|
|
10
|
+
export const mailer = createMailer({
|
|
11
|
+
apiKey: process.env.RESEND_API_KEY,
|
|
12
|
+
from: process.env.MAIL_FROM || "__APP_NAME__ <onboarding@resend.dev>",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render and send a transactional email. Resolves to `{ data, error }` and never throws for API errors.
|
|
17
|
+
*
|
|
18
|
+
* const { error } = await sendTransactionalEmail({
|
|
19
|
+
* to: user.email,
|
|
20
|
+
* subject: "Your order has shipped",
|
|
21
|
+
* heading: "On its way",
|
|
22
|
+
* paragraphs: [`Order #${order.id} left our warehouse today.`],
|
|
23
|
+
* action: { label: "Track order", url: `https://example.com/orders/${order.id}` },
|
|
24
|
+
* idempotencyKey: `order-shipped/${order.id}`,
|
|
25
|
+
* });
|
|
26
|
+
*/
|
|
27
|
+
export async function sendTransactionalEmail({
|
|
28
|
+
to,
|
|
29
|
+
subject,
|
|
30
|
+
idempotencyKey,
|
|
31
|
+
...content
|
|
32
|
+
}: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
|
|
33
|
+
const { html, text } = renderTransactionalEmail({ appName: "__APP_NAME__", ...content });
|
|
34
|
+
return mailer.send({ to, subject, html, text, idempotencyKey });
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime, on the Next.js stack.
|
|
3
|
+
*
|
|
4
|
+
* The Cloudflare stack gives every channel a Durable Object: one address, one thread,
|
|
5
|
+
* its own storage, and websockets that stay open. Vercel has no equivalent — functions
|
|
6
|
+
* don't hold connections — so rather than pretend, this says so, and publishing is a
|
|
7
|
+
* no-op that never breaks a write.
|
|
8
|
+
*
|
|
9
|
+
* If you need it here, the shape to reach for is a hosted pub/sub (Ably, Pusher,
|
|
10
|
+
* Upstash) behind these same two functions.
|
|
11
|
+
*/
|
|
12
|
+
import type { RealtimeAuthorize } from "@flaredev/core/realtime/server";
|
|
13
|
+
|
|
14
|
+
export const authorizeRealtime: RealtimeAuthorize = () => false;
|
|
15
|
+
|
|
16
|
+
export interface RealtimeChannel {
|
|
17
|
+
publish(event: string, data: unknown): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A channel that accepts publishes and drops them, so callers need no special case. */
|
|
21
|
+
export function realtimeChannel(_name: string): RealtimeChannel {
|
|
22
|
+
return { publish: async () => undefined };
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Redis } from "@upstash/redis";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Upstash Redis, over HTTP.
|
|
5
|
+
*
|
|
6
|
+
* Next's own data cache (`cached()` in lib/cache.ts) handles query caching. This is for
|
|
7
|
+
* the things a cache can't do: rate limits, locks, anything that has to be true across
|
|
8
|
+
* every instance at once. It talks HTTP rather than a socket, so a serverless function
|
|
9
|
+
* needs no connection pool.
|
|
10
|
+
*
|
|
11
|
+
* Unset credentials give you `null` rather than a crash, so an app that doesn't use it
|
|
12
|
+
* doesn't have to configure it.
|
|
13
|
+
*/
|
|
14
|
+
export const redis =
|
|
15
|
+
process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
|
|
16
|
+
? new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN })
|
|
17
|
+
: null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AwsClient } from "aws4fetch";
|
|
2
|
+
import { createObjectKey, createStorage } from "@flaredev/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* File storage on R2, over its S3-compatible API.
|
|
6
|
+
*
|
|
7
|
+
* R2 rather than Vercel Blob for one reason: R2 charges nothing to serve what it
|
|
8
|
+
* stores. On a file-heavy app that is the difference between a small bill and a large
|
|
9
|
+
* one, and it costs nothing to use from outside Cloudflare — see the cost guide.
|
|
10
|
+
*
|
|
11
|
+
* `aws4fetch` signs the requests; it is a few kilobytes and works in any runtime, unlike
|
|
12
|
+
* the AWS SDK. Everything above this — the upload widget, the signed-URL helpers, the
|
|
13
|
+
* file field — is the same code the Cloudflare stack runs.
|
|
14
|
+
*/
|
|
15
|
+
const client = new AwsClient({
|
|
16
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
|
|
17
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
|
|
18
|
+
service: "s3",
|
|
19
|
+
region: "auto",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const endpoint = `${process.env.R2_ENDPOINT ?? ""}/${process.env.R2_BUCKET ?? ""}`;
|
|
23
|
+
|
|
24
|
+
/** The bucket, shaped the way @flaredev/core expects, over plain HTTP calls. */
|
|
25
|
+
const bucket = {
|
|
26
|
+
async get(key: string) {
|
|
27
|
+
const response = await client.fetch(`${endpoint}/${encodeURIComponent(key)}`);
|
|
28
|
+
if (!response.ok || !response.body) return null;
|
|
29
|
+
return {
|
|
30
|
+
body: response.body,
|
|
31
|
+
httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
|
|
32
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
33
|
+
// R2 returns the object's ETag quoted, which is what the header wants back.
|
|
34
|
+
httpEtag: response.headers.get("etag") ?? "",
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
async put(key: string, value: ReadableStream | ArrayBuffer | string | null, options?: { httpMetadata?: { contentType?: string } }) {
|
|
38
|
+
await client.fetch(`${endpoint}/${encodeURIComponent(key)}`, {
|
|
39
|
+
method: "PUT",
|
|
40
|
+
body: value,
|
|
41
|
+
headers: options?.httpMetadata?.contentType ? { "content-type": options.httpMetadata.contentType } : undefined,
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
async delete(keys: string | string[]) {
|
|
45
|
+
for (const key of [keys].flat()) {
|
|
46
|
+
await client.fetch(`${endpoint}/${encodeURIComponent(key)}`, { method: "DELETE" });
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const storage = createStorage({
|
|
52
|
+
bucket,
|
|
53
|
+
// Signing keys are derived from the auth secret with a storage-specific label.
|
|
54
|
+
secret: process.env.BETTER_AUTH_SECRET ?? "",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export { createObjectKey };
|