@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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/LICENSE +21 -0
- package/README.md +68 -2
- package/assets/auth.sqlite.schema.ts +479 -0
- package/assets/bun-sqlite.test.ts +46 -0
- package/assets/d1.test.ts +41 -0
- package/assets/drizzle.config.ts +2 -0
- package/assets/generate-auth-schema.mjs +25 -0
- package/assets/platform.sqlite.schema.ts +46 -0
- package/assets/polar-checkout.ts +10 -0
- package/assets/polar-route.ts +19 -0
- package/assets/polar.test.ts +30 -0
- package/assets/polar.ts +12 -0
- package/assets/purchase.bun-sqlite.ts +38 -0
- package/assets/purchase.sqlite.ts +39 -0
- package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
- package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
- package/assets/sqlite-migrations/meta/_journal.json +13 -0
- package/assets/turso.test.ts +40 -0
- package/package.json +44 -5
- package/src/adapters/database.mjs +448 -0
- package/src/adapters/deployment.mjs +101 -0
- package/src/adapters/email.mjs +75 -0
- package/src/adapters/payments.mjs +270 -0
- package/src/catalog.mjs +128 -0
- package/src/cli.mjs +294 -0
- package/src/generate.mjs +183 -0
- package/src/template.mjs +93 -0
- package/template/.env.docker +16 -0
- package/template/.env.example +67 -0
- package/template/DESIGN.md +58 -0
- package/template/LICENSE +21 -0
- package/template/biome.json +46 -0
- package/template/docker-compose.yml +47 -0
- package/template/docs/project-guide.md +61 -0
- package/template/drizzle/0000_create-app-role.sql +54 -0
- package/template/drizzle/0001_add-rls-context.sql +21 -0
- package/template/drizzle/0002_platform_tables.sql +320 -0
- package/template/drizzle/meta/0002_snapshot.json +2125 -0
- package/template/drizzle/meta/_journal.json +27 -0
- package/template/drizzle.config.ts +25 -0
- package/template/e2e/authenticated-ui.spec.ts +40 -0
- package/template/e2e/headless-design.spec.ts +66 -0
- package/template/e2e/navigation-loading.spec.ts +70 -0
- package/template/e2e/theme-responsive.spec.ts +260 -0
- package/template/gitignore +24 -0
- package/template/messages/de.json +212 -0
- package/template/messages/en.json +212 -0
- package/template/package.json +101 -0
- package/template/paraglide.config.ts +49 -0
- package/template/playwright.config.ts +58 -0
- package/template/project.inlang/settings.json +12 -0
- package/template/public/drizzle.svg +1 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logo192.png +0 -0
- package/template/public/logo512.png +0 -0
- package/template/public/manifest.json +25 -0
- package/template/public/robots.txt +3 -0
- package/template/scripts/check-client-bundle.ts +150 -0
- package/template/scripts/check-server-fns.ts +290 -0
- package/template/scripts/check-sql-raw.ts +50 -0
- package/template/scripts/compile-paraglide.ts +12 -0
- package/template/scripts/postgres/README.md +76 -0
- package/template/scripts/postgres/create-app-role.sh +60 -0
- package/template/scripts/seed-local.ts +331 -0
- package/template/scripts/validate-prod-env.ts +53 -0
- package/template/src/__tests__/README.md +9 -0
- package/template/src/__tests__/account-security.test.ts +664 -0
- package/template/src/__tests__/admin-http-surface.test.ts +311 -0
- package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
- package/template/src/__tests__/auth-harness.ts +104 -0
- package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
- package/template/src/__tests__/auth-redirect.test.ts +120 -0
- package/template/src/__tests__/body-limit.test.ts +47 -0
- package/template/src/__tests__/creem.test.ts +62 -0
- package/template/src/__tests__/demo-checkout.test.ts +141 -0
- package/template/src/__tests__/email-locale.test.ts +110 -0
- package/template/src/__tests__/global-setup.ts +80 -0
- package/template/src/__tests__/input-rules.test.ts +0 -0
- package/template/src/__tests__/message-parity.test.ts +38 -0
- package/template/src/__tests__/navigation-loading.test.tsx +252 -0
- package/template/src/__tests__/nowpayments.test.ts +106 -0
- package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
- package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
- package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
- package/template/src/__tests__/rls-policy.test.ts +120 -0
- package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
- package/template/src/__tests__/search-params-oauth.test.ts +57 -0
- package/template/src/__tests__/security-headers.test.ts +12 -0
- package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
- package/template/src/__tests__/stripe.test.ts +84 -0
- package/template/src/__tests__/test-db.ts +32 -0
- package/template/src/__tests__/test-rls-db.ts +23 -0
- package/template/src/__tests__/user-update-guard.test.ts +109 -0
- package/template/src/components/LocaleSwitcher.tsx +79 -0
- package/template/src/components/ThemeToggle.tsx +47 -0
- package/template/src/components/auth/social.tsx +90 -0
- package/template/src/components/auth/turnstile.tsx +67 -0
- package/template/src/components/root/navigation-progress.tsx +32 -0
- package/template/src/components/root/not-found.tsx +20 -0
- package/template/src/components/root/pending.tsx +9 -0
- package/template/src/components/root/root-document.tsx +37 -0
- package/template/src/components/root/root-error.tsx +34 -0
- package/template/src/components/shells.tsx +201 -0
- package/template/src/components/ui/styles.ts +9 -0
- package/template/src/db/auth.schema.ts +455 -0
- package/template/src/db/helper.ts +38 -0
- package/template/src/db/index.ts +15 -0
- package/template/src/db/platform.schema.ts +122 -0
- package/template/src/db/schema.ts +2 -0
- package/template/src/env.ts +65 -0
- package/template/src/hooks/use-theme.ts +32 -0
- package/template/src/integrations/better-auth/header-user.tsx +36 -0
- package/template/src/integrations/better-auth/session-query.ts +11 -0
- package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
- package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
- package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
- package/template/src/integrations/react-email/theme.ts +75 -0
- package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
- package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
- package/template/src/lib/auth-client.ts +27 -0
- package/template/src/lib/auth-paths.ts +94 -0
- package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
- package/template/src/lib/auth-plugins/field-rules.ts +84 -0
- package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
- package/template/src/lib/auth-timing.ts +9 -0
- package/template/src/lib/auth.ts +279 -0
- package/template/src/lib/captcha.ts +11 -0
- package/template/src/lib/email.tsx +162 -0
- package/template/src/lib/github.ts +18 -0
- package/template/src/lib/locale-cookie.ts +12 -0
- package/template/src/lib/localized-pages.ts +25 -0
- package/template/src/lib/oauth-metadata.ts +19 -0
- package/template/src/lib/permissions.ts +29 -0
- package/template/src/lib/security-headers.ts +20 -0
- package/template/src/lib/site.ts +59 -0
- package/template/src/lib/theme.ts +42 -0
- package/template/src/lib/user-update-guard.ts +35 -0
- package/template/src/middlewares/admin.ts +46 -0
- package/template/src/middlewares/auth-rate-limit.ts +37 -0
- package/template/src/middlewares/better-auth.ts +15 -0
- package/template/src/middlewares/body-limit.ts +62 -0
- package/template/src/middlewares/database.ts +13 -0
- package/template/src/middlewares/protected.ts +45 -0
- package/template/src/routeTree.gen.ts +536 -0
- package/template/src/router.tsx +53 -0
- package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
- package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
- package/template/src/routes/__root.tsx +175 -0
- package/template/src/routes/admin/audit.tsx +74 -0
- package/template/src/routes/admin/index.tsx +7 -0
- package/template/src/routes/admin/oauth-apps.tsx +164 -0
- package/template/src/routes/admin/route.tsx +27 -0
- package/template/src/routes/admin/users.tsx +215 -0
- package/template/src/routes/api/auth/$.ts +14 -0
- package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
- package/template/src/routes/api/webhooks/creem.ts +69 -0
- package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
- package/template/src/routes/api/webhooks/stripe.ts +74 -0
- package/template/src/routes/dashboard/account.tsx +430 -0
- package/template/src/routes/dashboard/index.tsx +128 -0
- package/template/src/routes/dashboard/route.tsx +17 -0
- package/template/src/routes/forgot-password.tsx +65 -0
- package/template/src/routes/index.tsx +85 -0
- package/template/src/routes/oauth/consent.tsx +77 -0
- package/template/src/routes/reset-password.tsx +78 -0
- package/template/src/routes/sign-in.tsx +174 -0
- package/template/src/routes/sign-up.tsx +135 -0
- package/template/src/routes/two-factor.tsx +74 -0
- package/template/src/server/README.md +3 -0
- package/template/src/server/account-security.ts +163 -0
- package/template/src/server/admin-audit.ts +37 -0
- package/template/src/server/admin-users.ts +112 -0
- package/template/src/server/auth.ts +44 -0
- package/template/src/server/demo-checkout.ts +67 -0
- package/template/src/server/oauth-clients.ts +83 -0
- package/template/src/server/payment-toggles.ts +39 -0
- package/template/src/server.ts +20 -0
- package/template/src/services/README.md +3 -0
- package/template/src/services/account-security.ts +893 -0
- package/template/src/services/admin-security.ts +164 -0
- package/template/src/services/admin-users.ts +181 -0
- package/template/src/services/auth-rate-limit.ts +32 -0
- package/template/src/services/creem.ts +83 -0
- package/template/src/services/demo-checkout.ts +100 -0
- package/template/src/services/nowpayments.ts +154 -0
- package/template/src/services/oauth-client.ts +85 -0
- package/template/src/services/oauth-clients.ts +15 -0
- package/template/src/services/payment-providers.ts +157 -0
- package/template/src/services/payment-toggles.ts +48 -0
- package/template/src/services/purchase.ts +233 -0
- package/template/src/services/stripe.ts +106 -0
- package/template/src/start.ts +35 -0
- package/template/src/styles/layout.css +93 -0
- package/template/src/styles/theme.css +84 -0
- package/template/src/styles/ui.css +233 -0
- package/template/src/styles.css +90 -0
- package/template/src/utils/api-error.ts +129 -0
- package/template/src/utils/auth-errors.ts +157 -0
- package/template/src/utils/auth-redirect.ts +61 -0
- package/template/src/utils/body-limit.ts +84 -0
- package/template/src/utils/cn.ts +6 -0
- package/template/src/utils/input-rules.ts +172 -0
- package/template/src/utils/locale-paths.ts +18 -0
- package/template/src/utils/same-page-locale-href.ts +20 -0
- package/template/src/utils/search-params.ts +71 -0
- package/template/tsconfig.json +30 -0
- package/template/tsr.config.json +6 -0
- package/template/vite.config.ts +24 -0
- package/template/vitest.config.ts +26 -0
- package/template/worker-configuration.d.ts +14660 -0
- package/template/wrangler.jsonc +39 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import * as schema from "@/db/schema";
|
|
4
|
+
|
|
5
|
+
export function createDB(connection: Hyperdrive) {
|
|
6
|
+
const client = new Pool({
|
|
7
|
+
connectionString: connection.connectionString,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
return drizzle(client, { schema });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Database = ReturnType<typeof createDB>;
|
|
14
|
+
export type DatabaseTxCallback = Parameters<Database["transaction"]>[0];
|
|
15
|
+
export type DatabaseTx = Parameters<DatabaseTxCallback>[0];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform tables owned by the Starter (not Better Auth generated).
|
|
3
|
+
* Purchase, audit log, system_config — product domain tables stay out.
|
|
4
|
+
*/
|
|
5
|
+
import { sql } from "drizzle-orm";
|
|
6
|
+
import {
|
|
7
|
+
index,
|
|
8
|
+
jsonb,
|
|
9
|
+
pgPolicy,
|
|
10
|
+
pgRole,
|
|
11
|
+
pgTable,
|
|
12
|
+
text,
|
|
13
|
+
timestamp,
|
|
14
|
+
uniqueIndex,
|
|
15
|
+
} from "drizzle-orm/pg-core";
|
|
16
|
+
import { user } from "./auth.schema";
|
|
17
|
+
|
|
18
|
+
/** App role used by Hyperdrive connections (RLS policies target this). */
|
|
19
|
+
export const appRole = pgRole("starter_app", { createRole: false }).existing();
|
|
20
|
+
|
|
21
|
+
export type JsonValue =
|
|
22
|
+
| string
|
|
23
|
+
| number
|
|
24
|
+
| boolean
|
|
25
|
+
| null
|
|
26
|
+
| { [key: string]: JsonValue }
|
|
27
|
+
| JsonValue[];
|
|
28
|
+
|
|
29
|
+
export const systemConfig = pgTable(
|
|
30
|
+
"system_config",
|
|
31
|
+
{
|
|
32
|
+
key: text("key").primaryKey(),
|
|
33
|
+
value: jsonb("value").$type<JsonValue>().notNull(),
|
|
34
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
35
|
+
.defaultNow()
|
|
36
|
+
.$onUpdate(() => new Date())
|
|
37
|
+
.notNull(),
|
|
38
|
+
},
|
|
39
|
+
() => [
|
|
40
|
+
pgPolicy("rls_policy", {
|
|
41
|
+
as: "permissive",
|
|
42
|
+
to: appRole,
|
|
43
|
+
for: "all",
|
|
44
|
+
using: sql`app_private.is_admin()`,
|
|
45
|
+
withCheck: sql`app_private.is_admin()`,
|
|
46
|
+
}),
|
|
47
|
+
],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const adminAction = pgTable(
|
|
51
|
+
"admin_action",
|
|
52
|
+
{
|
|
53
|
+
id: text("id").primaryKey(),
|
|
54
|
+
actorId: text("actor_id").references(() => user.id, {
|
|
55
|
+
onDelete: "set null",
|
|
56
|
+
}),
|
|
57
|
+
action: text("action").notNull(),
|
|
58
|
+
targetType: text("target_type"),
|
|
59
|
+
targetId: text("target_id"),
|
|
60
|
+
detail: jsonb("detail").$type<JsonValue>(),
|
|
61
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
62
|
+
.defaultNow()
|
|
63
|
+
.notNull(),
|
|
64
|
+
},
|
|
65
|
+
(t) => [
|
|
66
|
+
pgPolicy("rls_policy", {
|
|
67
|
+
as: "permissive",
|
|
68
|
+
to: appRole,
|
|
69
|
+
for: "all",
|
|
70
|
+
using: sql`app_private.is_admin()`,
|
|
71
|
+
withCheck: sql`app_private.is_admin()`,
|
|
72
|
+
}),
|
|
73
|
+
index("admin_action_created_idx").on(t.createdAt.desc()),
|
|
74
|
+
],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export const purchase = pgTable(
|
|
78
|
+
"purchase",
|
|
79
|
+
{
|
|
80
|
+
id: text("id").primaryKey(),
|
|
81
|
+
userId: text("user_id")
|
|
82
|
+
.notNull()
|
|
83
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
84
|
+
provider: text("provider").notNull(), // stripe | creem | nowpayments
|
|
85
|
+
externalId: text("external_id").notNull(),
|
|
86
|
+
// Minor units as decimal text (e.g. "1999" cents). Avoid float JSON;
|
|
87
|
+
// adapters parse with parseInt, not Number(), for safe integers.
|
|
88
|
+
amount: text("amount").notNull(),
|
|
89
|
+
currency: text("currency").notNull().default("usd"),
|
|
90
|
+
status: text("status").notNull().default("pending"), // pending | paid | failed
|
|
91
|
+
metadata: jsonb("metadata").$type<JsonValue>(),
|
|
92
|
+
paidAt: timestamp("paid_at", { withTimezone: true }),
|
|
93
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
94
|
+
.defaultNow()
|
|
95
|
+
.notNull(),
|
|
96
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
97
|
+
.defaultNow()
|
|
98
|
+
.$onUpdate(() => new Date())
|
|
99
|
+
.notNull(),
|
|
100
|
+
},
|
|
101
|
+
(t) => [
|
|
102
|
+
uniqueIndex("purchase_provider_external_uq").on(t.provider, t.externalId),
|
|
103
|
+
index("purchase_userId_idx").on(t.userId),
|
|
104
|
+
pgPolicy("rls_user_read", {
|
|
105
|
+
as: "permissive",
|
|
106
|
+
to: appRole,
|
|
107
|
+
for: "select",
|
|
108
|
+
using: sql`app_private.is_admin() OR user_id = app_private.current_user_id()`,
|
|
109
|
+
}),
|
|
110
|
+
pgPolicy("rls_service_write", {
|
|
111
|
+
as: "permissive",
|
|
112
|
+
to: appRole,
|
|
113
|
+
for: "all",
|
|
114
|
+
using: sql`app_private.is_admin()`,
|
|
115
|
+
withCheck: sql`app_private.is_admin()`,
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
export type SystemConfigRow = typeof systemConfig.$inferSelect;
|
|
121
|
+
export type AdminActionRow = typeof adminAction.$inferSelect;
|
|
122
|
+
export type PurchaseRow = typeof purchase.$inferSelect;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createEnv } from "@t3-oss/env-core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
function createAppEnv() {
|
|
5
|
+
return createEnv({
|
|
6
|
+
server: {
|
|
7
|
+
APP_ORIGIN: z.url(),
|
|
8
|
+
SERVER_URL: z.url().optional(),
|
|
9
|
+
BETTER_AUTH_URL: z.url(),
|
|
10
|
+
BETTER_AUTH_SECRET: z.string().min(1),
|
|
11
|
+
// Comma-separated OAuth resource audiences; empty defaults to APP_ORIGIN.
|
|
12
|
+
BETTER_AUTH_AUDIENCES: z.string().optional(),
|
|
13
|
+
TURNSTILE_SECRET_KEY: z.string().optional(),
|
|
14
|
+
EMAIL_FROM: z.string().min(1),
|
|
15
|
+
/** Inbox From display name — clone-time brand string. */
|
|
16
|
+
EMAIL_FROM_NAME: z.string().min(1).default("Starter"),
|
|
17
|
+
|
|
18
|
+
GITHUB_CLIENT_ID: z.string().optional(),
|
|
19
|
+
GITHUB_CLIENT_SECRET: z.string().optional(),
|
|
20
|
+
|
|
21
|
+
// Optional payment adapters — absence means inactive.
|
|
22
|
+
CREEM_API_KEY: z.string().optional(),
|
|
23
|
+
CREEM_WEBHOOK_SECRET: z.string().optional(),
|
|
24
|
+
CREEM_PRODUCT_ID: z.string().optional(),
|
|
25
|
+
CREEM_TEST_MODE: z.stringbool().default(false),
|
|
26
|
+
|
|
27
|
+
STRIPE_SECRET_KEY: z.string().optional(),
|
|
28
|
+
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
|
29
|
+
|
|
30
|
+
NOW_PAYMENTS_API_KEY: z.string().optional(),
|
|
31
|
+
NOW_PAYMENTS_IPN_KEY: z.string().optional(),
|
|
32
|
+
NOW_PAYMENTS_TEST_MODE: z.stringbool().default(false),
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
clientPrefix: "VITE_",
|
|
36
|
+
|
|
37
|
+
client: {
|
|
38
|
+
VITE_APP_TITLE: z.string().min(1).optional(),
|
|
39
|
+
VITE_APP_ORIGIN: z.url().optional(),
|
|
40
|
+
VITE_TURNSTILE_SITE_KEY: z.string().min(1).optional(),
|
|
41
|
+
// Public GitHub OAuth client id (same value as GITHUB_CLIENT_ID).
|
|
42
|
+
// When set with GITHUB_CLIENT_SECRET, the provider is enabled end-to-end.
|
|
43
|
+
VITE_GITHUB_CLIENT_ID: z.string().min(1).optional(),
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
runtimeEnv: process.env,
|
|
47
|
+
emptyStringAsUndefined: true,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type AppEnv = ReturnType<typeof createAppEnv>;
|
|
52
|
+
|
|
53
|
+
let appEnv: AppEnv | undefined;
|
|
54
|
+
|
|
55
|
+
export function getEnv() {
|
|
56
|
+
if (!appEnv) appEnv = createAppEnv();
|
|
57
|
+
return appEnv;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Lazy proxy so importing modules does not force env validation at load time in tests. */
|
|
61
|
+
export const env = new Proxy({} as AppEnv, {
|
|
62
|
+
get(_target, prop, receiver) {
|
|
63
|
+
return Reflect.get(getEnv(), prop, receiver);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
applyTheme,
|
|
4
|
+
readThemePreference,
|
|
5
|
+
type ThemePreference,
|
|
6
|
+
} from "@/lib/theme";
|
|
7
|
+
|
|
8
|
+
/** Theme behavior shared by the supplied control and custom presentations. */
|
|
9
|
+
export function useTheme() {
|
|
10
|
+
const [preference, setPreference] = useState<ThemePreference>("system");
|
|
11
|
+
const [mounted, setMounted] = useState(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setPreference(readThemePreference());
|
|
15
|
+
setMounted(true);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!mounted || preference !== "system") return;
|
|
20
|
+
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
|
21
|
+
const onChange = () => applyTheme("system");
|
|
22
|
+
query.addEventListener("change", onChange);
|
|
23
|
+
return () => query.removeEventListener("change", onChange);
|
|
24
|
+
}, [mounted, preference]);
|
|
25
|
+
|
|
26
|
+
function setTheme(next: ThemePreference) {
|
|
27
|
+
setPreference(next);
|
|
28
|
+
applyTheme(next);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { preference, setTheme, mounted };
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { authClient } from "@/lib/auth-client";
|
|
2
|
+
|
|
3
|
+
export default function BetterAuthHeader() {
|
|
4
|
+
const { data: session, isPending } = authClient.useSession();
|
|
5
|
+
|
|
6
|
+
if (isPending) {
|
|
7
|
+
return <div className="h-8 w-8 bg-muted animate-pulse" />;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (session?.user) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="flex items-center gap-2">
|
|
13
|
+
{session.user.image ? (
|
|
14
|
+
<img src={session.user.image} alt="" className="h-8 w-8" />
|
|
15
|
+
) : (
|
|
16
|
+
<div className="h-8 w-8 bg-muted flex items-center justify-center">
|
|
17
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
18
|
+
{session.user.name?.charAt(0).toUpperCase() || "U"}
|
|
19
|
+
</span>
|
|
20
|
+
</div>
|
|
21
|
+
)}
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
onClick={() => {
|
|
25
|
+
void authClient.signOut();
|
|
26
|
+
}}
|
|
27
|
+
className="flex-1 h-9 px-4 text-sm font-medium bg-background text-muted-foreground border border-border hover:bg-muted dark:hover:bg-muted transition-colors"
|
|
28
|
+
>
|
|
29
|
+
Sign out
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { queryOptions } from "@tanstack/react-query";
|
|
2
|
+
import { getSession } from "@/server/auth";
|
|
3
|
+
|
|
4
|
+
/** Shared by root guards; fetchQuery revalidates stale or invalidated sessions. */
|
|
5
|
+
export const sessionQueryOptions = queryOptions({
|
|
6
|
+
queryKey: ["auth", "session"],
|
|
7
|
+
queryFn: () => getSession(),
|
|
8
|
+
staleTime: 30_000,
|
|
9
|
+
// Keep a failed session check from holding navigation through retry backoff.
|
|
10
|
+
retry: false,
|
|
11
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Container,
|
|
4
|
+
Head,
|
|
5
|
+
Heading,
|
|
6
|
+
Hr,
|
|
7
|
+
Html,
|
|
8
|
+
Preview,
|
|
9
|
+
Text,
|
|
10
|
+
} from "@react-email/components";
|
|
11
|
+
|
|
12
|
+
import * as m from "@/paraglide/messages";
|
|
13
|
+
import * as s from "./theme.ts";
|
|
14
|
+
|
|
15
|
+
/** See `VerifyEmail` for why `locale` is a prop rather than ambient state. */
|
|
16
|
+
export function PasswordOtpEmail({
|
|
17
|
+
otp,
|
|
18
|
+
brandName,
|
|
19
|
+
purpose,
|
|
20
|
+
locale,
|
|
21
|
+
}: {
|
|
22
|
+
otp: string;
|
|
23
|
+
brandName: string;
|
|
24
|
+
purpose: "forget-password" | "sign-in";
|
|
25
|
+
locale?: "en" | "de";
|
|
26
|
+
}) {
|
|
27
|
+
const opts = { locale };
|
|
28
|
+
const reset = purpose === "forget-password";
|
|
29
|
+
const preview = reset
|
|
30
|
+
? m.email_otp_preview_reset({ brand: brandName }, opts)
|
|
31
|
+
: m.email_otp_preview_signin({ brand: brandName }, opts);
|
|
32
|
+
const heading = reset
|
|
33
|
+
? m.email_otp_heading_reset({}, opts)
|
|
34
|
+
: m.email_otp_heading_signin({}, opts);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Html lang={locale ?? "en"}>
|
|
38
|
+
<Head />
|
|
39
|
+
<Preview>{preview}</Preview>
|
|
40
|
+
<Body style={s.main}>
|
|
41
|
+
<Container style={s.container}>
|
|
42
|
+
<Text style={s.wordmark}>{brandName}</Text>
|
|
43
|
+
<Heading style={s.heading}>{heading}</Heading>
|
|
44
|
+
<Text style={s.text}>{m.email_otp_body({}, opts)}</Text>
|
|
45
|
+
<Text style={s.otpCode}>{otp}</Text>
|
|
46
|
+
<Hr style={s.hr} />
|
|
47
|
+
<Text style={s.footer}>
|
|
48
|
+
{m.email_otp_footer({ brand: brandName }, opts)}
|
|
49
|
+
</Text>
|
|
50
|
+
</Container>
|
|
51
|
+
</Body>
|
|
52
|
+
</Html>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
PasswordOtpEmail.PreviewProps = {
|
|
57
|
+
otp: "123456",
|
|
58
|
+
brandName: "Starter",
|
|
59
|
+
purpose: "sign-in" as const,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default PasswordOtpEmail;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Button,
|
|
4
|
+
Container,
|
|
5
|
+
Head,
|
|
6
|
+
Heading,
|
|
7
|
+
Hr,
|
|
8
|
+
Html,
|
|
9
|
+
Preview,
|
|
10
|
+
Section,
|
|
11
|
+
Text,
|
|
12
|
+
} from "@react-email/components";
|
|
13
|
+
|
|
14
|
+
import * as m from "@/paraglide/messages";
|
|
15
|
+
import * as s from "./theme.ts";
|
|
16
|
+
|
|
17
|
+
/** See `VerifyEmail` for why `locale` is a prop rather than ambient state. */
|
|
18
|
+
export function ResetPasswordEmail({
|
|
19
|
+
url,
|
|
20
|
+
name,
|
|
21
|
+
brandName,
|
|
22
|
+
locale,
|
|
23
|
+
}: {
|
|
24
|
+
url: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
brandName: string;
|
|
27
|
+
locale?: "en" | "de";
|
|
28
|
+
}) {
|
|
29
|
+
const opts = { locale };
|
|
30
|
+
return (
|
|
31
|
+
<Html lang={locale ?? "en"}>
|
|
32
|
+
<Head />
|
|
33
|
+
<Preview>{m.email_reset_preview({ brand: brandName }, opts)}</Preview>
|
|
34
|
+
<Body style={s.main}>
|
|
35
|
+
<Container style={s.container}>
|
|
36
|
+
<Text style={s.wordmark}>{brandName}</Text>
|
|
37
|
+
<Heading style={s.heading}>{m.email_reset_heading({}, opts)}</Heading>
|
|
38
|
+
<Text style={s.text}>
|
|
39
|
+
{name
|
|
40
|
+
? m.email_reset_body_named({ name, brand: brandName }, opts)
|
|
41
|
+
: m.email_reset_body({ brand: brandName }, opts)}
|
|
42
|
+
</Text>
|
|
43
|
+
<Section style={{ textAlign: "center", margin: "32px 0 4px" }}>
|
|
44
|
+
<Button href={url} style={s.button}>
|
|
45
|
+
{m.email_reset_cta({}, opts)}
|
|
46
|
+
</Button>
|
|
47
|
+
</Section>
|
|
48
|
+
<Text style={s.text}>{m.email_link_fallback({}, opts)}</Text>
|
|
49
|
+
<Text style={s.linkText}>{url}</Text>
|
|
50
|
+
<Hr style={s.hr} />
|
|
51
|
+
<Text style={s.footer}>{m.email_reset_footer({}, opts)}</Text>
|
|
52
|
+
</Container>
|
|
53
|
+
</Body>
|
|
54
|
+
</Html>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
ResetPasswordEmail.PreviewProps = {
|
|
59
|
+
url: "https://example.com/reset-password?token=preview-token",
|
|
60
|
+
name: "Ada",
|
|
61
|
+
brandName: "Starter",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default ResetPasswordEmail;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Button,
|
|
4
|
+
Container,
|
|
5
|
+
Head,
|
|
6
|
+
Heading,
|
|
7
|
+
Hr,
|
|
8
|
+
Html,
|
|
9
|
+
Preview,
|
|
10
|
+
Section,
|
|
11
|
+
Text,
|
|
12
|
+
} from "@react-email/components";
|
|
13
|
+
|
|
14
|
+
import * as m from "@/paraglide/messages";
|
|
15
|
+
import * as s from "./theme.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `locale` is passed explicitly rather than read from ambient state: an email
|
|
19
|
+
* is rendered inside a request whose own locale is the sender's, not the
|
|
20
|
+
* recipient's. It is the account's stored `user.locale` (ADR 0005).
|
|
21
|
+
*/
|
|
22
|
+
export function VerifyEmail({
|
|
23
|
+
url,
|
|
24
|
+
name,
|
|
25
|
+
brandName,
|
|
26
|
+
locale,
|
|
27
|
+
}: {
|
|
28
|
+
url: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
brandName: string;
|
|
31
|
+
locale?: "en" | "de";
|
|
32
|
+
}) {
|
|
33
|
+
const opts = { locale };
|
|
34
|
+
return (
|
|
35
|
+
<Html lang={locale ?? "en"}>
|
|
36
|
+
<Head />
|
|
37
|
+
<Preview>{m.email_verify_preview({ brand: brandName }, opts)}</Preview>
|
|
38
|
+
<Body style={s.main}>
|
|
39
|
+
<Container style={s.container}>
|
|
40
|
+
<Text style={s.wordmark}>{brandName}</Text>
|
|
41
|
+
<Heading style={s.heading}>
|
|
42
|
+
{m.email_verify_heading({}, opts)}
|
|
43
|
+
</Heading>
|
|
44
|
+
<Text style={s.text}>
|
|
45
|
+
{name
|
|
46
|
+
? m.email_verify_body_named({ name }, opts)
|
|
47
|
+
: m.email_verify_body({}, opts)}
|
|
48
|
+
</Text>
|
|
49
|
+
<Section style={{ textAlign: "center", margin: "32px 0 4px" }}>
|
|
50
|
+
<Button href={url} style={s.button}>
|
|
51
|
+
{m.email_verify_cta({}, opts)}
|
|
52
|
+
</Button>
|
|
53
|
+
</Section>
|
|
54
|
+
<Text style={s.text}>{m.email_link_fallback({}, opts)}</Text>
|
|
55
|
+
<Text style={s.linkText}>{url}</Text>
|
|
56
|
+
<Hr style={s.hr} />
|
|
57
|
+
<Text style={s.footer}>
|
|
58
|
+
{m.email_verify_footer({ brand: brandName }, opts)}
|
|
59
|
+
</Text>
|
|
60
|
+
</Container>
|
|
61
|
+
</Body>
|
|
62
|
+
</Html>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
VerifyEmail.PreviewProps = {
|
|
67
|
+
url: "https://example.com/api/auth/verify-email?token=preview-token",
|
|
68
|
+
name: "Ada",
|
|
69
|
+
brandName: "Starter",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default VerifyEmail;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Shared email styles. Email clients need inline styles and web-safe fonts.
|
|
2
|
+
// Neutral structural palette only — clones rebrand for product identity.
|
|
3
|
+
export const serif = "Georgia, 'Times New Roman', Times, serif";
|
|
4
|
+
|
|
5
|
+
export const main = {
|
|
6
|
+
backgroundColor: "#f4f4f5",
|
|
7
|
+
margin: 0,
|
|
8
|
+
// Horizontal padding keeps the card off the screen edges on small devices.
|
|
9
|
+
padding: "32px 16px",
|
|
10
|
+
fontFamily: serif,
|
|
11
|
+
};
|
|
12
|
+
export const container = {
|
|
13
|
+
maxWidth: "480px",
|
|
14
|
+
margin: "0 auto",
|
|
15
|
+
backgroundColor: "#ffffff",
|
|
16
|
+
border: "1px solid #e4e4e7",
|
|
17
|
+
borderRadius: "4px",
|
|
18
|
+
padding: "40px 40px 32px",
|
|
19
|
+
};
|
|
20
|
+
export const wordmark = {
|
|
21
|
+
margin: 0,
|
|
22
|
+
fontFamily: serif,
|
|
23
|
+
fontSize: "22px",
|
|
24
|
+
fontWeight: 600,
|
|
25
|
+
letterSpacing: "0.01em",
|
|
26
|
+
color: "#18181b",
|
|
27
|
+
};
|
|
28
|
+
export const heading = {
|
|
29
|
+
margin: "24px 0 0",
|
|
30
|
+
fontFamily: serif,
|
|
31
|
+
fontSize: "26px",
|
|
32
|
+
fontWeight: 600,
|
|
33
|
+
color: "#18181b",
|
|
34
|
+
};
|
|
35
|
+
export const text = {
|
|
36
|
+
margin: "12px 0 0",
|
|
37
|
+
fontSize: "16px",
|
|
38
|
+
lineHeight: "1.6",
|
|
39
|
+
color: "#52525b",
|
|
40
|
+
};
|
|
41
|
+
export const button = {
|
|
42
|
+
backgroundColor: "#18181b",
|
|
43
|
+
color: "#fafafa",
|
|
44
|
+
fontFamily: serif,
|
|
45
|
+
fontSize: "16px",
|
|
46
|
+
fontWeight: 600,
|
|
47
|
+
textDecoration: "none",
|
|
48
|
+
padding: "12px 28px",
|
|
49
|
+
borderRadius: "3px",
|
|
50
|
+
display: "inline-block",
|
|
51
|
+
};
|
|
52
|
+
export const linkText = {
|
|
53
|
+
margin: "8px 0 0",
|
|
54
|
+
fontSize: "13px",
|
|
55
|
+
lineHeight: "1.5",
|
|
56
|
+
color: "#3f3f46",
|
|
57
|
+
wordBreak: "break-all" as const,
|
|
58
|
+
};
|
|
59
|
+
export const hr = { borderColor: "#e4e4e7", margin: "28px 0 0" };
|
|
60
|
+
export const footer = {
|
|
61
|
+
margin: "16px 0 0",
|
|
62
|
+
fontSize: "13px",
|
|
63
|
+
lineHeight: "1.6",
|
|
64
|
+
color: "#71717a",
|
|
65
|
+
};
|
|
66
|
+
export const otpCode = {
|
|
67
|
+
margin: "20px 0 0",
|
|
68
|
+
fontFamily:
|
|
69
|
+
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
70
|
+
fontSize: "28px",
|
|
71
|
+
fontWeight: 600,
|
|
72
|
+
letterSpacing: "0.2em",
|
|
73
|
+
color: "#18181b",
|
|
74
|
+
textAlign: "center" as const,
|
|
75
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { RootSession } from "@/server/auth";
|
|
3
|
+
import { HttpError } from "@/utils/api-error";
|
|
4
|
+
import { goToSignIn } from "@/utils/auth-redirect";
|
|
5
|
+
|
|
6
|
+
export interface MyRouterContext {
|
|
7
|
+
queryClient: QueryClient;
|
|
8
|
+
/** Filled by root `beforeLoad` — one session read per request. */
|
|
9
|
+
session?: RootSession | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one place an expired session is handled.
|
|
14
|
+
*
|
|
15
|
+
* Every guarded server function throws a 401 the same way, so the remedy
|
|
16
|
+
* belongs here rather than in each page that consumes one — a component
|
|
17
|
+
* whose query 401s has no better answer than the sign-in page.
|
|
18
|
+
*/
|
|
19
|
+
function handleClientDataError(error: unknown) {
|
|
20
|
+
if (typeof window === "undefined") return;
|
|
21
|
+
|
|
22
|
+
if (error instanceof HttpError && error.status === 401) {
|
|
23
|
+
goToSignIn(`${window.location.pathname}${window.location.search}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getContext(): MyRouterContext {
|
|
28
|
+
const queryClient = new QueryClient({
|
|
29
|
+
defaultOptions: {
|
|
30
|
+
queries: {
|
|
31
|
+
// Reuse SSR/loader/preload results on mount rather than immediately
|
|
32
|
+
// repeating the same server function. Pages can override freshness.
|
|
33
|
+
staleTime: 30_000,
|
|
34
|
+
// A 4xx is a refusal, not a flake: asking again gets the same
|
|
35
|
+
// answer, and for a 401 the default three retries held the user
|
|
36
|
+
// on a dead page through ~7s of backoff before the redirect
|
|
37
|
+
// above could fire. Everything else — network faults, 5xx —
|
|
38
|
+
// keeps the default three attempts.
|
|
39
|
+
retry: (failureCount, error) =>
|
|
40
|
+
!(
|
|
41
|
+
error instanceof HttpError &&
|
|
42
|
+
error.status >= 400 &&
|
|
43
|
+
error.status < 500
|
|
44
|
+
) && failureCount < 3,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
queryCache: new QueryCache({
|
|
48
|
+
onError: (error) => handleClientDataError(error),
|
|
49
|
+
}),
|
|
50
|
+
mutationCache: new MutationCache({
|
|
51
|
+
onError: (error) => handleClientDataError(error),
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
queryClient,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function TanstackQueryProvider() {}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
|
2
|
+
import { passkeyClient } from "@better-auth/passkey/client";
|
|
3
|
+
import {
|
|
4
|
+
adminClient,
|
|
5
|
+
inferAdditionalFields,
|
|
6
|
+
twoFactorClient,
|
|
7
|
+
} from "better-auth/client/plugins";
|
|
8
|
+
import { createAuthClient } from "better-auth/react";
|
|
9
|
+
// Type-only import: a value import would drag server-only code into the client.
|
|
10
|
+
import type { auth } from "@/lib/auth";
|
|
11
|
+
import { localizedAuthPath } from "@/utils/auth-redirect";
|
|
12
|
+
import { ac, admin, superadmin, user } from "./permissions";
|
|
13
|
+
|
|
14
|
+
export const authClient = createAuthClient({
|
|
15
|
+
plugins: [
|
|
16
|
+
adminClient({ ac, roles: { admin, superadmin, user } }),
|
|
17
|
+
oauthProviderClient(),
|
|
18
|
+
passkeyClient(),
|
|
19
|
+
twoFactorClient({
|
|
20
|
+
onTwoFactorRedirect() {
|
|
21
|
+
// Locale-prefixed challenge page so full-app URL i18n is preserved.
|
|
22
|
+
window.location.href = `${localizedAuthPath("/two-factor")}${window.location.search}`;
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
inferAdditionalFields<typeof auth>(),
|
|
26
|
+
],
|
|
27
|
+
});
|