create-nexst-monorepo 0.1.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/dist/index.js +1727 -0
- package/package.json +37 -0
- package/template/.agents/plugins/marketplace.json +21 -0
- package/template/.claude/settings.json +6 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +154 -0
- package/template/.claude/skills/theme-factory/SKILL.md +95 -0
- package/template/.claude/skills/theme-factory/references/schema.md +104 -0
- package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
- package/template/.dockerignore +43 -0
- package/template/.editorconfig +10 -0
- package/template/.github/workflows/ci.yaml +289 -0
- package/template/.github/workflows/publish-app.yaml +66 -0
- package/template/.github/workflows/publish.yaml +148 -0
- package/template/.github/workflows/snyk.yaml +34 -0
- package/template/.vscode/settings.json +8 -0
- package/template/AGENTS.md +42 -0
- package/template/Dockerfile.base +9 -0
- package/template/README.md +54 -0
- package/template/_gitignore +10 -0
- package/template/apps/api/.env.example +32 -0
- package/template/apps/api/AGENTS.md +44 -0
- package/template/apps/api/Dockerfile +69 -0
- package/template/apps/api/_gitignore +8 -0
- package/template/apps/api/knip.config.ts +22 -0
- package/template/apps/api/kustomize/base/application.properties +16 -0
- package/template/apps/api/kustomize/base/deployment.yaml +94 -0
- package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/base/service.yaml +10 -0
- package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
- package/template/apps/api/kustomize/secret.keys +14 -0
- package/template/apps/api/oxfmt.config.ts +8 -0
- package/template/apps/api/oxlint.config.ts +107 -0
- package/template/apps/api/package.json +79 -0
- package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/schema.prisma +113 -0
- package/template/apps/api/prisma.config.ts +7 -0
- package/template/apps/api/scripts/auth.cli.ts +19 -0
- package/template/apps/api/scripts/seal-secret.sh +171 -0
- package/template/apps/api/sonar-project.properties +9 -0
- package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
- package/template/apps/api/src/app-info/app-info.module.ts +7 -0
- package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
- package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
- package/template/apps/api/src/app.module.spec.ts +93 -0
- package/template/apps/api/src/app.module.ts +77 -0
- package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
- package/template/apps/api/src/auth/auth.guard.ts +97 -0
- package/template/apps/api/src/auth/auth.ts +129 -0
- package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
- package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
- package/template/apps/api/src/auth/testing/make-session.ts +31 -0
- package/template/apps/api/src/auth/types/auth.types.ts +27 -0
- package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
- package/template/apps/api/src/bootstrap/security.ts +24 -0
- package/template/apps/api/src/bootstrap/swagger.ts +28 -0
- package/template/apps/api/src/config/app.config.spec.ts +22 -0
- package/template/apps/api/src/config/app.config.ts +122 -0
- package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
- package/template/apps/api/src/config/graphql.config.ts +77 -0
- package/template/apps/api/src/config/resources/app.yaml +29 -0
- package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
- package/template/apps/api/src/errors/app.exception.ts +47 -0
- package/template/apps/api/src/errors/error-code.enum.ts +54 -0
- package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
- package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
- package/template/apps/api/src/health/health.controller.spec.ts +82 -0
- package/template/apps/api/src/health/health.controller.ts +57 -0
- package/template/apps/api/src/health/health.module.ts +12 -0
- package/template/apps/api/src/logging/context-logger.ts +18 -0
- package/template/apps/api/src/logging/logger.config.ts +23 -0
- package/template/apps/api/src/logging/logging.module.ts +22 -0
- package/template/apps/api/src/mail/mail.module.ts +12 -0
- package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
- package/template/apps/api/src/mail/mail.service.ts +123 -0
- package/template/apps/api/src/main.ts +41 -0
- package/template/apps/api/src/prisma/prisma.module.ts +12 -0
- package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
- package/template/apps/api/src/prisma/prisma.service.ts +138 -0
- package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
- package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
- package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
- package/template/apps/api/src/queue/example-queue.service.ts +32 -0
- package/template/apps/api/src/queue/queue.module.ts +27 -0
- package/template/apps/api/src/r2/r2.module.ts +12 -0
- package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
- package/template/apps/api/src/r2/r2.service.ts +131 -0
- package/template/apps/api/src/user/dto/user.dto.ts +26 -0
- package/template/apps/api/src/user/user.module.ts +10 -0
- package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
- package/template/apps/api/src/user/user.resolver.ts +24 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
- package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
- package/template/apps/api/src/utils/error-handler.util.ts +12 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
- package/template/apps/api/src/utils/pagination.input.ts +20 -0
- package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
- package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
- package/template/apps/api/tests/app.e2e-spec.ts +35 -0
- package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
- package/template/apps/api/tests/helpers/api.ts +27 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/api/vitest.config.e2e.ts +16 -0
- package/template/apps/api/vitest.config.ts +40 -0
- package/template/apps/web/.env.example +7 -0
- package/template/apps/web/.storybook/decorators.tsx +38 -0
- package/template/apps/web/.storybook/main.ts +17 -0
- package/template/apps/web/.storybook/preview.ts +38 -0
- package/template/apps/web/.storybook/vitest.setup.ts +5 -0
- package/template/apps/web/AGENTS.md +29 -0
- package/template/apps/web/Dockerfile +49 -0
- package/template/apps/web/_gitignore +16 -0
- package/template/apps/web/knip.config.ts +15 -0
- package/template/apps/web/kustomize/base/application.properties +5 -0
- package/template/apps/web/kustomize/base/deployment.yaml +60 -0
- package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/web/kustomize/base/service.yaml +10 -0
- package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
- package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/web/messages/de.json +124 -0
- package/template/apps/web/messages/en.json +124 -0
- package/template/apps/web/next-env.d.ts +7 -0
- package/template/apps/web/next.config.ts +39 -0
- package/template/apps/web/oxfmt.config.ts +9 -0
- package/template/apps/web/oxlint.config.ts +36 -0
- package/template/apps/web/package.json +80 -0
- package/template/apps/web/playwright.config.ts +34 -0
- package/template/apps/web/postcss.config.mjs +7 -0
- package/template/apps/web/sonar-project.properties +9 -0
- package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
- package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
- package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
- package/template/apps/web/src/app/[locale]/page.tsx +23 -0
- package/template/apps/web/src/app/api/health/route.ts +3 -0
- package/template/apps/web/src/app/global-error.tsx +19 -0
- package/template/apps/web/src/app/globals.css +98 -0
- package/template/apps/web/src/app/icon.svg +1 -0
- package/template/apps/web/src/app/robots.ts +15 -0
- package/template/apps/web/src/app/sitemap.ts +24 -0
- package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
- package/template/apps/web/src/components/alert/alert.tsx +50 -0
- package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
- package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
- package/template/apps/web/src/components/button/button-variants.ts +28 -0
- package/template/apps/web/src/components/button/button.stories.tsx +33 -0
- package/template/apps/web/src/components/button/button.tsx +32 -0
- package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
- package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
- package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
- package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
- package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
- package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
- package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
- package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
- package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
- package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
- package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
- package/template/apps/web/src/components/empty-state.tsx +33 -0
- package/template/apps/web/src/components/field-group.stories.tsx +47 -0
- package/template/apps/web/src/components/field-group.tsx +20 -0
- package/template/apps/web/src/components/field-label.stories.tsx +34 -0
- package/template/apps/web/src/components/field-label.tsx +28 -0
- package/template/apps/web/src/components/icons.tsx +25 -0
- package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
- package/template/apps/web/src/components/input/input-otp.tsx +184 -0
- package/template/apps/web/src/components/input/input.stories.tsx +35 -0
- package/template/apps/web/src/components/input/input.tsx +180 -0
- package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
- package/template/apps/web/src/components/popover/popover.tsx +45 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
- package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
- package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
- package/template/apps/web/src/components/select/select.stories.tsx +293 -0
- package/template/apps/web/src/components/select/select.tsx +445 -0
- package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
- package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
- package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
- package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
- package/template/apps/web/src/components/switch/switch.tsx +35 -0
- package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
- package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
- package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
- package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
- package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
- package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
- package/template/apps/web/src/components/toast/toaster.tsx +38 -0
- package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
- package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
- package/template/apps/web/src/config/csp.ts +59 -0
- package/template/apps/web/src/config/routes.ts +14 -0
- package/template/apps/web/src/config/site.ts +5 -0
- package/template/apps/web/src/env.ts +15 -0
- package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
- package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
- package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
- package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
- package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
- package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
- package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
- package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
- package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
- package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
- package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
- package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
- package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
- package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
- package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
- package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
- package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
- package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
- package/template/apps/web/src/features/shared/provider.tsx +16 -0
- package/template/apps/web/src/features/shared/site-header.tsx +48 -0
- package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
- package/template/apps/web/src/i18n/navigation.ts +5 -0
- package/template/apps/web/src/i18n/request.ts +21 -0
- package/template/apps/web/src/i18n/routing.ts +6 -0
- package/template/apps/web/src/lib/auth-client.ts +19 -0
- package/template/apps/web/src/lib/auth-server.ts +46 -0
- package/template/apps/web/src/lib/auth.ts +8 -0
- package/template/apps/web/src/lib/codegen.ts +30 -0
- package/template/apps/web/src/lib/graphql-client.ts +29 -0
- package/template/apps/web/src/lib/graphql-server.ts +28 -0
- package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
- package/template/apps/web/src/lib/graphql-utils.ts +76 -0
- package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
- package/template/apps/web/src/lib/metadata.ts +17 -0
- package/template/apps/web/src/lib/network-error.test.ts +30 -0
- package/template/apps/web/src/lib/network-error.ts +17 -0
- package/template/apps/web/src/lib/proxy-utils.ts +42 -0
- package/template/apps/web/src/lib/query-client.ts +29 -0
- package/template/apps/web/src/lib/utils.ts +5 -0
- package/template/apps/web/src/proxy.ts +57 -0
- package/template/apps/web/test-results/.last-run.json +4 -0
- package/template/apps/web/tests/auth.spec.ts +17 -0
- package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
- package/template/apps/web/tests/home.spec.ts +25 -0
- package/template/apps/web/tsconfig.build.json +4 -0
- package/template/apps/web/tsconfig.json +28 -0
- package/template/apps/web/vitest.config.ts +40 -0
- package/template/apps/web/vitest.shims.d.ts +1 -0
- package/template/apps/worker/.env.example +4 -0
- package/template/apps/worker/AGENTS.md +11 -0
- package/template/apps/worker/Dockerfile +35 -0
- package/template/apps/worker/knip.config.ts +3 -0
- package/template/apps/worker/kustomize/base/application.properties +4 -0
- package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
- package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
- package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/worker/oxfmt.config.ts +8 -0
- package/template/apps/worker/oxlint.config.ts +15 -0
- package/template/apps/worker/package.json +33 -0
- package/template/apps/worker/sonar-project.properties +8 -0
- package/template/apps/worker/src/env.ts +13 -0
- package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
- package/template/apps/worker/src/lib/logger.ts +20 -0
- package/template/apps/worker/src/main.ts +17 -0
- package/template/apps/worker/src/processors/example-processor.ts +15 -0
- package/template/apps/worker/src/worker/redis.ts +19 -0
- package/template/apps/worker/src/worker/worker.ts +37 -0
- package/template/apps/worker/tests/example-processor.test.ts +13 -0
- package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
- package/template/apps/worker/tsconfig.json +4 -0
- package/template/apps/worker/vitest.config.ts +13 -0
- package/template/bun.lock +3053 -0
- package/template/bunfig.toml +2 -0
- package/template/compose.yaml +53 -0
- package/template/docker-bake.hcl +86 -0
- package/template/package.json +47 -0
- package/template/packages/lint/knip.ts +40 -0
- package/template/packages/lint/oxfmt.ts +8 -0
- package/template/packages/lint/oxlint.ts +136 -0
- package/template/packages/lint/package.json +13 -0
- package/template/packages/queues/oxfmt.config.ts +8 -0
- package/template/packages/queues/oxlint.config.ts +7 -0
- package/template/packages/queues/package.json +15 -0
- package/template/packages/queues/src/example-job.ts +11 -0
- package/template/packages/queues/src/index.ts +3 -0
- package/template/packages/queues/src/queue-names.ts +2 -0
- package/template/packages/queues/src/redis-connection.ts +27 -0
- package/template/packages/queues/tsconfig.json +4 -0
- package/template/packages/tsconfig/base.json +23 -0
- package/template/packages/tsconfig/node.json +11 -0
- package/template/packages/tsconfig/package.json +4 -0
- package/template/turbo.json +50 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { hasLocale } from "next-intl";
|
|
2
|
+
import { getRequestConfig } from "next-intl/server";
|
|
3
|
+
import { notFound } from "next/navigation";
|
|
4
|
+
import { locale as rootLocale } from "next/root-params";
|
|
5
|
+
import { routing } from "./routing";
|
|
6
|
+
|
|
7
|
+
export default getRequestConfig(async ({ locale }) => {
|
|
8
|
+
if (!locale) {
|
|
9
|
+
const paramValue = await rootLocale();
|
|
10
|
+
if (hasLocale(routing.locales, paramValue)) {
|
|
11
|
+
locale = paramValue;
|
|
12
|
+
} else {
|
|
13
|
+
notFound();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
locale,
|
|
19
|
+
messages: (await import(`../../messages/${locale}.json`)).default,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { passkeyClient } from "@better-auth/passkey/client";
|
|
2
|
+
import { createAuthClient } from "better-auth/client";
|
|
3
|
+
import { oneTapClient } from "better-auth/client/plugins";
|
|
4
|
+
import { env } from "@/env";
|
|
5
|
+
|
|
6
|
+
/** Google sign-in is optional: without a client ID the button and One Tap stay hidden. */
|
|
7
|
+
export const isGoogleEnabled = Boolean(env.NEXT_PUBLIC_GOOGLE_CLIENT_ID);
|
|
8
|
+
|
|
9
|
+
export const authClient = createAuthClient({
|
|
10
|
+
baseURL: env.NEXT_PUBLIC_API_URL,
|
|
11
|
+
plugins: [oneTapClient({ clientId: env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "" }), passkeyClient()],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type AuthError = {
|
|
15
|
+
code?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
status: number;
|
|
18
|
+
statusText: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { User as BetterAuthUser, Session } from "better-auth";
|
|
2
|
+
import { env } from "@/env";
|
|
3
|
+
|
|
4
|
+
export type SessionResponse = null | {
|
|
5
|
+
session: SerializedSession;
|
|
6
|
+
user: SessionUser;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** The Better Auth user plus the fields the API's `customSession` plugin adds. */
|
|
10
|
+
type SessionUser = BetterAuthUser & {
|
|
11
|
+
role?: null | string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type SerializedSession = Omit<Session, "createdAt" | "expiresAt" | "updatedAt"> & {
|
|
15
|
+
createdAt?: string;
|
|
16
|
+
expiresAt?: string;
|
|
17
|
+
updatedAt?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the session behind a `Cookie` header against the API.
|
|
22
|
+
*
|
|
23
|
+
* Only the cookie is forwarded on purpose: passing the incoming request's headers
|
|
24
|
+
* through would include its `Host`, which Bun's fetch sends as-is and thereby
|
|
25
|
+
* routes the call to the web host instead of the API.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchSession(cookie: null | string | undefined): Promise<SessionResponse> {
|
|
28
|
+
if (!cookie) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/auth/get-session`, {
|
|
34
|
+
cache: "no-store",
|
|
35
|
+
headers: { cookie },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (await response.json()) as SessionResponse;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { fetchSession } from "@/lib/auth-server";
|
|
3
|
+
|
|
4
|
+
/** The current session in a server component, route handler or action. */
|
|
5
|
+
export async function getSession() {
|
|
6
|
+
const headerStore = await headers();
|
|
7
|
+
return fetchSession(headerStore.get("cookie"));
|
|
8
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CodegenConfig } from "@graphql-codegen/cli";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `bun run codegen` — introspects the running API (or `CODEGEN_SCHEMA`) and
|
|
5
|
+
* generates typed `graphql()` documents into src/generated/gql.
|
|
6
|
+
*/
|
|
7
|
+
const config: CodegenConfig = {
|
|
8
|
+
documents: ["src/**/*.{ts,tsx}", "!src/generated/**"],
|
|
9
|
+
generates: {
|
|
10
|
+
"./src/generated/gql/": {
|
|
11
|
+
config: {
|
|
12
|
+
enumValues: "./schema-types",
|
|
13
|
+
scalars: { DateTime: "string" },
|
|
14
|
+
},
|
|
15
|
+
preset: "client",
|
|
16
|
+
},
|
|
17
|
+
"./src/generated/gql/schema-types.ts": {
|
|
18
|
+
config: {
|
|
19
|
+
scalars: { DateTime: "string" },
|
|
20
|
+
},
|
|
21
|
+
plugins: ["typescript"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
ignoreNoDocuments: true,
|
|
25
|
+
schema:
|
|
26
|
+
process.env.CODEGEN_SCHEMA ??
|
|
27
|
+
`${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"}/graphql`,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default config;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
|
|
2
|
+
import { GraphQLClient } from "graphql-request";
|
|
3
|
+
import { env } from "@/env";
|
|
4
|
+
import { toApiError } from "./graphql-utils";
|
|
5
|
+
|
|
6
|
+
const client = new GraphQLClient(`${env.NEXT_PUBLIC_API_URL}/graphql`, {
|
|
7
|
+
credentials: "include",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** Browser-side API client: sends the session cookie, throws `ApiError`s. */
|
|
11
|
+
export const graphqlClient = {
|
|
12
|
+
async request<TData, TVars>(
|
|
13
|
+
doc: TypedDocumentNode<TData, TVars>,
|
|
14
|
+
variables?: TVars,
|
|
15
|
+
): Promise<TData> {
|
|
16
|
+
try {
|
|
17
|
+
// @ts-expect-error -- graphql-request's overloads don't accept an optional variables arg
|
|
18
|
+
return await client.request<TData, TVars>(doc, variables);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const apiError = toApiError(error);
|
|
21
|
+
// @if auth
|
|
22
|
+
if (apiError.code === "UNAUTHORIZED" && typeof window !== "undefined") {
|
|
23
|
+
window.location.href = "/sign-in";
|
|
24
|
+
}
|
|
25
|
+
// @endif
|
|
26
|
+
throw apiError;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
|
|
3
|
+
import { GraphQLClient } from "graphql-request";
|
|
4
|
+
import { env } from "@/env";
|
|
5
|
+
import type { ApiError } from "./graphql-utils";
|
|
6
|
+
import { toApiError } from "./graphql-utils";
|
|
7
|
+
|
|
8
|
+
const client = new GraphQLClient(`${env.NEXT_PUBLIC_API_URL}/graphql`);
|
|
9
|
+
|
|
10
|
+
type APIResult<TData> = { data?: TData; error?: ApiError };
|
|
11
|
+
|
|
12
|
+
/** Server-side API client: forward the incoming cookie to act as the user. */
|
|
13
|
+
export async function APIClient<TData, TVars>(
|
|
14
|
+
doc: TypedDocumentNode<TData, TVars>,
|
|
15
|
+
{ cookie, variables }: { cookie?: string; variables?: TVars } = {},
|
|
16
|
+
): Promise<APIResult<TData>> {
|
|
17
|
+
try {
|
|
18
|
+
// @ts-expect-error -- graphql-request's overloads don't accept an optional variables arg
|
|
19
|
+
const data = await client.request<TData, TVars>(
|
|
20
|
+
doc,
|
|
21
|
+
variables,
|
|
22
|
+
cookie ? { cookie } : undefined,
|
|
23
|
+
);
|
|
24
|
+
return { data };
|
|
25
|
+
} catch (error: unknown) {
|
|
26
|
+
return { error: toApiError(error) };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ClientError } from "graphql-request";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { apiErrorMessage, toApiError } from "./graphql-utils";
|
|
4
|
+
|
|
5
|
+
describe("toApiError", () => {
|
|
6
|
+
it("maps a dropped connection to NETWORK_ERROR", () => {
|
|
7
|
+
expect(toApiError(new TypeError("Failed to fetch")).code).toBe("NETWORK_ERROR");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("reads the code and params from the first GraphQL error", () => {
|
|
11
|
+
const response = {
|
|
12
|
+
errors: [{ extensions: { code: "NOT_FOUND", params: { id: "1" } }, message: "NOT_FOUND" }],
|
|
13
|
+
status: 200,
|
|
14
|
+
} as unknown as ConstructorParameters<typeof ClientError>[0];
|
|
15
|
+
const error = new ClientError(response, { query: "query { x }" });
|
|
16
|
+
|
|
17
|
+
expect(toApiError(error)).toMatchObject({ code: "NOT_FOUND", params: { id: "1" } });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("apiErrorMessage", () => {
|
|
22
|
+
const t = (key: string) => `t:${key}`;
|
|
23
|
+
|
|
24
|
+
it("translates known codes", () => {
|
|
25
|
+
expect(apiErrorMessage(t, { code: "UNAUTHORIZED" })).toBe("t:UNAUTHORIZED");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("falls back for unknown or missing codes", () => {
|
|
29
|
+
expect(apiErrorMessage(t, { code: "SOMETHING_NEW" })).toBe("t:defaultError");
|
|
30
|
+
expect(apiErrorMessage(t, null)).toBe("t:defaultError");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ClientError } from "graphql-request";
|
|
2
|
+
import { isNetworkError } from "./network-error";
|
|
3
|
+
|
|
4
|
+
export type ApiErrorParams = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A backend failure, carrying the machine-readable `code` the API returns
|
|
8
|
+
* (never prose) plus any structured `params` for interpolation.
|
|
9
|
+
*
|
|
10
|
+
* `message` mirrors `code` so anything that still reads `.message` sees the
|
|
11
|
+
* code rather than blank text, but display code should go through
|
|
12
|
+
* `apiErrorMessage` (backed by the `ApiErrors` next-intl namespace) instead of
|
|
13
|
+
* showing `.message` directly.
|
|
14
|
+
*/
|
|
15
|
+
export class ApiError extends Error {
|
|
16
|
+
readonly code?: string;
|
|
17
|
+
readonly params?: ApiErrorParams;
|
|
18
|
+
|
|
19
|
+
constructor(code?: string, params?: ApiErrorParams) {
|
|
20
|
+
super(code ?? "UNKNOWN_ERROR");
|
|
21
|
+
this.name = "ApiError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.params = params;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds the `ApiError` a `graphql-request` failure carries. A request that
|
|
29
|
+
* never reached the API gets the client-side `NETWORK_ERROR` code; anything
|
|
30
|
+
* else without a response error is codeless.
|
|
31
|
+
*/
|
|
32
|
+
export function toApiError(error: unknown): ApiError {
|
|
33
|
+
if (isNetworkError(error)) return new ApiError("NETWORK_ERROR");
|
|
34
|
+
|
|
35
|
+
const first = error instanceof ClientError ? error.response.errors?.[0] : undefined;
|
|
36
|
+
const code = first?.extensions?.code as string | undefined;
|
|
37
|
+
const params = first?.extensions?.params as ApiErrorParams | undefined;
|
|
38
|
+
return new ApiError(code, params);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The API's `ErrorCode` values (plus the client-side `NETWORK_ERROR`) that have
|
|
43
|
+
* their own copy in the `ApiErrors` namespace of `messages/*.json`. Anything else
|
|
44
|
+
* falls back to `ApiErrors.defaultError`, so an unmapped code never shows a raw
|
|
45
|
+
* translation key on screen.
|
|
46
|
+
*/
|
|
47
|
+
const TRANSLATED_ERROR_CODES = new Set([
|
|
48
|
+
"BAD_REQUEST",
|
|
49
|
+
"CONFLICT",
|
|
50
|
+
"EMAIL_NOT_VERIFIED",
|
|
51
|
+
"FORBIDDEN",
|
|
52
|
+
"INSUFFICIENT_ROLE",
|
|
53
|
+
"INTERNAL_SERVER_ERROR",
|
|
54
|
+
"NETWORK_ERROR",
|
|
55
|
+
"NOT_FOUND",
|
|
56
|
+
"SERVICE_UNAVAILABLE",
|
|
57
|
+
"TOO_MANY_REQUESTS",
|
|
58
|
+
"UNAUTHORIZED",
|
|
59
|
+
"UNPROCESSABLE_ENTITY",
|
|
60
|
+
"VALIDATION_FAILED",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Translates an API error's `code` through the `ApiErrors` next-intl namespace,
|
|
65
|
+
* falling back to `ApiErrors.defaultError`.
|
|
66
|
+
*
|
|
67
|
+
* @param t - `useTranslations("ApiErrors")` from the calling component
|
|
68
|
+
* @param error - The error a mutation/query's `onError`/`error` carries
|
|
69
|
+
*/
|
|
70
|
+
export function apiErrorMessage(
|
|
71
|
+
t: (key: string) => string,
|
|
72
|
+
error: null | undefined | { code?: string },
|
|
73
|
+
): string {
|
|
74
|
+
const code = error?.code;
|
|
75
|
+
return code && TRANSLATED_ERROR_CODES.has(code) ? t(code) : t("defaultError");
|
|
76
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
export function useMediaQuery(query: string) {
|
|
6
|
+
const subscribe = (callback: () => void) => {
|
|
7
|
+
const media = window.matchMedia(query);
|
|
8
|
+
media.addEventListener("change", callback);
|
|
9
|
+
return () => media.removeEventListener("change", callback);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getSnapshot = () => {
|
|
13
|
+
return window.matchMedia(query).matches;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getServerSnapshot = () => {
|
|
17
|
+
return false;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { siteConfig } from "@/config/site";
|
|
3
|
+
|
|
4
|
+
export function constructMetadata(): Metadata {
|
|
5
|
+
return {
|
|
6
|
+
description: siteConfig.description,
|
|
7
|
+
metadataBase: new URL(siteConfig.url),
|
|
8
|
+
openGraph: {
|
|
9
|
+
description: siteConfig.description,
|
|
10
|
+
title: siteConfig.name,
|
|
11
|
+
},
|
|
12
|
+
title: {
|
|
13
|
+
default: siteConfig.name,
|
|
14
|
+
template: `%s | ${siteConfig.name}`,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isNetworkError } from "./network-error";
|
|
3
|
+
|
|
4
|
+
describe("isNetworkError", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["Firefox", "NetworkError when attempting to fetch resource."],
|
|
7
|
+
["Chromium", "Failed to fetch"],
|
|
8
|
+
["Safari", "Load failed"],
|
|
9
|
+
["Node", "fetch failed"],
|
|
10
|
+
])("recognises the %s wording of a failed fetch", (_engine, message) => {
|
|
11
|
+
expect(isNetworkError(new TypeError(message))).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("ignores a TypeError that is not a failed request", () => {
|
|
15
|
+
expect(
|
|
16
|
+
isNetworkError(new TypeError("Cannot read properties of undefined (reading 'id')")),
|
|
17
|
+
).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("ignores an error that merely carries the same text", () => {
|
|
21
|
+
expect(isNetworkError(new Error("Failed to fetch"))).toBe(false);
|
|
22
|
+
expect(isNetworkError({ message: "Failed to fetch" })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("ignores values that are not errors at all", () => {
|
|
26
|
+
expect(isNetworkError(null)).toBe(false);
|
|
27
|
+
expect(isNetworkError(undefined)).toBe(false);
|
|
28
|
+
expect(isNetworkError("Failed to fetch")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wording each engine puts on the `TypeError` `fetch` rejects with when the
|
|
3
|
+
* request never got an HTTP response (offline, DNS, refused connection, CORS
|
|
4
|
+
* block): Firefox, Chromium, Safari, and Node's undici for server-side calls.
|
|
5
|
+
*/
|
|
6
|
+
const FETCH_FAILURE_MESSAGE =
|
|
7
|
+
/^(?:networkerror when attempting to fetch resource|failed to fetch|load failed|fetch failed|network request failed)/i;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether `error` is a request that never reached the API, as opposed to one
|
|
11
|
+
* the API answered with an error. The browser's wording for it (e.g. "NetworkError
|
|
12
|
+
* when attempting to fetch resource.") is meaningless to a user, so callers
|
|
13
|
+
* show translated copy instead of `error.message`.
|
|
14
|
+
*/
|
|
15
|
+
export function isNetworkError(error: unknown): boolean {
|
|
16
|
+
return error instanceof TypeError && FETCH_FAILURE_MESSAGE.test(error.message);
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { generateCspHeader } from "@/config/csp";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generates a per-request nonce + CSP and returns the request to hand to the
|
|
6
|
+
* next middleware (so Next.js nonces its own scripts) plus a helper that stamps
|
|
7
|
+
* the same header on the response.
|
|
8
|
+
*/
|
|
9
|
+
export function createCspContext(request: NextRequest) {
|
|
10
|
+
const nonce = crypto.randomUUID().replaceAll("-", "");
|
|
11
|
+
const cspHeader = generateCspHeader({ nonce });
|
|
12
|
+
const requestHeaders = new Headers(request.headers);
|
|
13
|
+
requestHeaders.set("Content-Security-Policy", cspHeader);
|
|
14
|
+
requestHeaders.set("x-nonce", nonce);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
applyTo(response: NextResponse) {
|
|
18
|
+
response.headers.set("Content-Security-Policy", cspHeader);
|
|
19
|
+
return response;
|
|
20
|
+
},
|
|
21
|
+
request: new NextRequest(request, { headers: requestHeaders }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// @if auth
|
|
25
|
+
|
|
26
|
+
/** Sends the user to sign-in, remembering where they wanted to go. */
|
|
27
|
+
export function redirectToSignIn(
|
|
28
|
+
request: NextRequest,
|
|
29
|
+
pathname: string,
|
|
30
|
+
locale: string,
|
|
31
|
+
): NextResponse {
|
|
32
|
+
const signInUrl = new URL(`/${locale}/sign-in`, request.url);
|
|
33
|
+
|
|
34
|
+
const requestedPath =
|
|
35
|
+
(locale ? pathname.slice(locale.length + 1) : pathname) + request.nextUrl.search;
|
|
36
|
+
if (requestedPath && requestedPath !== "/" && !requestedPath.startsWith("/sign-in")) {
|
|
37
|
+
signInUrl.searchParams.set("returnTo", requestedPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return NextResponse.redirect(signInUrl);
|
|
41
|
+
}
|
|
42
|
+
// @endif
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { isServer, QueryClient } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
function makeQueryClient() {
|
|
6
|
+
return new QueryClient({
|
|
7
|
+
defaultOptions: {
|
|
8
|
+
mutations: {
|
|
9
|
+
onError: (error) => {
|
|
10
|
+
console.error("Query mutation failed:", error);
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
queries: {
|
|
14
|
+
staleTime: 60 * 1000,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let browserQueryClient: QueryClient | undefined;
|
|
21
|
+
|
|
22
|
+
export function getQueryClient() {
|
|
23
|
+
if (isServer) {
|
|
24
|
+
return makeQueryClient();
|
|
25
|
+
} else {
|
|
26
|
+
browserQueryClient ??= makeQueryClient();
|
|
27
|
+
return browserQueryClient;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import createMiddleware from "next-intl/middleware";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
import { NextResponse } from "next/server"; // @if auth
|
|
4
|
+
import { guestOnlyRoutes, publicPaths } from "./config/routes"; // @if auth
|
|
5
|
+
import { routing } from "./i18n/routing";
|
|
6
|
+
import { fetchSession } from "./lib/auth-server"; // @if auth
|
|
7
|
+
import { createCspContext, redirectToSignIn } from "./lib/proxy-utils"; // @if auth
|
|
8
|
+
// import { createCspContext } from "./lib/proxy-utils"; // @if !auth
|
|
9
|
+
|
|
10
|
+
const i18nMiddleware = createMiddleware(routing);
|
|
11
|
+
|
|
12
|
+
export async function proxy(request: NextRequest) {
|
|
13
|
+
const csp = createCspContext(request);
|
|
14
|
+
const response = await handleRequest(request, csp.request);
|
|
15
|
+
return csp.applyTo(response);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// @if auth
|
|
19
|
+
async function handleRequest(request: NextRequest, requestWithCsp: NextRequest) {
|
|
20
|
+
const i18nResponse = i18nMiddleware(requestWithCsp);
|
|
21
|
+
if (i18nResponse.status >= 300 && i18nResponse.status < 400) {
|
|
22
|
+
return i18nResponse;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { pathname } = request.nextUrl;
|
|
26
|
+
const locale = pathname.match(/^\/([^/]+)/)?.[1] ?? routing.defaultLocale;
|
|
27
|
+
const route = pathname.slice(locale.length + 1) || "/";
|
|
28
|
+
const session = await fetchSession(request.headers.get("cookie"));
|
|
29
|
+
|
|
30
|
+
if (!session && !publicPaths.has(pathname)) {
|
|
31
|
+
return redirectToSignIn(request, pathname, locale);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (session && guestOnlyRoutes.includes(route)) {
|
|
35
|
+
return NextResponse.redirect(new URL(`/${locale}/dashboard`, request.url));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return i18nResponse;
|
|
39
|
+
}
|
|
40
|
+
// @endif
|
|
41
|
+
// @if !auth
|
|
42
|
+
// function handleRequest(_request: NextRequest, requestWithCsp: NextRequest) {
|
|
43
|
+
// return i18nMiddleware(requestWithCsp);
|
|
44
|
+
// }
|
|
45
|
+
// @endif
|
|
46
|
+
|
|
47
|
+
export const config = {
|
|
48
|
+
matcher: [
|
|
49
|
+
{
|
|
50
|
+
missing: [
|
|
51
|
+
{ key: "next-router-prefetch", type: "header" },
|
|
52
|
+
{ key: "purpose", type: "header", value: "prefetch" },
|
|
53
|
+
],
|
|
54
|
+
source: "/((?!api|_next|.*\\.[\\w]+$).*)",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test.describe("auth", () => {
|
|
4
|
+
test("sends anonymous visitors of a protected page to sign-in", async ({ page }) => {
|
|
5
|
+
await page.goto("/en/dashboard");
|
|
6
|
+
|
|
7
|
+
await expect(page).toHaveURL(/\/en\/sign-in\?returnTo=%2Fdashboard$/);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("validates the sign-in form before calling the API", async ({ page }) => {
|
|
11
|
+
await page.goto("/en/sign-in");
|
|
12
|
+
|
|
13
|
+
await page.getByRole("button", { name: "Sign in" }).click();
|
|
14
|
+
|
|
15
|
+
await expect(page.getByText("Enter a valid email address.")).toBeVisible();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Guards the hash allowlist in src/config/csp.ts: next-themes' theme-init
|
|
5
|
+
* <script> (see src/features/shared/provider.tsx) gets no per-request nonce,
|
|
6
|
+
* so it only runs if its exact rendered bytes match one of the checked-in
|
|
7
|
+
* sha256 hashes. Turbopack renders it differently in dev vs. `next build`, so
|
|
8
|
+
* a next-themes/Turbopack upgrade can silently drift the hash and get the
|
|
9
|
+
* script CSP-blocked (theme flash, no thrown error) without this failing.
|
|
10
|
+
*/
|
|
11
|
+
test.describe("CSP: theme init script", () => {
|
|
12
|
+
test("next-themes inline script runs without a CSP violation", async ({ page }) => {
|
|
13
|
+
await page.addInitScript(() => {
|
|
14
|
+
(window as unknown as { __cspViolations: string[] }).__cspViolations = [];
|
|
15
|
+
window.addEventListener("securitypolicyviolation", (event) => {
|
|
16
|
+
(window as unknown as { __cspViolations: string[] }).__cspViolations.push(
|
|
17
|
+
`${event.violatedDirective}: ${event.blockedURI}`,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await page.goto("http://localhost:3000/en");
|
|
23
|
+
await page.waitForLoadState("domcontentloaded");
|
|
24
|
+
|
|
25
|
+
const appliedThemeClass = await page.evaluate(
|
|
26
|
+
() =>
|
|
27
|
+
document.documentElement.classList.contains("light") ||
|
|
28
|
+
document.documentElement.classList.contains("dark"),
|
|
29
|
+
);
|
|
30
|
+
expect(appliedThemeClass, "next-themes should apply light/dark before hydration").toBe(true);
|
|
31
|
+
|
|
32
|
+
const violations = await page.evaluate(
|
|
33
|
+
() => (window as unknown as { __cspViolations: string[] }).__cspViolations,
|
|
34
|
+
);
|
|
35
|
+
expect(violations, "a hash in src/config/csp.ts is stale — see the comment there").toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import AxeBuilder from "@axe-core/playwright";
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
|
|
4
|
+
test.describe("home", () => {
|
|
5
|
+
test("renders the hero without accessibility violations", async ({ page }) => {
|
|
6
|
+
await page.goto("/en");
|
|
7
|
+
|
|
8
|
+
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
9
|
+
|
|
10
|
+
const results = await new AxeBuilder({ page }).analyze();
|
|
11
|
+
expect(results.violations).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("redirects the bare root to the default locale", async ({ page }) => {
|
|
15
|
+
await page.goto("/");
|
|
16
|
+
|
|
17
|
+
await expect(page).toHaveURL(/\/en$/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("serves the German locale", async ({ page }) => {
|
|
21
|
+
await page.goto("/de");
|
|
22
|
+
|
|
23
|
+
await expect(page.locator("html")).toHaveAttribute("lang", "de");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@nexst/tsconfig/base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"jsx": "react-jsx",
|
|
5
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
6
|
+
"module": "esnext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"paths": {
|
|
9
|
+
"@/*": ["./src/*"],
|
|
10
|
+
"~sb/*": ["./.storybook/*"]
|
|
11
|
+
},
|
|
12
|
+
"allowJs": true,
|
|
13
|
+
"plugins": [
|
|
14
|
+
{
|
|
15
|
+
"name": "next"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"include": [
|
|
20
|
+
"next-env.d.ts",
|
|
21
|
+
"**/*.ts",
|
|
22
|
+
"**/*.tsx",
|
|
23
|
+
".storybook/**/*.ts",
|
|
24
|
+
".storybook/**/*.tsx",
|
|
25
|
+
".next/types/**/*.ts",
|
|
26
|
+
".next/dev/types/**/*.ts"
|
|
27
|
+
]
|
|
28
|
+
}
|