@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,69 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { withRlsService } from "@/db/helper";
|
|
3
|
+
import { env } from "@/env";
|
|
4
|
+
import { webhookBodyLimitMiddleware } from "@/middlewares/body-limit";
|
|
5
|
+
import { databaseMiddleware } from "@/middlewares/database";
|
|
6
|
+
import { handleCreemWebhook } from "@/services/creem";
|
|
7
|
+
import { APIError, methodNotAllowed } from "@/utils/api-error";
|
|
8
|
+
|
|
9
|
+
// Creem checkout webhook: a thin shell over the framework-agnostic handler.
|
|
10
|
+
// Fulfillment runs under service RLS context. HTTP contract: a valid signature
|
|
11
|
+
// answers 200 for every mapped outcome so Creem stops retrying a delivered
|
|
12
|
+
// event; a bad or missing signature answers 400 with no effect; a genuine
|
|
13
|
+
// processing failure throws and answers 5xx so Creem retries — that retry is
|
|
14
|
+
// what makes the purchase-paid hook exactly-once.
|
|
15
|
+
export const Route = createFileRoute("/api/webhooks/creem")({
|
|
16
|
+
server: {
|
|
17
|
+
handlers: ({ createHandlers }) =>
|
|
18
|
+
createHandlers({
|
|
19
|
+
// Answers a browser, which would otherwise get the SPA shell.
|
|
20
|
+
GET: { handler: () => methodNotAllowed("POST") },
|
|
21
|
+
POST: {
|
|
22
|
+
// Public and unauthenticated: the body is capped before anything
|
|
23
|
+
// reads or hashes it.
|
|
24
|
+
middleware: [databaseMiddleware, webhookBodyLimitMiddleware],
|
|
25
|
+
handler: async ({ request, context }) => {
|
|
26
|
+
const secret = env.CREEM_WEBHOOK_SECRET;
|
|
27
|
+
if (!secret) {
|
|
28
|
+
return APIError({
|
|
29
|
+
status: 503,
|
|
30
|
+
error: "not_configured",
|
|
31
|
+
message: "Creem is not configured.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = await withRlsService(context.db, (tx) =>
|
|
36
|
+
handleCreemWebhook(tx, {
|
|
37
|
+
rawBody: context.rawBody,
|
|
38
|
+
signature:
|
|
39
|
+
request.headers.get("creem-signature") ??
|
|
40
|
+
request.headers.get("x-creem-signature"),
|
|
41
|
+
secret,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (result === "invalid_signature") {
|
|
46
|
+
return APIError({
|
|
47
|
+
status: 400,
|
|
48
|
+
error: "invalid_signature",
|
|
49
|
+
message: "Signature verification failed.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (result === "invalid_payload") {
|
|
53
|
+
return APIError({
|
|
54
|
+
status: 400,
|
|
55
|
+
error: "invalid_payload",
|
|
56
|
+
message: "Request body is not valid JSON.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (result === "unknown_order") {
|
|
60
|
+
console.error(
|
|
61
|
+
"[creem] event references an unknown purchase — not fulfilled",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return Response.json({ result }, { status: 200 });
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { withRlsService } from "@/db/helper";
|
|
3
|
+
import { env } from "@/env";
|
|
4
|
+
import { webhookBodyLimitMiddleware } from "@/middlewares/body-limit";
|
|
5
|
+
import { databaseMiddleware } from "@/middlewares/database";
|
|
6
|
+
import { handleNowPaymentsWebhook } from "@/services/nowpayments";
|
|
7
|
+
import { APIError, methodNotAllowed } from "@/utils/api-error";
|
|
8
|
+
|
|
9
|
+
// NowPayments IPN callback: a thin shell over the framework-agnostic handler.
|
|
10
|
+
// Fulfillment runs under service RLS context. HTTP contract: a valid signature
|
|
11
|
+
// answers 200 for every mapped outcome so NowPayments stops retrying a
|
|
12
|
+
// delivered notification; a bad or missing signature answers 400 with no
|
|
13
|
+
// effect; a genuine processing failure throws and answers 5xx so NowPayments
|
|
14
|
+
// retries — that retry is what makes the purchase-paid hook exactly-once.
|
|
15
|
+
export const Route = createFileRoute("/api/webhooks/nowpayments")({
|
|
16
|
+
server: {
|
|
17
|
+
handlers: ({ createHandlers }) =>
|
|
18
|
+
createHandlers({
|
|
19
|
+
// Answers a browser, which would otherwise get the SPA shell.
|
|
20
|
+
GET: { handler: () => methodNotAllowed("POST") },
|
|
21
|
+
POST: {
|
|
22
|
+
// Public and unauthenticated: the body is capped before anything
|
|
23
|
+
// reads or hashes it.
|
|
24
|
+
middleware: [databaseMiddleware, webhookBodyLimitMiddleware],
|
|
25
|
+
handler: async ({ request, context }) => {
|
|
26
|
+
const ipnKey = env.NOW_PAYMENTS_IPN_KEY;
|
|
27
|
+
if (!ipnKey) {
|
|
28
|
+
return APIError({
|
|
29
|
+
status: 503,
|
|
30
|
+
error: "not_configured",
|
|
31
|
+
message: "NowPayments is not configured.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = await withRlsService(context.db, (tx) =>
|
|
36
|
+
handleNowPaymentsWebhook(tx, {
|
|
37
|
+
rawBody: context.rawBody,
|
|
38
|
+
signature: request.headers.get("x-nowpayments-sig"),
|
|
39
|
+
ipnKey,
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (result === "invalid_signature") {
|
|
44
|
+
return APIError({
|
|
45
|
+
status: 400,
|
|
46
|
+
error: "invalid_signature",
|
|
47
|
+
message: "Signature verification failed.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (result === "unknown_order") {
|
|
51
|
+
// Signed, but joins no known Purchase — nothing to do. Still 200
|
|
52
|
+
// so NowPayments stops retrying a callback we cannot act on.
|
|
53
|
+
console.error(
|
|
54
|
+
"[nowpayments] IPN references an unknown purchase — not fulfilled",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return Response.json({ result }, { status: 200 });
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { withRlsService } from "@/db/helper";
|
|
3
|
+
import { env } from "@/env";
|
|
4
|
+
import { webhookBodyLimitMiddleware } from "@/middlewares/body-limit";
|
|
5
|
+
import { databaseMiddleware } from "@/middlewares/database";
|
|
6
|
+
import { handleStripeWebhook } from "@/services/stripe";
|
|
7
|
+
import { APIError, methodNotAllowed } from "@/utils/api-error";
|
|
8
|
+
|
|
9
|
+
// Stripe Checkout webhook: a thin shell over the framework-agnostic handler.
|
|
10
|
+
// Fulfillment runs under service RLS context. HTTP contract: a valid signature
|
|
11
|
+
// answers 200 for every mapped outcome so Stripe stops retrying a delivered
|
|
12
|
+
// event; a bad or missing signature answers 400 with no effect; a genuine
|
|
13
|
+
// processing failure throws and answers 5xx so Stripe retries — that retry is
|
|
14
|
+
// what makes the purchase-paid hook exactly-once.
|
|
15
|
+
export const Route = createFileRoute("/api/webhooks/stripe")({
|
|
16
|
+
server: {
|
|
17
|
+
handlers: ({ createHandlers }) =>
|
|
18
|
+
createHandlers({
|
|
19
|
+
// Answers a browser, which would otherwise get the SPA shell.
|
|
20
|
+
GET: { handler: () => methodNotAllowed("POST") },
|
|
21
|
+
POST: {
|
|
22
|
+
// Public and unauthenticated: the body is capped before anything
|
|
23
|
+
// reads or hashes it.
|
|
24
|
+
middleware: [databaseMiddleware, webhookBodyLimitMiddleware],
|
|
25
|
+
handler: async ({ request, context }) => {
|
|
26
|
+
const secret = env.STRIPE_WEBHOOK_SECRET;
|
|
27
|
+
if (!secret) {
|
|
28
|
+
return APIError({
|
|
29
|
+
status: 503,
|
|
30
|
+
error: "not_configured",
|
|
31
|
+
message: "Stripe is not configured.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = await withRlsService(context.db, (tx) =>
|
|
36
|
+
handleStripeWebhook(tx, {
|
|
37
|
+
rawBody: context.rawBody,
|
|
38
|
+
signature: request.headers.get("stripe-signature"),
|
|
39
|
+
secret,
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (result === "invalid_signature") {
|
|
44
|
+
return APIError({
|
|
45
|
+
status: 400,
|
|
46
|
+
error: "invalid_signature",
|
|
47
|
+
message: "Signature verification failed.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (result === "invalid_payload") {
|
|
51
|
+
return APIError({
|
|
52
|
+
status: 400,
|
|
53
|
+
error: "invalid_payload",
|
|
54
|
+
message: "Request body is not valid JSON.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (result === "amount_mismatch") {
|
|
58
|
+
// The one branch designed to fail closed. It answers 200 so
|
|
59
|
+
// Stripe stops retrying, but it must not pass unnoticed.
|
|
60
|
+
console.error(
|
|
61
|
+
"[stripe] reported amount does not match the Purchase row — not fulfilled",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (result === "unknown_order") {
|
|
65
|
+
console.error(
|
|
66
|
+
"[stripe] event references an unknown purchase — not fulfilled",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return Response.json({ result }, { status: 200 });
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { Select } from "@base-ui/react/select";
|
|
2
|
+
import { Tabs } from "@base-ui/react/tabs";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
buttonClass,
|
|
8
|
+
buttonSecondaryClass,
|
|
9
|
+
DashboardShell,
|
|
10
|
+
Field,
|
|
11
|
+
inputClass,
|
|
12
|
+
mutedTextClass,
|
|
13
|
+
} from "@/components/shells";
|
|
14
|
+
import { sessionQueryOptions } from "@/integrations/better-auth/session-query";
|
|
15
|
+
import { authClient } from "@/lib/auth-client";
|
|
16
|
+
import * as m from "@/paraglide/messages";
|
|
17
|
+
import {
|
|
18
|
+
beginTwoFactorEnable,
|
|
19
|
+
confirmPasswordChange,
|
|
20
|
+
disableTwoFactor,
|
|
21
|
+
getSecurityOverview,
|
|
22
|
+
removePasskey,
|
|
23
|
+
requestPasswordOtp,
|
|
24
|
+
requestReauthCode,
|
|
25
|
+
revokeOtherSessions,
|
|
26
|
+
} from "@/server/account-security";
|
|
27
|
+
import { accountSecurityErrorMessage } from "@/utils/auth-errors";
|
|
28
|
+
|
|
29
|
+
/** One key for the page: profile, security, passkeys and sessions load together. */
|
|
30
|
+
const SECURITY_OVERVIEW_KEY = ["security-overview"] as const;
|
|
31
|
+
|
|
32
|
+
const overviewQuery = {
|
|
33
|
+
queryKey: SECURITY_OVERVIEW_KEY,
|
|
34
|
+
queryFn: () => getSecurityOverview(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Route = createFileRoute("/dashboard/account")({
|
|
38
|
+
component: AccountPage,
|
|
39
|
+
// Every panel on the page reads this one payload, so there is nothing to
|
|
40
|
+
// render before it resolves.
|
|
41
|
+
loader: ({ context }) => context.queryClient.ensureQueryData(overviewQuery),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function AccountPage() {
|
|
45
|
+
// `/dashboard` narrowed `session` to signed-in for this whole subtree.
|
|
46
|
+
const { session } = Route.useRouteContext();
|
|
47
|
+
const qc = useQueryClient();
|
|
48
|
+
const router = useRouter();
|
|
49
|
+
const overview = useQuery(overviewQuery);
|
|
50
|
+
|
|
51
|
+
const sessionLocale =
|
|
52
|
+
(session.user as { locale?: string | null }).locale ?? "en";
|
|
53
|
+
const [name, setName] = useState(session.user.name);
|
|
54
|
+
const [locale, setLocale] = useState(sessionLocale);
|
|
55
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
56
|
+
const [otp, setOtp] = useState("");
|
|
57
|
+
const [newPassword, setNewPassword] = useState("");
|
|
58
|
+
const [totpUri, setTotpUri] = useState<string | null>(null);
|
|
59
|
+
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
|
60
|
+
const [factorCode, setFactorCode] = useState("");
|
|
61
|
+
|
|
62
|
+
const invalidate = () => {
|
|
63
|
+
void qc.invalidateQueries({ queryKey: SECURITY_OVERVIEW_KEY });
|
|
64
|
+
void qc
|
|
65
|
+
.invalidateQueries({ queryKey: sessionQueryOptions.queryKey })
|
|
66
|
+
.then(() => router.invalidate());
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
async function saveProfile(e: React.FormEvent) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
setMessage(null);
|
|
72
|
+
const { error } = await authClient.updateUser({ name, locale });
|
|
73
|
+
setMessage(
|
|
74
|
+
error
|
|
75
|
+
? (error.message ?? m.account_update_failed())
|
|
76
|
+
: m.account_profile_saved(),
|
|
77
|
+
);
|
|
78
|
+
invalidate();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const requestOtp = useMutation({
|
|
82
|
+
mutationFn: () => requestPasswordOtp(),
|
|
83
|
+
onSuccess: (r) => {
|
|
84
|
+
setMessage(
|
|
85
|
+
r.ok
|
|
86
|
+
? m.account_otp_sent({ seconds: r.retryAfterSeconds })
|
|
87
|
+
: accountSecurityErrorMessage(r.code),
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const changePw = useMutation({
|
|
93
|
+
mutationFn: () => confirmPasswordChange({ data: { otp, newPassword } }),
|
|
94
|
+
onSuccess: (r) => {
|
|
95
|
+
setMessage(
|
|
96
|
+
r.ok
|
|
97
|
+
? m.account_password_updated()
|
|
98
|
+
: accountSecurityErrorMessage(r.code),
|
|
99
|
+
);
|
|
100
|
+
if (r.ok) {
|
|
101
|
+
setOtp("");
|
|
102
|
+
setNewPassword("");
|
|
103
|
+
}
|
|
104
|
+
invalidate();
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const reauthFor2fa = useMutation({
|
|
109
|
+
mutationFn: () => requestReauthCode(),
|
|
110
|
+
onSuccess: (r) => {
|
|
111
|
+
setMessage(
|
|
112
|
+
r.ok ? m.account_reauth_emailed() : accountSecurityErrorMessage(r.code),
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const enable2fa = useMutation({
|
|
118
|
+
mutationFn: () => beginTwoFactorEnable({ data: { otp } }),
|
|
119
|
+
onSuccess: (r) => {
|
|
120
|
+
if (r.ok) {
|
|
121
|
+
setTotpUri(r.totpURI);
|
|
122
|
+
setBackupCodes(r.backupCodes);
|
|
123
|
+
setMessage(m.account_2fa_enroll_hint());
|
|
124
|
+
} else setMessage(accountSecurityErrorMessage(r.code));
|
|
125
|
+
invalidate();
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const disable2fa = useMutation({
|
|
130
|
+
mutationFn: () =>
|
|
131
|
+
disableTwoFactor({ data: { code: factorCode, backupCode: false } }),
|
|
132
|
+
onSuccess: (r) => {
|
|
133
|
+
setMessage(
|
|
134
|
+
r.ok ? m.account_2fa_disabled() : accountSecurityErrorMessage(r.code),
|
|
135
|
+
);
|
|
136
|
+
invalidate();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const revokeOthers = useMutation({
|
|
141
|
+
mutationFn: () => revokeOtherSessions(),
|
|
142
|
+
onSuccess: (r) => {
|
|
143
|
+
setMessage(
|
|
144
|
+
r.ok ? m.account_sessions_revoked() : m.account_err_unauthorized(),
|
|
145
|
+
);
|
|
146
|
+
invalidate();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const dropPasskey = useMutation({
|
|
151
|
+
mutationFn: (passkeyId: string) => removePasskey({ data: { passkeyId } }),
|
|
152
|
+
onSuccess: (r) => {
|
|
153
|
+
setMessage(
|
|
154
|
+
r.ok
|
|
155
|
+
? m.account_passkey_removed()
|
|
156
|
+
: accountSecurityErrorMessage(r.code),
|
|
157
|
+
);
|
|
158
|
+
invalidate();
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const o = overview.data;
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (o?.locale) setLocale(o.locale);
|
|
166
|
+
}, [o?.locale]);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<DashboardShell title={m.account_title()}>
|
|
170
|
+
{message ? (
|
|
171
|
+
<p className="mb-4 text-sm text-foreground">{message}</p>
|
|
172
|
+
) : null}
|
|
173
|
+
|
|
174
|
+
<Tabs.Root defaultValue="profile">
|
|
175
|
+
<Tabs.List className="ui-tabs-list">
|
|
176
|
+
<Tabs.Tab className="ui-tabs-tab" value="profile">
|
|
177
|
+
{m.account_tab_profile()}
|
|
178
|
+
</Tabs.Tab>
|
|
179
|
+
<Tabs.Tab className="ui-tabs-tab" value="security">
|
|
180
|
+
{m.account_tab_security()}
|
|
181
|
+
</Tabs.Tab>
|
|
182
|
+
<Tabs.Tab className="ui-tabs-tab" value="sessions">
|
|
183
|
+
{m.account_tab_sessions()}
|
|
184
|
+
</Tabs.Tab>
|
|
185
|
+
</Tabs.List>
|
|
186
|
+
|
|
187
|
+
<Tabs.Panel className="ui-tabs-panel" value="profile">
|
|
188
|
+
<form onSubmit={saveProfile} className="max-w-md">
|
|
189
|
+
<Field label={m.field_display_name()}>
|
|
190
|
+
<input
|
|
191
|
+
className={inputClass}
|
|
192
|
+
value={name}
|
|
193
|
+
onChange={(ev) => setName(ev.target.value)}
|
|
194
|
+
/>
|
|
195
|
+
</Field>
|
|
196
|
+
<Field label={m.field_locale()}>
|
|
197
|
+
<Select.Root
|
|
198
|
+
value={locale}
|
|
199
|
+
onValueChange={(v) => {
|
|
200
|
+
if (typeof v === "string") setLocale(v);
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<Select.Trigger className="ui-select-trigger">
|
|
204
|
+
<Select.Value>
|
|
205
|
+
{(value: string | null) =>
|
|
206
|
+
value === "de" ? m.locale_de() : m.locale_en()
|
|
207
|
+
}
|
|
208
|
+
</Select.Value>
|
|
209
|
+
<Select.Icon className="ui-select-icon">
|
|
210
|
+
<span aria-hidden>▾</span>
|
|
211
|
+
</Select.Icon>
|
|
212
|
+
</Select.Trigger>
|
|
213
|
+
<Select.Portal>
|
|
214
|
+
<Select.Positioner sideOffset={4} className="ui-positioner">
|
|
215
|
+
<Select.Popup className="ui-select-popup">
|
|
216
|
+
<Select.Item className="ui-select-item" value="en">
|
|
217
|
+
<Select.ItemText>{m.locale_en()}</Select.ItemText>
|
|
218
|
+
</Select.Item>
|
|
219
|
+
<Select.Item className="ui-select-item" value="de">
|
|
220
|
+
<Select.ItemText>{m.locale_de()}</Select.ItemText>
|
|
221
|
+
</Select.Item>
|
|
222
|
+
</Select.Popup>
|
|
223
|
+
</Select.Positioner>
|
|
224
|
+
</Select.Portal>
|
|
225
|
+
</Select.Root>
|
|
226
|
+
</Field>
|
|
227
|
+
<button type="submit" className={`${buttonClass} max-w-xs`}>
|
|
228
|
+
{m.account_save_profile()}
|
|
229
|
+
</button>
|
|
230
|
+
</form>
|
|
231
|
+
</Tabs.Panel>
|
|
232
|
+
|
|
233
|
+
<Tabs.Panel className="ui-tabs-panel" value="security">
|
|
234
|
+
<section className="mb-8">
|
|
235
|
+
{overview.isLoading ? (
|
|
236
|
+
<p className={mutedTextClass}>{m.common_loading()}</p>
|
|
237
|
+
) : o ? (
|
|
238
|
+
<ul className="list-inside list-disc text-sm text-foreground">
|
|
239
|
+
<li>
|
|
240
|
+
{m.account_email_label()}: {o.email} (
|
|
241
|
+
{o.emailVerified
|
|
242
|
+
? m.account_verified()
|
|
243
|
+
: m.account_unverified()}
|
|
244
|
+
)
|
|
245
|
+
</li>
|
|
246
|
+
<li>
|
|
247
|
+
{m.account_password_label()}:{" "}
|
|
248
|
+
{o.hasPassword
|
|
249
|
+
? m.account_password_set()
|
|
250
|
+
: m.account_password_none()}
|
|
251
|
+
</li>
|
|
252
|
+
<li>
|
|
253
|
+
{m.account_github_label()}:{" "}
|
|
254
|
+
{o.githubLinked
|
|
255
|
+
? m.account_github_linked()
|
|
256
|
+
: m.account_github_not_linked()}
|
|
257
|
+
</li>
|
|
258
|
+
<li>
|
|
259
|
+
{m.account_2fa_label()}:{" "}
|
|
260
|
+
{o.twoFactorEnabled ? m.account_on() : m.account_off()}
|
|
261
|
+
</li>
|
|
262
|
+
<li>
|
|
263
|
+
{m.account_passkeys_label()}: {o.passkeys.length}
|
|
264
|
+
</li>
|
|
265
|
+
</ul>
|
|
266
|
+
) : null}
|
|
267
|
+
</section>
|
|
268
|
+
|
|
269
|
+
<section className="mb-8 max-w-md">
|
|
270
|
+
<h2 className="mb-3 text-lg font-medium">
|
|
271
|
+
{m.account_change_password_title()}
|
|
272
|
+
</h2>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
className={`${buttonClass} mb-3 max-w-xs`}
|
|
276
|
+
disabled={requestOtp.isPending}
|
|
277
|
+
onClick={() => requestOtp.mutate()}
|
|
278
|
+
>
|
|
279
|
+
{m.account_send_otp()}
|
|
280
|
+
</button>
|
|
281
|
+
<form
|
|
282
|
+
onSubmit={(e) => {
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
changePw.mutate();
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<Field label={m.account_field_otp()}>
|
|
288
|
+
<input
|
|
289
|
+
className={inputClass}
|
|
290
|
+
value={otp}
|
|
291
|
+
onChange={(e) => setOtp(e.target.value)}
|
|
292
|
+
/>
|
|
293
|
+
</Field>
|
|
294
|
+
<Field label={m.account_field_new_password()}>
|
|
295
|
+
<input
|
|
296
|
+
className={inputClass}
|
|
297
|
+
type="password"
|
|
298
|
+
value={newPassword}
|
|
299
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
300
|
+
/>
|
|
301
|
+
</Field>
|
|
302
|
+
<button type="submit" className={`${buttonClass} max-w-xs`}>
|
|
303
|
+
{m.account_update_password()}
|
|
304
|
+
</button>
|
|
305
|
+
</form>
|
|
306
|
+
</section>
|
|
307
|
+
|
|
308
|
+
<section className="mb-8 max-w-md">
|
|
309
|
+
<h2 className="mb-3 text-lg font-medium">
|
|
310
|
+
{m.account_section_two_factor()}
|
|
311
|
+
</h2>
|
|
312
|
+
{!o?.twoFactorEnabled ? (
|
|
313
|
+
<>
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
className={`${buttonClass} mb-2 max-w-xs`}
|
|
317
|
+
onClick={() => reauthFor2fa.mutate()}
|
|
318
|
+
>
|
|
319
|
+
{m.account_email_enrollment_code()}
|
|
320
|
+
</button>
|
|
321
|
+
<form
|
|
322
|
+
onSubmit={(e) => {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
enable2fa.mutate();
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
<Field label={m.account_field_enrollment_otp()}>
|
|
328
|
+
<input
|
|
329
|
+
className={inputClass}
|
|
330
|
+
value={otp}
|
|
331
|
+
onChange={(e) => setOtp(e.target.value)}
|
|
332
|
+
/>
|
|
333
|
+
</Field>
|
|
334
|
+
<button type="submit" className={`${buttonClass} max-w-xs`}>
|
|
335
|
+
{m.account_begin_2fa()}
|
|
336
|
+
</button>
|
|
337
|
+
</form>
|
|
338
|
+
{totpUri ? (
|
|
339
|
+
<p className="mt-2 break-all font-mono text-xs">{totpUri}</p>
|
|
340
|
+
) : null}
|
|
341
|
+
{backupCodes ? (
|
|
342
|
+
<pre className="mt-2 text-xs">{backupCodes.join("\n")}</pre>
|
|
343
|
+
) : null}
|
|
344
|
+
</>
|
|
345
|
+
) : (
|
|
346
|
+
<form
|
|
347
|
+
onSubmit={(e) => {
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
disable2fa.mutate();
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
<Field label={m.account_field_totp_disable()}>
|
|
353
|
+
<input
|
|
354
|
+
className={inputClass}
|
|
355
|
+
value={factorCode}
|
|
356
|
+
onChange={(e) => setFactorCode(e.target.value)}
|
|
357
|
+
/>
|
|
358
|
+
</Field>
|
|
359
|
+
<button type="submit" className={`${buttonClass} max-w-xs`}>
|
|
360
|
+
{m.account_disable_2fa()}
|
|
361
|
+
</button>
|
|
362
|
+
</form>
|
|
363
|
+
)}
|
|
364
|
+
</section>
|
|
365
|
+
|
|
366
|
+
<section className="mb-8">
|
|
367
|
+
<h2 className="mb-3 text-lg font-medium">
|
|
368
|
+
{m.account_section_passkeys()}
|
|
369
|
+
</h2>
|
|
370
|
+
{o?.passkeys.length ? (
|
|
371
|
+
<ul className="space-y-2 text-sm">
|
|
372
|
+
{o.passkeys.map((pk) => (
|
|
373
|
+
<li key={pk.id} className="flex items-center gap-3">
|
|
374
|
+
<span>
|
|
375
|
+
{pk.name ?? pk.id}
|
|
376
|
+
{pk.createdAt ? ` · ${pk.createdAt}` : ""}
|
|
377
|
+
</span>
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
className={`${buttonClass} w-auto px-2 py-1 text-xs`}
|
|
381
|
+
onClick={() => dropPasskey.mutate(pk.id)}
|
|
382
|
+
>
|
|
383
|
+
{m.account_remove()}
|
|
384
|
+
</button>
|
|
385
|
+
</li>
|
|
386
|
+
))}
|
|
387
|
+
</ul>
|
|
388
|
+
) : (
|
|
389
|
+
<p className={mutedTextClass}>{m.account_no_passkeys()}</p>
|
|
390
|
+
)}
|
|
391
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
392
|
+
{m.account_passkey_hint()}
|
|
393
|
+
</p>
|
|
394
|
+
</section>
|
|
395
|
+
</Tabs.Panel>
|
|
396
|
+
|
|
397
|
+
<Tabs.Panel className="ui-tabs-panel" value="sessions">
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
className={`${buttonClass} mb-3 max-w-xs`}
|
|
401
|
+
onClick={() => revokeOthers.mutate()}
|
|
402
|
+
>
|
|
403
|
+
{m.account_revoke_other_sessions()}
|
|
404
|
+
</button>
|
|
405
|
+
{overview.data?.sessions ? (
|
|
406
|
+
<ul className="space-y-1 text-xs text-muted-foreground">
|
|
407
|
+
{overview.data.sessions.map((s) => (
|
|
408
|
+
<li key={s.id}>
|
|
409
|
+
{s.isCurrent ? `${m.account_session_current()} · ` : ""}
|
|
410
|
+
{s.createdAt}
|
|
411
|
+
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
|
|
412
|
+
</li>
|
|
413
|
+
))}
|
|
414
|
+
</ul>
|
|
415
|
+
) : null}
|
|
416
|
+
<button
|
|
417
|
+
type="button"
|
|
418
|
+
className={`${buttonSecondaryClass} mt-6 w-full max-w-xs`}
|
|
419
|
+
onClick={async () => {
|
|
420
|
+
await authClient.signOut();
|
|
421
|
+
window.location.href = "/sign-in";
|
|
422
|
+
}}
|
|
423
|
+
>
|
|
424
|
+
{m.action_sign_out()}
|
|
425
|
+
</button>
|
|
426
|
+
</Tabs.Panel>
|
|
427
|
+
</Tabs.Root>
|
|
428
|
+
</DashboardShell>
|
|
429
|
+
);
|
|
430
|
+
}
|