@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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seam: `user.update.before` must refuse server-controlled columns.
|
|
3
|
+
*
|
|
4
|
+
* Better Auth merges a before-hook result over the original data, so
|
|
5
|
+
* stripping keys is a silent no-op. The guard refuses instead.
|
|
6
|
+
*/
|
|
7
|
+
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
8
|
+
import { betterAuth } from "better-auth";
|
|
9
|
+
import { admin } from "better-auth/plugins";
|
|
10
|
+
import { eq } from "drizzle-orm";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { userUpdateGuard } from "@/lib/user-update-guard";
|
|
13
|
+
import { AUTH_DDL, cookieHeaders } from "./auth-harness";
|
|
14
|
+
import { createTestDatabase, hasTestDatabase } from "./test-db";
|
|
15
|
+
|
|
16
|
+
describe("userUpdateGuard", () => {
|
|
17
|
+
it("refuses role and ban fields", () => {
|
|
18
|
+
expect(() => userUpdateGuard({ role: "admin" })).toThrow(
|
|
19
|
+
/cannot be changed/i,
|
|
20
|
+
);
|
|
21
|
+
expect(() => userUpdateGuard({ banned: true })).toThrow(
|
|
22
|
+
/cannot be changed/i,
|
|
23
|
+
);
|
|
24
|
+
expect(() =>
|
|
25
|
+
userUpdateGuard({ banReason: "x", banExpires: new Date() }),
|
|
26
|
+
).toThrow(/cannot be changed/i);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("refuses tokensRevokedAt", () => {
|
|
30
|
+
expect(() => userUpdateGuard({ tokensRevokedAt: new Date() })).toThrow(
|
|
31
|
+
/cannot be changed/i,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const EMAIL = "ada@example.com";
|
|
37
|
+
const PW = "Passw0rd!";
|
|
38
|
+
|
|
39
|
+
async function makeAuth() {
|
|
40
|
+
const db = await createTestDatabase(AUTH_DDL);
|
|
41
|
+
const auth = betterAuth({
|
|
42
|
+
baseURL: "http://localhost:3000",
|
|
43
|
+
secret: "user-update-guard-test-secret",
|
|
44
|
+
telemetry: { enabled: false },
|
|
45
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
46
|
+
emailAndPassword: { enabled: true },
|
|
47
|
+
plugins: [admin()],
|
|
48
|
+
databaseHooks: {
|
|
49
|
+
user: { update: { before: async (data) => userUpdateGuard(data) } },
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return { auth, db };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function signedUp(auth: Awaited<ReturnType<typeof makeAuth>>["auth"]) {
|
|
56
|
+
const res = await auth.api.signUpEmail({
|
|
57
|
+
body: { email: EMAIL, password: PW, name: "Ada" },
|
|
58
|
+
returnHeaders: true,
|
|
59
|
+
});
|
|
60
|
+
return cookieHeaders(res.headers);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function rowOf(db: Awaited<ReturnType<typeof makeAuth>>["db"]) {
|
|
64
|
+
const { user } = await import("@/db/auth.schema");
|
|
65
|
+
const [row] = await db
|
|
66
|
+
.select({ name: user.name, role: user.role, banned: user.banned })
|
|
67
|
+
.from(user)
|
|
68
|
+
.where(eq(user.email, EMAIL));
|
|
69
|
+
return row;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The unit tests above prove the function refuses. They pass just as happily
|
|
74
|
+
* against a build where the hook was never registered — and in that build the
|
|
75
|
+
* account-owner endpoint writes `role` straight through. So this drives the
|
|
76
|
+
* real Better Auth endpoint instead.
|
|
77
|
+
*/
|
|
78
|
+
describe.skipIf(!hasTestDatabase)("updating your own account", () => {
|
|
79
|
+
it("still allows an ordinary profile edit", async () => {
|
|
80
|
+
const { auth, db } = await makeAuth();
|
|
81
|
+
const headers = await signedUp(auth);
|
|
82
|
+
|
|
83
|
+
await auth.api.updateUser({ body: { name: "Ada Lovelace" }, headers });
|
|
84
|
+
|
|
85
|
+
expect((await rowOf(db)).name).toBe("Ada Lovelace");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("cannot promote itself through the account-owner endpoint", async () => {
|
|
89
|
+
const { auth, db } = await makeAuth();
|
|
90
|
+
const headers = await signedUp(auth);
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
auth.api.updateUser({ body: { role: "admin" } as never, headers }),
|
|
94
|
+
).rejects.toThrow();
|
|
95
|
+
|
|
96
|
+
expect((await rowOf(db)).role).not.toBe("admin");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("cannot ban itself through the account-owner endpoint", async () => {
|
|
100
|
+
const { auth, db } = await makeAuth();
|
|
101
|
+
const headers = await signedUp(auth);
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
auth.api.updateUser({ body: { banned: true } as never, headers }),
|
|
105
|
+
).rejects.toThrow();
|
|
106
|
+
|
|
107
|
+
expect((await rowOf(db)).banned).toBeFalsy();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Menu } from "@base-ui/react/menu";
|
|
2
|
+
import { useLocation } from "@tanstack/react-router";
|
|
3
|
+
import { LOCALE_COOKIE, writeLocaleCookie } from "@/lib/locale-cookie";
|
|
4
|
+
import * as m from "@/paraglide/messages";
|
|
5
|
+
import { getLocale, locales } from "@/paraglide/runtime";
|
|
6
|
+
import { cn } from "@/utils/cn";
|
|
7
|
+
import { samePageLocaleHref } from "@/utils/same-page-locale-href";
|
|
8
|
+
|
|
9
|
+
const LABEL: Record<string, () => string> = {
|
|
10
|
+
en: () => m.locale_en(),
|
|
11
|
+
de: () => m.locale_de(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Language switcher: menu of real same-page localized anchors (path, query,
|
|
16
|
+
* hash preserved) plus a locale cookie write. Primary control is not a button
|
|
17
|
+
* that only calls setLocale.
|
|
18
|
+
*/
|
|
19
|
+
export function LocaleSwitcher({
|
|
20
|
+
variant = "button",
|
|
21
|
+
}: {
|
|
22
|
+
variant?: "button" | "bare";
|
|
23
|
+
} = {}) {
|
|
24
|
+
const location = useLocation();
|
|
25
|
+
const current = getLocale();
|
|
26
|
+
const bare = variant === "bare";
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Menu.Root>
|
|
30
|
+
<Menu.Trigger
|
|
31
|
+
aria-label={m.language_label()}
|
|
32
|
+
data-testid="locale-switcher"
|
|
33
|
+
className={cn(
|
|
34
|
+
"ui-locale-trigger",
|
|
35
|
+
!bare && "ui-locale-trigger--button",
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<span className="font-medium uppercase">{current}</span>
|
|
39
|
+
</Menu.Trigger>
|
|
40
|
+
<Menu.Portal>
|
|
41
|
+
<Menu.Positioner
|
|
42
|
+
side="bottom"
|
|
43
|
+
align="end"
|
|
44
|
+
sideOffset={6}
|
|
45
|
+
className="ui-positioner"
|
|
46
|
+
>
|
|
47
|
+
<Menu.Popup
|
|
48
|
+
className="ui-menu-popup"
|
|
49
|
+
data-testid="locale-switcher-menu"
|
|
50
|
+
>
|
|
51
|
+
{locales.map((locale) => (
|
|
52
|
+
<Menu.LinkItem
|
|
53
|
+
key={locale}
|
|
54
|
+
href={samePageLocaleHref({
|
|
55
|
+
pathname: location.pathname,
|
|
56
|
+
search: location.searchStr,
|
|
57
|
+
hash: location.hash ? `#${location.hash}` : "",
|
|
58
|
+
locale,
|
|
59
|
+
})}
|
|
60
|
+
hrefLang={locale}
|
|
61
|
+
aria-current={locale === current ? "true" : undefined}
|
|
62
|
+
onClick={() => {
|
|
63
|
+
writeLocaleCookie(locale);
|
|
64
|
+
}}
|
|
65
|
+
className="ui-menu-item"
|
|
66
|
+
data-testid={`locale-link-${locale}`}
|
|
67
|
+
data-locale-cookie={LOCALE_COOKIE}
|
|
68
|
+
>
|
|
69
|
+
{LABEL[locale]?.() ?? locale}
|
|
70
|
+
</Menu.LinkItem>
|
|
71
|
+
))}
|
|
72
|
+
</Menu.Popup>
|
|
73
|
+
</Menu.Positioner>
|
|
74
|
+
</Menu.Portal>
|
|
75
|
+
</Menu.Root>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default LocaleSwitcher;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useTheme } from "@/hooks/use-theme";
|
|
2
|
+
import type { ThemePreference } from "@/lib/theme";
|
|
3
|
+
import * as m from "@/paraglide/messages";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
|
|
6
|
+
const OPTIONS: ThemePreference[] = ["light", "dark", "system"];
|
|
7
|
+
|
|
8
|
+
function labelFor(option: ThemePreference): string {
|
|
9
|
+
switch (option) {
|
|
10
|
+
case "light":
|
|
11
|
+
return m.theme_light();
|
|
12
|
+
case "dark":
|
|
13
|
+
return m.theme_dark();
|
|
14
|
+
default:
|
|
15
|
+
return m.theme_system();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Default presentation of the Headless theme behavior. */
|
|
20
|
+
export function ThemeToggle({ className }: { className?: string }) {
|
|
21
|
+
const { preference, setTheme, mounted } = useTheme();
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<fieldset
|
|
25
|
+
className={cn("m-0 inline-flex gap-1 border-0 p-0", className)}
|
|
26
|
+
data-testid="theme-toggle"
|
|
27
|
+
>
|
|
28
|
+
<legend className="sr-only">{m.theme_label()}</legend>
|
|
29
|
+
{OPTIONS.map((option) => {
|
|
30
|
+
const active = mounted ? preference === option : option === "system";
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
key={option}
|
|
34
|
+
type="button"
|
|
35
|
+
data-testid={`theme-${option}`}
|
|
36
|
+
data-active={active ? "true" : "false"}
|
|
37
|
+
aria-pressed={active}
|
|
38
|
+
onClick={() => setTheme(option)}
|
|
39
|
+
className="ui-theme-option"
|
|
40
|
+
>
|
|
41
|
+
{labelFor(option)}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
})}
|
|
45
|
+
</fieldset>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { buttonClass, mutedTextClass } from "@/components/shells";
|
|
3
|
+
import { authClient } from "@/lib/auth-client";
|
|
4
|
+
import * as m from "@/paraglide/messages";
|
|
5
|
+
import { afterAuthTarget } from "@/utils/auth-redirect";
|
|
6
|
+
import { cn } from "@/utils/cn";
|
|
7
|
+
|
|
8
|
+
/** "or" hairline between the credentials form and social / passkey options. */
|
|
9
|
+
export function AuthDivider() {
|
|
10
|
+
return (
|
|
11
|
+
// No `role="separator"`: a separator carries no children, and the word
|
|
12
|
+
// "or" is content a reader needs. The hairlines are decoration.
|
|
13
|
+
<div className="mt-6 flex items-center gap-3">
|
|
14
|
+
<span aria-hidden="true" className="h-px flex-1 bg-border" />
|
|
15
|
+
<span className={cn(mutedTextClass, "text-xs uppercase tracking-wide")}>
|
|
16
|
+
{m.auth_or()}
|
|
17
|
+
</span>
|
|
18
|
+
<span aria-hidden="true" className="h-px flex-1 bg-border" />
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GitHub OAuth entry. Success resumes a mid-authorize flow (or ?redirect);
|
|
25
|
+
* failures bounce back with Better Auth's error handling.
|
|
26
|
+
*/
|
|
27
|
+
export function GithubButton({ label }: { label: string }) {
|
|
28
|
+
const [busy, setBusy] = useState(false);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex w-full flex-col gap-2">
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
disabled={busy}
|
|
35
|
+
className={cn(buttonClass, "w-full")}
|
|
36
|
+
onClick={() => {
|
|
37
|
+
setBusy(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
void authClient.signIn
|
|
40
|
+
.social({
|
|
41
|
+
provider: "github",
|
|
42
|
+
callbackURL: afterAuthTarget(),
|
|
43
|
+
errorCallbackURL: `${window.location.pathname}${window.location.search}`,
|
|
44
|
+
})
|
|
45
|
+
.then(({ error: authError }) => {
|
|
46
|
+
if (authError)
|
|
47
|
+
setError(authError.message ?? m.auth_github_failed());
|
|
48
|
+
})
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
setError(
|
|
51
|
+
err instanceof Error ? err.message : m.auth_github_failed(),
|
|
52
|
+
);
|
|
53
|
+
})
|
|
54
|
+
.finally(() => {
|
|
55
|
+
setBusy(false);
|
|
56
|
+
});
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{busy ? m.auth_redirecting() : label}
|
|
60
|
+
</button>
|
|
61
|
+
{error ? (
|
|
62
|
+
<p className="text-center text-xs text-red-600 dark:text-red-400">
|
|
63
|
+
{error}
|
|
64
|
+
</p>
|
|
65
|
+
) : null}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Passkey sign-in entry. */
|
|
71
|
+
export function PasskeyButton({
|
|
72
|
+
label,
|
|
73
|
+
disabled,
|
|
74
|
+
onClick,
|
|
75
|
+
}: {
|
|
76
|
+
label: string;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
onClick: () => void;
|
|
79
|
+
}) {
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
disabled={disabled}
|
|
84
|
+
className={cn(buttonClass, "w-full")}
|
|
85
|
+
onClick={onClick}
|
|
86
|
+
>
|
|
87
|
+
{label}
|
|
88
|
+
</button>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type TurnstileInstance,
|
|
3
|
+
Turnstile as TurnstileWidget,
|
|
4
|
+
} from "@marsidev/react-turnstile";
|
|
5
|
+
import {
|
|
6
|
+
forwardRef,
|
|
7
|
+
useEffect,
|
|
8
|
+
useImperativeHandle,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { CAPTCHA_ENABLED, TURNSTILE_SITE_KEY } from "@/lib/captcha";
|
|
13
|
+
|
|
14
|
+
export type TurnstileHandle = { reset: () => void };
|
|
15
|
+
|
|
16
|
+
/** The site's current dark-mode state: the `.dark` class the theme toggle sets
|
|
17
|
+
* on `<html>` (SSR-safe — reports "light" before the DOM exists). */
|
|
18
|
+
const readSiteTheme = (): "light" | "dark" =>
|
|
19
|
+
typeof document !== "undefined" &&
|
|
20
|
+
document.documentElement.classList.contains("dark")
|
|
21
|
+
? "dark"
|
|
22
|
+
: "light";
|
|
23
|
+
|
|
24
|
+
function useSiteTheme(): "light" | "dark" {
|
|
25
|
+
const [theme, setTheme] = useState<"light" | "dark">(readSiteTheme);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const root = document.documentElement;
|
|
28
|
+
const sync = () => setTheme(readSiteTheme());
|
|
29
|
+
const observer = new MutationObserver(sync);
|
|
30
|
+
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
|
31
|
+
sync();
|
|
32
|
+
return () => observer.disconnect();
|
|
33
|
+
}, []);
|
|
34
|
+
return theme;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cloudflare Turnstile widget. Renders only when CAPTCHA_ENABLED (production
|
|
39
|
+
* with a site key). Call `reset()` after each submit — tokens are single-use.
|
|
40
|
+
*/
|
|
41
|
+
export const Turnstile = forwardRef<
|
|
42
|
+
TurnstileHandle,
|
|
43
|
+
{ onToken: (token: string | null) => void }
|
|
44
|
+
>(function Turnstile({ onToken }, ref) {
|
|
45
|
+
const widget = useRef<TurnstileInstance>(undefined);
|
|
46
|
+
const theme = useSiteTheme();
|
|
47
|
+
|
|
48
|
+
useImperativeHandle(ref, () => ({
|
|
49
|
+
reset() {
|
|
50
|
+
widget.current?.reset();
|
|
51
|
+
onToken(null);
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
if (!CAPTCHA_ENABLED) return null;
|
|
56
|
+
return (
|
|
57
|
+
<TurnstileWidget
|
|
58
|
+
ref={widget}
|
|
59
|
+
siteKey={TURNSTILE_SITE_KEY as string}
|
|
60
|
+
onSuccess={(token) => onToken(token)}
|
|
61
|
+
onError={() => onToken(null)}
|
|
62
|
+
onExpire={() => onToken(null)}
|
|
63
|
+
options={{ theme }}
|
|
64
|
+
className="mt-1"
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import * as m from "@/paraglide/messages";
|
|
4
|
+
|
|
5
|
+
/** beforeLoad can block before a child's pending boundary is available. */
|
|
6
|
+
export function NavigationProgress() {
|
|
7
|
+
const waiting = useRouterState({
|
|
8
|
+
select: (state) =>
|
|
9
|
+
state.isLoading &&
|
|
10
|
+
!state.matches.some((match) => match.status === "pending"),
|
|
11
|
+
});
|
|
12
|
+
const [visible, setVisible] = useState(false);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!waiting) {
|
|
16
|
+
setVisible(false);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const timer = window.setTimeout(() => setVisible(true), 150);
|
|
20
|
+
return () => window.clearTimeout(timer);
|
|
21
|
+
}, [waiting]);
|
|
22
|
+
|
|
23
|
+
if (!waiting || !visible) return null;
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className="fixed inset-x-0 top-0 z-50 border-b border-border bg-background px-4 py-2 text-sm text-muted-foreground"
|
|
27
|
+
role="status"
|
|
28
|
+
>
|
|
29
|
+
{m.common_loading()}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { buttonPrimaryClass, mutedTextClass } from "@/components/ui/styles";
|
|
3
|
+
import * as m from "@/paraglide/messages";
|
|
4
|
+
|
|
5
|
+
export function NotFound() {
|
|
6
|
+
return (
|
|
7
|
+
<div className="page py-24 text-center">
|
|
8
|
+
<p className="text-5xl text-muted-foreground">404</p>
|
|
9
|
+
<h1 className="mt-3 text-xl font-medium">{m.common_not_found()}</h1>
|
|
10
|
+
<p className={`mt-2 ${mutedTextClass}`}>
|
|
11
|
+
The page you are looking for does not exist or has moved.
|
|
12
|
+
</p>
|
|
13
|
+
<div className="mt-8 flex justify-center">
|
|
14
|
+
<Link to="/" className={buttonPrimaryClass}>
|
|
15
|
+
{m.nav_home()}
|
|
16
|
+
</Link>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TanStackDevtools } from "@tanstack/react-devtools";
|
|
2
|
+
import { HeadContent, Scripts } from "@tanstack/react-router";
|
|
3
|
+
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import TanStackQueryDevtools from "@/integrations/tanstack-query/devtools";
|
|
6
|
+
import { getLocale } from "@/paraglide/runtime";
|
|
7
|
+
|
|
8
|
+
export function RootDocument({ children }: { children: ReactNode }) {
|
|
9
|
+
const locale = getLocale();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<html lang={locale} suppressHydrationWarning>
|
|
13
|
+
<head>
|
|
14
|
+
<HeadContent />
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
{/* Base UI portal stacking root — isolation: isolate (styles.css .root) */}
|
|
18
|
+
<div className="root" id="app">
|
|
19
|
+
<main className="flex flex-1 flex-col">{children}</main>
|
|
20
|
+
</div>
|
|
21
|
+
{import.meta.env.DEV ? (
|
|
22
|
+
<TanStackDevtools
|
|
23
|
+
config={{ position: "bottom-right" }}
|
|
24
|
+
plugins={[
|
|
25
|
+
{
|
|
26
|
+
name: "Tanstack Router",
|
|
27
|
+
render: <TanStackRouterDevtoolsPanel />,
|
|
28
|
+
},
|
|
29
|
+
TanStackQueryDevtools,
|
|
30
|
+
]}
|
|
31
|
+
/>
|
|
32
|
+
) : null}
|
|
33
|
+
<Scripts />
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
buttonPrimaryClass,
|
|
4
|
+
buttonSecondaryClass,
|
|
5
|
+
mutedTextClass,
|
|
6
|
+
} from "@/components/ui/styles";
|
|
7
|
+
import * as m from "@/paraglide/messages";
|
|
8
|
+
|
|
9
|
+
export function RootError({
|
|
10
|
+
error,
|
|
11
|
+
reset,
|
|
12
|
+
}: {
|
|
13
|
+
error: unknown;
|
|
14
|
+
reset: () => void;
|
|
15
|
+
}) {
|
|
16
|
+
const message =
|
|
17
|
+
error instanceof Error ? error.message : "Something went wrong.";
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="page py-24 text-center">
|
|
21
|
+
<p className="text-sm text-destructive">{m.common_error()}</p>
|
|
22
|
+
<h1 className="mt-2 text-xl font-medium">{m.common_something_wrong()}</h1>
|
|
23
|
+
<p className={`mt-2 ${mutedTextClass}`}>{message}</p>
|
|
24
|
+
<div className="mt-8 flex flex-wrap justify-center gap-2">
|
|
25
|
+
<button type="button" className={buttonPrimaryClass} onClick={reset}>
|
|
26
|
+
Try again
|
|
27
|
+
</button>
|
|
28
|
+
<Link to="/" className={buttonSecondaryClass}>
|
|
29
|
+
{m.nav_home()}
|
|
30
|
+
</Link>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|