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,49 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export type EmailProps = z.infer<ReturnType<typeof createEmailSchema>>;
|
|
4
|
+
|
|
5
|
+
export type NewPasswordProps = z.infer<ReturnType<typeof createNewPasswordSchema>>;
|
|
6
|
+
export type SignInProps = z.infer<ReturnType<typeof createSignInSchema>>;
|
|
7
|
+
export type SignUpProps = z.infer<ReturnType<typeof createSignUpSchema>>;
|
|
8
|
+
type Translate = (key: string) => string;
|
|
9
|
+
|
|
10
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
11
|
+
|
|
12
|
+
export function createEmailSchema(t: Translate) {
|
|
13
|
+
return z.object({
|
|
14
|
+
email: z.email(t("Auth.Errors.emailInvalid")),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createNewPasswordSchema(t: Translate) {
|
|
19
|
+
return z
|
|
20
|
+
.object({
|
|
21
|
+
confirmPassword: z.string().min(1, t("Auth.Errors.confirmPasswordRequired")),
|
|
22
|
+
password: z.string().min(MIN_PASSWORD_LENGTH, t("Auth.Errors.passwordTooShort")),
|
|
23
|
+
})
|
|
24
|
+
.refine((data) => data.password === data.confirmPassword, {
|
|
25
|
+
message: t("Auth.Errors.passwordsMustMatch"),
|
|
26
|
+
path: ["confirmPassword"],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createSignInSchema(t: Translate) {
|
|
31
|
+
return z.object({
|
|
32
|
+
email: z.email(t("Auth.Errors.emailInvalid")),
|
|
33
|
+
password: z.string().min(1, t("Auth.Errors.passwordRequired")),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSignUpSchema(t: Translate) {
|
|
38
|
+
return z
|
|
39
|
+
.object({
|
|
40
|
+
confirmPassword: z.string().min(1, t("Auth.Errors.confirmPasswordRequired")),
|
|
41
|
+
email: z.email(t("Auth.Errors.emailInvalid")),
|
|
42
|
+
name: z.string().trim().min(1, t("Auth.Errors.nameRequired")).max(100),
|
|
43
|
+
password: z.string().min(MIN_PASSWORD_LENGTH, t("Auth.Errors.passwordTooShort")),
|
|
44
|
+
})
|
|
45
|
+
.refine((data) => data.password === data.confirmPassword, {
|
|
46
|
+
message: t("Auth.Errors.passwordsMustMatch"),
|
|
47
|
+
path: ["confirmPassword"],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { KeyRound, Trash2 } from "lucide-react";
|
|
5
|
+
import { useFormatter, useTranslations } from "next-intl";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { Button } from "@/components/button/button";
|
|
8
|
+
import { authClient } from "@/lib/auth-client";
|
|
9
|
+
|
|
10
|
+
const PASSKEYS_QUERY_KEY = ["passkeys"];
|
|
11
|
+
|
|
12
|
+
/** Lists the signed-in user's passkeys and lets them add or delete one. */
|
|
13
|
+
export function PasskeySettings() {
|
|
14
|
+
const t = useTranslations("Auth.Passkey");
|
|
15
|
+
const format = useFormatter();
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
|
|
18
|
+
const { data: passkeys = [], isLoading } = useQuery({
|
|
19
|
+
queryFn: async () => {
|
|
20
|
+
const { data, error } = await authClient.passkey.listUserPasskeys();
|
|
21
|
+
if (error) throw error;
|
|
22
|
+
return data ?? [];
|
|
23
|
+
},
|
|
24
|
+
queryKey: PASSKEYS_QUERY_KEY,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const refresh = () => queryClient.invalidateQueries({ queryKey: PASSKEYS_QUERY_KEY });
|
|
28
|
+
|
|
29
|
+
const { isPending: isAdding, mutate: addPasskey } = useMutation({
|
|
30
|
+
mutationFn: async () => {
|
|
31
|
+
const { error } = await authClient.passkey.addPasskey({ name: t("defaultName") });
|
|
32
|
+
if (error) throw error;
|
|
33
|
+
},
|
|
34
|
+
onError: () => toast.error(t("addError")),
|
|
35
|
+
onSuccess: async () => {
|
|
36
|
+
await refresh();
|
|
37
|
+
toast.success(t("added"));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const { isPending: isDeleting, mutate: deletePasskey } = useMutation({
|
|
42
|
+
mutationFn: async (id: string) => {
|
|
43
|
+
const { error } = await authClient.passkey.deletePasskey({ id });
|
|
44
|
+
if (error) throw error;
|
|
45
|
+
},
|
|
46
|
+
onError: () => toast.error(t("deleteError")),
|
|
47
|
+
onSuccess: async () => {
|
|
48
|
+
await refresh();
|
|
49
|
+
toast.success(t("deleted"));
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<section className="space-y-4">
|
|
55
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
56
|
+
<div>
|
|
57
|
+
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
|
58
|
+
<p className="text-muted-foreground mt-1 text-sm">{t("description")}</p>
|
|
59
|
+
</div>
|
|
60
|
+
<Button className="shrink-0" isLoading={isAdding} onClick={() => addPasskey()} size="sm">
|
|
61
|
+
{isAdding ? null : <KeyRound aria-hidden size={16} />}
|
|
62
|
+
{t("add")}
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{isLoading ? (
|
|
67
|
+
<p className="text-muted-foreground text-sm">{t("loading")}</p>
|
|
68
|
+
) : passkeys.length === 0 ? (
|
|
69
|
+
<p className="text-muted-foreground bg-card border-border rounded-xl border p-4 text-sm">
|
|
70
|
+
{t("empty")}
|
|
71
|
+
</p>
|
|
72
|
+
) : (
|
|
73
|
+
<ul className="space-y-2">
|
|
74
|
+
{passkeys.map((passkey) => {
|
|
75
|
+
const name = passkey.name || t("fallbackName");
|
|
76
|
+
return (
|
|
77
|
+
<li
|
|
78
|
+
className="bg-card border-border flex items-center gap-3 rounded-xl border p-4"
|
|
79
|
+
key={passkey.id}
|
|
80
|
+
>
|
|
81
|
+
<KeyRound aria-hidden className="text-primary shrink-0" size={19} />
|
|
82
|
+
<div className="min-w-0 flex-1">
|
|
83
|
+
<p className="truncate text-sm font-medium">{name}</p>
|
|
84
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
85
|
+
{t("createdAt", {
|
|
86
|
+
date: format.dateTime(new Date(passkey.createdAt), { dateStyle: "medium" }),
|
|
87
|
+
})}
|
|
88
|
+
</p>
|
|
89
|
+
</div>
|
|
90
|
+
<Button
|
|
91
|
+
aria-label={t("deleteLabel", { name })}
|
|
92
|
+
disabled={isDeleting}
|
|
93
|
+
onClick={() => deletePasskey(passkey.id)}
|
|
94
|
+
size="icon"
|
|
95
|
+
variant="ghost"
|
|
96
|
+
>
|
|
97
|
+
<Trash2 aria-hidden size={17} />
|
|
98
|
+
</Button>
|
|
99
|
+
</li>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</ul>
|
|
103
|
+
)}
|
|
104
|
+
</section>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useMutation } from "@tanstack/react-query";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { useMemo } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import { Alert } from "@/components/alert/alert";
|
|
9
|
+
import { Button } from "@/components/button/button";
|
|
10
|
+
import { Input } from "@/components/input/input";
|
|
11
|
+
import { env } from "@/env";
|
|
12
|
+
import { authClient, type AuthError } from "@/lib/auth-client";
|
|
13
|
+
import { authFallbackMessage } from "./lib/auth-error";
|
|
14
|
+
import { createEmailSchema, type EmailProps } from "./lib/validator";
|
|
15
|
+
|
|
16
|
+
/** Requests a password-reset mail; the link lands on /reset-password?token=…. */
|
|
17
|
+
export function RecoveryForm() {
|
|
18
|
+
const t = useTranslations("Auth.Recovery");
|
|
19
|
+
const tErrors = useTranslations("Auth.Errors");
|
|
20
|
+
const tAll = useTranslations();
|
|
21
|
+
const schema = useMemo(() => createEmailSchema(tAll), [tAll]);
|
|
22
|
+
|
|
23
|
+
const { formState, handleSubmit, register } = useForm<EmailProps>({
|
|
24
|
+
defaultValues: { email: "" },
|
|
25
|
+
resolver: zodResolver(schema),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { error, isPending, isSuccess, mutate } = useMutation<void, AuthError, EmailProps>({
|
|
29
|
+
mutationFn: async ({ email }) => {
|
|
30
|
+
const { error } = await authClient.requestPasswordReset({
|
|
31
|
+
email,
|
|
32
|
+
redirectTo: `${env.NEXT_PUBLIC_HOST_URL}/reset-password`,
|
|
33
|
+
});
|
|
34
|
+
if (error) throw error;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (isSuccess) {
|
|
39
|
+
return <Alert message={t("emailSent")} variant="success" />;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="w-full">
|
|
44
|
+
{error ? <Alert message={authFallbackMessage(error, tErrors)} variant="error" /> : null}
|
|
45
|
+
<form className="space-y-3.5" noValidate onSubmit={handleSubmit((data) => mutate(data))}>
|
|
46
|
+
<Input
|
|
47
|
+
autoComplete="email"
|
|
48
|
+
disabled={isPending}
|
|
49
|
+
error={formState.errors.email?.message}
|
|
50
|
+
label={t("emailLabel")}
|
|
51
|
+
placeholder={t("emailPlaceholder")}
|
|
52
|
+
type="email"
|
|
53
|
+
{...register("email")}
|
|
54
|
+
/>
|
|
55
|
+
<Button className="w-full" isLoading={isPending} type="submit">
|
|
56
|
+
{t("submitButton")}
|
|
57
|
+
</Button>
|
|
58
|
+
</form>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation } from "@tanstack/react-query";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/button/button";
|
|
7
|
+
import { authClient, type AuthError } from "@/lib/auth-client";
|
|
8
|
+
import { authFallbackMessage } from "./lib/auth-error";
|
|
9
|
+
|
|
10
|
+
export function ResendVerificationButton({ email }: Readonly<{ email: string }>) {
|
|
11
|
+
const t = useTranslations("Auth.Verify");
|
|
12
|
+
const tErrors = useTranslations("Auth.Errors");
|
|
13
|
+
|
|
14
|
+
const { isPending, isSuccess, mutate } = useMutation<void, AuthError>({
|
|
15
|
+
mutationFn: async () => {
|
|
16
|
+
const { error } = await authClient.sendVerificationEmail({
|
|
17
|
+
callbackURL: "/dashboard",
|
|
18
|
+
email,
|
|
19
|
+
});
|
|
20
|
+
if (error) throw error;
|
|
21
|
+
},
|
|
22
|
+
onError: (error) => toast.error(authFallbackMessage(error, tErrors)),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Button
|
|
27
|
+
disabled={isSuccess}
|
|
28
|
+
isLoading={isPending}
|
|
29
|
+
onClick={() => mutate()}
|
|
30
|
+
size="sm"
|
|
31
|
+
variant="secondary"
|
|
32
|
+
>
|
|
33
|
+
{isSuccess ? t("resent") : t("resendButton")}
|
|
34
|
+
</Button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useMutation } from "@tanstack/react-query";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { useMemo } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import { Alert } from "@/components/alert/alert";
|
|
9
|
+
import { Button } from "@/components/button/button";
|
|
10
|
+
import { Input } from "@/components/input/input";
|
|
11
|
+
import { useRouter } from "@/i18n/navigation";
|
|
12
|
+
import { authClient, type AuthError } from "@/lib/auth-client";
|
|
13
|
+
import { authFallbackMessage } from "./lib/auth-error";
|
|
14
|
+
import { createNewPasswordSchema, type NewPasswordProps } from "./lib/validator";
|
|
15
|
+
|
|
16
|
+
export function ResetPasswordForm({ token }: Readonly<{ token: string }>) {
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
const t = useTranslations("Auth.ResetPassword");
|
|
19
|
+
const tErrors = useTranslations("Auth.Errors");
|
|
20
|
+
const tAll = useTranslations();
|
|
21
|
+
const schema = useMemo(() => createNewPasswordSchema(tAll), [tAll]);
|
|
22
|
+
|
|
23
|
+
const { formState, handleSubmit, register } = useForm<NewPasswordProps>({
|
|
24
|
+
defaultValues: { confirmPassword: "", password: "" },
|
|
25
|
+
resolver: zodResolver(schema),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { error, isPending, mutate } = useMutation<void, AuthError, NewPasswordProps>({
|
|
29
|
+
mutationFn: async ({ password }) => {
|
|
30
|
+
const { error } = await authClient.resetPassword({ newPassword: password, token });
|
|
31
|
+
if (error) throw error;
|
|
32
|
+
},
|
|
33
|
+
onSuccess: () => router.push("/sign-in"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="w-full">
|
|
38
|
+
{error ? <Alert message={authFallbackMessage(error, tErrors)} variant="error" /> : null}
|
|
39
|
+
<form className="space-y-3.5" noValidate onSubmit={handleSubmit((data) => mutate(data))}>
|
|
40
|
+
<Input
|
|
41
|
+
autoComplete="new-password"
|
|
42
|
+
disabled={isPending}
|
|
43
|
+
error={formState.errors.password?.message}
|
|
44
|
+
label={t("passwordLabel")}
|
|
45
|
+
type="password"
|
|
46
|
+
{...register("password")}
|
|
47
|
+
/>
|
|
48
|
+
<Input
|
|
49
|
+
autoComplete="new-password"
|
|
50
|
+
disabled={isPending}
|
|
51
|
+
error={formState.errors.confirmPassword?.message}
|
|
52
|
+
label={t("confirmPasswordLabel")}
|
|
53
|
+
type="password"
|
|
54
|
+
{...register("confirmPassword")}
|
|
55
|
+
/>
|
|
56
|
+
<Button className="w-full" isLoading={isPending} type="submit">
|
|
57
|
+
{t("submitButton")}
|
|
58
|
+
</Button>
|
|
59
|
+
</form>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { withContainer } from "~sb/decorators";
|
|
4
|
+
import { SignInForm } from "./sign-in-form";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
component: SignInForm,
|
|
8
|
+
decorators: [withContainer()],
|
|
9
|
+
} satisfies Meta<typeof SignInForm>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
export const Empty: Story = {};
|
|
15
|
+
|
|
16
|
+
export const ValidationErrors: Story = {
|
|
17
|
+
play: async ({ canvasElement }) => {
|
|
18
|
+
const canvas = within(canvasElement);
|
|
19
|
+
await userEvent.click(canvas.getByRole("button", { name: "Sign in" }));
|
|
20
|
+
await expect(await canvas.findByText("Enter a valid email address.")).toBeInTheDocument();
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useMutation } from "@tanstack/react-query";
|
|
5
|
+
import { KeyRound } from "lucide-react";
|
|
6
|
+
import { useTranslations } from "next-intl";
|
|
7
|
+
import { useMemo, useState } from "react";
|
|
8
|
+
import { useForm } from "react-hook-form";
|
|
9
|
+
import { Alert } from "@/components/alert/alert";
|
|
10
|
+
import { Button } from "@/components/button/button";
|
|
11
|
+
import { Input } from "@/components/input/input";
|
|
12
|
+
import { Link } from "@/i18n/navigation";
|
|
13
|
+
import { authClient, type AuthError } from "@/lib/auth-client";
|
|
14
|
+
import { AuthDivider } from "./auth-divider";
|
|
15
|
+
import { GoogleAuthButton } from "./google-auth-button";
|
|
16
|
+
import { authFallbackMessage } from "./lib/auth-error";
|
|
17
|
+
import { getReturnTo } from "./lib/return-to";
|
|
18
|
+
import { createSignInSchema, type SignInProps } from "./lib/validator";
|
|
19
|
+
|
|
20
|
+
export function SignInForm() {
|
|
21
|
+
const t = useTranslations("Auth.SignIn");
|
|
22
|
+
const tErrors = useTranslations("Auth.Errors");
|
|
23
|
+
const tPasskey = useTranslations("Auth.Passkey");
|
|
24
|
+
const tAll = useTranslations();
|
|
25
|
+
const schema = useMemo(() => createSignInSchema(tAll), [tAll]);
|
|
26
|
+
const [googleError, setGoogleError] = useState<AuthError | null>(null);
|
|
27
|
+
|
|
28
|
+
const { formState, handleSubmit, register, setValue } = useForm<SignInProps>({
|
|
29
|
+
defaultValues: { email: "", password: "" },
|
|
30
|
+
resolver: zodResolver(schema),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const { error, isPending, mutate } = useMutation<void, AuthError, SignInProps>({
|
|
34
|
+
mutationFn: async ({ email, password }) => {
|
|
35
|
+
const { error } = await authClient.signIn.email({ email, password });
|
|
36
|
+
if (error) throw error;
|
|
37
|
+
},
|
|
38
|
+
onError: () => setValue("password", ""),
|
|
39
|
+
onSuccess: () => window.location.assign(getReturnTo() ?? "/dashboard"),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
error: passkeyError,
|
|
44
|
+
isPending: isPasskeyPending,
|
|
45
|
+
mutate: signInWithPasskey,
|
|
46
|
+
} = useMutation<void, AuthError>({
|
|
47
|
+
mutationFn: async () => {
|
|
48
|
+
const { error } = await authClient.signIn.passkey();
|
|
49
|
+
if (error) throw error;
|
|
50
|
+
},
|
|
51
|
+
onSuccess: () => window.location.assign(getReturnTo() ?? "/dashboard"),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const isAuthenticating = isPending || isPasskeyPending;
|
|
55
|
+
|
|
56
|
+
const otherError = passkeyError ?? googleError;
|
|
57
|
+
const errorMessage = error
|
|
58
|
+
? error.code === "INVALID_EMAIL_OR_PASSWORD"
|
|
59
|
+
? tErrors("invalidCredentials")
|
|
60
|
+
: authFallbackMessage(error, tErrors)
|
|
61
|
+
: otherError
|
|
62
|
+
? authFallbackMessage(otherError, tErrors)
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="w-full">
|
|
67
|
+
{errorMessage ? <Alert message={errorMessage} variant="error" /> : null}
|
|
68
|
+
<form
|
|
69
|
+
className="space-y-3.5"
|
|
70
|
+
data-testid="sign-in-form"
|
|
71
|
+
noValidate
|
|
72
|
+
onSubmit={handleSubmit((data) => mutate(data))}
|
|
73
|
+
>
|
|
74
|
+
<Input
|
|
75
|
+
autoComplete="email"
|
|
76
|
+
disabled={isAuthenticating}
|
|
77
|
+
error={formState.errors.email?.message}
|
|
78
|
+
label={t("emailLabel")}
|
|
79
|
+
placeholder={t("emailPlaceholder")}
|
|
80
|
+
type="email"
|
|
81
|
+
{...register("email")}
|
|
82
|
+
/>
|
|
83
|
+
<Input
|
|
84
|
+
autoComplete="current-password"
|
|
85
|
+
disabled={isAuthenticating}
|
|
86
|
+
error={formState.errors.password?.message}
|
|
87
|
+
label={t("passwordLabel")}
|
|
88
|
+
type="password"
|
|
89
|
+
{...register("password")}
|
|
90
|
+
/>
|
|
91
|
+
<div className="flex justify-end">
|
|
92
|
+
<Link className="text-link text-sm font-medium hover:underline" href="/recovery">
|
|
93
|
+
{t("forgotPassword")}
|
|
94
|
+
</Link>
|
|
95
|
+
</div>
|
|
96
|
+
<Button className="w-full" disabled={isPasskeyPending} isLoading={isPending} type="submit">
|
|
97
|
+
{t("submitButton")}
|
|
98
|
+
</Button>
|
|
99
|
+
</form>
|
|
100
|
+
<AuthDivider />
|
|
101
|
+
<div className="space-y-3">
|
|
102
|
+
<Button
|
|
103
|
+
className="w-full"
|
|
104
|
+
disabled={isPending}
|
|
105
|
+
isLoading={isPasskeyPending}
|
|
106
|
+
onClick={() => signInWithPasskey()}
|
|
107
|
+
variant="outline"
|
|
108
|
+
>
|
|
109
|
+
{isPasskeyPending ? null : <KeyRound aria-hidden size={18} />}
|
|
110
|
+
{isPasskeyPending ? tPasskey("signingIn") : tPasskey("signIn")}
|
|
111
|
+
</Button>
|
|
112
|
+
<GoogleAuthButton disabled={isAuthenticating} onError={setGoogleError} />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Button } from "@/components/button/button";
|
|
6
|
+
import { authClient } from "@/lib/auth-client";
|
|
7
|
+
|
|
8
|
+
export function SignOutButton() {
|
|
9
|
+
const t = useTranslations("Header");
|
|
10
|
+
const [isPending, setIsPending] = useState(false);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Button
|
|
14
|
+
isLoading={isPending}
|
|
15
|
+
onClick={async () => {
|
|
16
|
+
setIsPending(true);
|
|
17
|
+
await authClient.signOut();
|
|
18
|
+
window.location.assign("/");
|
|
19
|
+
}}
|
|
20
|
+
size="sm"
|
|
21
|
+
variant="outline"
|
|
22
|
+
>
|
|
23
|
+
{t("signOut")}
|
|
24
|
+
</Button>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useMutation } from "@tanstack/react-query";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { useMemo, useState } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import { Alert } from "@/components/alert/alert";
|
|
9
|
+
import { Button } from "@/components/button/button";
|
|
10
|
+
import { Input } from "@/components/input/input";
|
|
11
|
+
import { useRouter } from "@/i18n/navigation";
|
|
12
|
+
import { authClient, type AuthError, isGoogleEnabled } from "@/lib/auth-client";
|
|
13
|
+
import { AuthDivider } from "./auth-divider";
|
|
14
|
+
import { GoogleAuthButton } from "./google-auth-button";
|
|
15
|
+
import { authFallbackMessage } from "./lib/auth-error";
|
|
16
|
+
import { createSignUpSchema, type SignUpProps } from "./lib/validator";
|
|
17
|
+
|
|
18
|
+
export function SignUpForm() {
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const t = useTranslations("Auth.SignUp");
|
|
21
|
+
const tErrors = useTranslations("Auth.Errors");
|
|
22
|
+
const tAll = useTranslations();
|
|
23
|
+
const schema = useMemo(() => createSignUpSchema(tAll), [tAll]);
|
|
24
|
+
const [googleError, setGoogleError] = useState<AuthError | null>(null);
|
|
25
|
+
|
|
26
|
+
const { formState, handleSubmit, register, setValue } = useForm<SignUpProps>({
|
|
27
|
+
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
|
|
28
|
+
resolver: zodResolver(schema),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const { error, isPending, mutate } = useMutation<void, AuthError, SignUpProps>({
|
|
32
|
+
mutationFn: async ({ email, name, password }) => {
|
|
33
|
+
const { error } = await authClient.signUp.email({ email, name, password });
|
|
34
|
+
if (error) throw error;
|
|
35
|
+
},
|
|
36
|
+
onError: () => {
|
|
37
|
+
setValue("password", "");
|
|
38
|
+
setValue("confirmPassword", "");
|
|
39
|
+
},
|
|
40
|
+
onSuccess: () => {
|
|
41
|
+
router.push("/verify");
|
|
42
|
+
router.refresh();
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const errorMessage = error
|
|
47
|
+
? error.code === "USER_ALREADY_EXISTS" || error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"
|
|
48
|
+
? tErrors("emailAlreadyExists")
|
|
49
|
+
: authFallbackMessage(error, tErrors)
|
|
50
|
+
: googleError
|
|
51
|
+
? authFallbackMessage(googleError, tErrors)
|
|
52
|
+
: null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="w-full">
|
|
56
|
+
{errorMessage ? <Alert message={errorMessage} variant="error" /> : null}
|
|
57
|
+
<form
|
|
58
|
+
className="space-y-3.5"
|
|
59
|
+
data-testid="sign-up-form"
|
|
60
|
+
noValidate
|
|
61
|
+
onSubmit={handleSubmit((data) => mutate(data))}
|
|
62
|
+
>
|
|
63
|
+
<Input
|
|
64
|
+
autoComplete="name"
|
|
65
|
+
disabled={isPending}
|
|
66
|
+
error={formState.errors.name?.message}
|
|
67
|
+
label={t("nameLabel")}
|
|
68
|
+
{...register("name")}
|
|
69
|
+
/>
|
|
70
|
+
<Input
|
|
71
|
+
autoComplete="email"
|
|
72
|
+
disabled={isPending}
|
|
73
|
+
error={formState.errors.email?.message}
|
|
74
|
+
label={t("emailLabel")}
|
|
75
|
+
placeholder={t("emailPlaceholder")}
|
|
76
|
+
type="email"
|
|
77
|
+
{...register("email")}
|
|
78
|
+
/>
|
|
79
|
+
<Input
|
|
80
|
+
autoComplete="new-password"
|
|
81
|
+
disabled={isPending}
|
|
82
|
+
error={formState.errors.password?.message}
|
|
83
|
+
label={t("passwordLabel")}
|
|
84
|
+
type="password"
|
|
85
|
+
{...register("password")}
|
|
86
|
+
/>
|
|
87
|
+
<Input
|
|
88
|
+
autoComplete="new-password"
|
|
89
|
+
disabled={isPending}
|
|
90
|
+
error={formState.errors.confirmPassword?.message}
|
|
91
|
+
label={t("confirmPasswordLabel")}
|
|
92
|
+
type="password"
|
|
93
|
+
{...register("confirmPassword")}
|
|
94
|
+
/>
|
|
95
|
+
<Button className="w-full" isLoading={isPending} type="submit">
|
|
96
|
+
{t("submitButton")}
|
|
97
|
+
</Button>
|
|
98
|
+
</form>
|
|
99
|
+
{isGoogleEnabled ? (
|
|
100
|
+
<>
|
|
101
|
+
<AuthDivider />
|
|
102
|
+
<GoogleAuthButton disabled={isPending} onError={setGoogleError} />
|
|
103
|
+
</>
|
|
104
|
+
) : null}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ThemeProvider } from "next-themes";
|
|
5
|
+
import type { PropsWithChildren } from "react";
|
|
6
|
+
import { getQueryClient } from "@/lib/query-client";
|
|
7
|
+
|
|
8
|
+
export function Provider({ children }: Readonly<PropsWithChildren>) {
|
|
9
|
+
const queryClient = getQueryClient();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ThemeProvider attribute="class" disableTransitionOnChange>
|
|
13
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
14
|
+
</ThemeProvider>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getTranslations } from "next-intl/server";
|
|
2
|
+
import { buttonVariants } from "@/components/button/button-variants"; // @if auth
|
|
3
|
+
import { siteConfig } from "@/config/site";
|
|
4
|
+
import { SignOutButton } from "@/features/auth/sign-out-button"; // @if auth
|
|
5
|
+
import { Link } from "@/i18n/navigation";
|
|
6
|
+
import { getSession } from "@/lib/auth"; // @if auth
|
|
7
|
+
import { ThemeToggle } from "./theme-toggle";
|
|
8
|
+
|
|
9
|
+
/** The header bar without session-dependent content, also used as the Suspense fallback. */
|
|
10
|
+
export function HeaderFrame({ children }: Readonly<{ children?: React.ReactNode }>) {
|
|
11
|
+
return (
|
|
12
|
+
<header className="bg-background/90 fixed inset-x-0 top-0 z-20 h-16 border-b backdrop-blur-md">
|
|
13
|
+
<div className="mx-auto flex h-full max-w-5xl items-center justify-between px-4">
|
|
14
|
+
{children}
|
|
15
|
+
</div>
|
|
16
|
+
</header>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function SiteHeader() {
|
|
21
|
+
const t = await getTranslations("Header");
|
|
22
|
+
const session = await getSession(); // @if auth
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<HeaderFrame>
|
|
26
|
+
<Link className="text-base font-semibold tracking-tight" href="/">
|
|
27
|
+
{siteConfig.name}
|
|
28
|
+
</Link>
|
|
29
|
+
<nav aria-label={t("navigation")} className="flex items-center gap-2">
|
|
30
|
+
<ThemeToggle />
|
|
31
|
+
{/* @if auth */}
|
|
32
|
+
{session ? (
|
|
33
|
+
<>
|
|
34
|
+
<Link className={buttonVariants({ size: "sm", variant: "ghost" })} href="/dashboard">
|
|
35
|
+
{t("dashboard")}
|
|
36
|
+
</Link>
|
|
37
|
+
<SignOutButton />
|
|
38
|
+
</>
|
|
39
|
+
) : (
|
|
40
|
+
<Link className={buttonVariants({ size: "sm", variant: "outline" })} href="/sign-in">
|
|
41
|
+
{t("signIn")}
|
|
42
|
+
</Link>
|
|
43
|
+
)}
|
|
44
|
+
{/* @endif */}
|
|
45
|
+
</nav>
|
|
46
|
+
</HeaderFrame>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Moon, Sun } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useTheme } from "next-themes";
|
|
6
|
+
import { Button } from "@/components/button/button";
|
|
7
|
+
|
|
8
|
+
/** Flips between light and dark; icons swap via the `dark` class, so no hydration mismatch. */
|
|
9
|
+
export function ThemeToggle() {
|
|
10
|
+
const { resolvedTheme, setTheme } = useTheme();
|
|
11
|
+
const t = useTranslations("Header");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Button
|
|
15
|
+
aria-label={t("toggleTheme")}
|
|
16
|
+
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
|
17
|
+
size="icon"
|
|
18
|
+
variant="ghost"
|
|
19
|
+
>
|
|
20
|
+
<Sun aria-hidden className="size-4.5 dark:hidden" />
|
|
21
|
+
<Moon aria-hidden className="hidden size-4.5 dark:block" />
|
|
22
|
+
</Button>
|
|
23
|
+
);
|
|
24
|
+
}
|