mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Refactor authentication and account management with modular UI components (#242)
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -16,11 +16,11 @@
|
||||
/build
|
||||
|
||||
# project-specific build artifacts
|
||||
/src/Website/build/
|
||||
/src/Website/storybook-static/
|
||||
/src/Website/.react-router/
|
||||
/src/Website/playwright-report/
|
||||
/src/Website/test-results/
|
||||
/web/frontend/build/
|
||||
/web/frontend/storybook-static/
|
||||
/web/frontend/.react-router/
|
||||
/web/frontend/playwright-report/
|
||||
/web/frontend/test-results/
|
||||
/test-results/
|
||||
|
||||
# misc
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# Testing
|
||||
|
||||
This document describes the testing strategy and how to run tests for The
|
||||
@@ -125,6 +126,25 @@ npm run test:storybook:playwright
|
||||
- The command will start or reuse the Storybook server defined in
|
||||
`playwright.storybook.config.ts`
|
||||
|
||||
### Running Storybook tests with a visible browser
|
||||
|
||||
Both of the above run headless by default. To watch the browser while
|
||||
debugging a failure:
|
||||
|
||||
```bash
|
||||
# Playwright Storybook suite
|
||||
npx playwright test -c playwright.storybook.config.ts --headed
|
||||
# or the interactive step-through runner
|
||||
npx playwright test -c playwright.storybook.config.ts --ui
|
||||
|
||||
# Vitest/story play-function suite
|
||||
npx vitest run --project storybook --browser.headless=false
|
||||
# or Vitest's own UI (drop `run` for watch mode)
|
||||
npx vitest --project storybook --browser.headless=false --ui
|
||||
```
|
||||
|
||||
The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
||||
|
||||
## Test coverage
|
||||
|
||||
### Current coverage
|
||||
|
||||
3
web/frontend/.gitignore
vendored
Normal file
3
web/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.features-gen/
|
||||
.react-router
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"tabWidth": 3,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router";
|
||||
|
||||
declare module "react-router" {
|
||||
interface Future {
|
||||
v8_middleware: false
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router"
|
||||
|
||||
declare module "react-router" {
|
||||
interface Register {
|
||||
pages: Pages
|
||||
routeFiles: RouteFiles
|
||||
routeModules: RouteModules
|
||||
}
|
||||
}
|
||||
|
||||
type Pages = {
|
||||
"/": {
|
||||
params: {};
|
||||
};
|
||||
"/theme": {
|
||||
params: {};
|
||||
};
|
||||
"/login": {
|
||||
params: {};
|
||||
};
|
||||
"/register": {
|
||||
params: {};
|
||||
};
|
||||
"/logout": {
|
||||
params: {};
|
||||
};
|
||||
"/dashboard": {
|
||||
params: {};
|
||||
};
|
||||
"/confirm": {
|
||||
params: {};
|
||||
};
|
||||
"/beers": {
|
||||
params: {};
|
||||
};
|
||||
"/breweries": {
|
||||
params: {};
|
||||
};
|
||||
"/beer-styles": {
|
||||
params: {};
|
||||
};
|
||||
};
|
||||
|
||||
type RouteFiles = {
|
||||
"root.tsx": {
|
||||
id: "root";
|
||||
page: "/" | "/theme" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles";
|
||||
};
|
||||
"routes/home.tsx": {
|
||||
id: "routes/home";
|
||||
page: "/";
|
||||
};
|
||||
"routes/theme.tsx": {
|
||||
id: "routes/theme";
|
||||
page: "/theme";
|
||||
};
|
||||
"routes/login.tsx": {
|
||||
id: "routes/login";
|
||||
page: "/login";
|
||||
};
|
||||
"routes/register.tsx": {
|
||||
id: "routes/register";
|
||||
page: "/register";
|
||||
};
|
||||
"routes/logout.tsx": {
|
||||
id: "routes/logout";
|
||||
page: "/logout";
|
||||
};
|
||||
"routes/dashboard.tsx": {
|
||||
id: "routes/dashboard";
|
||||
page: "/dashboard";
|
||||
};
|
||||
"routes/confirm.tsx": {
|
||||
id: "routes/confirm";
|
||||
page: "/confirm";
|
||||
};
|
||||
"routes/beers.tsx": {
|
||||
id: "routes/beers";
|
||||
page: "/beers";
|
||||
};
|
||||
"routes/breweries.tsx": {
|
||||
id: "routes/breweries";
|
||||
page: "/breweries";
|
||||
};
|
||||
"routes/beer-styles.tsx": {
|
||||
id: "routes/beer-styles";
|
||||
page: "/beer-styles";
|
||||
};
|
||||
};
|
||||
|
||||
type RouteModules = {
|
||||
"root": typeof import("./app/root.tsx");
|
||||
"routes/home": typeof import("./app/routes/home.tsx");
|
||||
"routes/theme": typeof import("./app/routes/theme.tsx");
|
||||
"routes/login": typeof import("./app/routes/login.tsx");
|
||||
"routes/register": typeof import("./app/routes/register.tsx");
|
||||
"routes/logout": typeof import("./app/routes/logout.tsx");
|
||||
"routes/dashboard": typeof import("./app/routes/dashboard.tsx");
|
||||
"routes/confirm": typeof import("./app/routes/confirm.tsx");
|
||||
"routes/beers": typeof import("./app/routes/beers.tsx");
|
||||
"routes/breweries": typeof import("./app/routes/breweries.tsx");
|
||||
"routes/beer-styles": typeof import("./app/routes/beer-styles.tsx");
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
declare module "virtual:react-router/server-build" {
|
||||
import { ServerBuild } from "react-router";
|
||||
export const assets: ServerBuild["assets"];
|
||||
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
|
||||
export const basename: ServerBuild["basename"];
|
||||
export const entry: ServerBuild["entry"];
|
||||
export const future: ServerBuild["future"];
|
||||
export const isSpaMode: ServerBuild["isSpaMode"];
|
||||
export const prerender: ServerBuild["prerender"];
|
||||
export const publicPath: ServerBuild["publicPath"];
|
||||
export const routeDiscovery: ServerBuild["routeDiscovery"];
|
||||
export const routes: ServerBuild["routes"];
|
||||
export const ssr: ServerBuild["ssr"];
|
||||
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
|
||||
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../root.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "root.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../root.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beer-styles.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beer-styles.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beer-styles";
|
||||
module: typeof import("../beer-styles.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beers.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beers.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beers";
|
||||
module: typeof import("../beers.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../breweries.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/breweries.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/breweries";
|
||||
module: typeof import("../breweries.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../confirm.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/confirm.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/confirm";
|
||||
module: typeof import("../confirm.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../dashboard.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/dashboard.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/dashboard";
|
||||
module: typeof import("../dashboard.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../home.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/home.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/home";
|
||||
module: typeof import("../home.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../login.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/login.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/login";
|
||||
module: typeof import("../login.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../logout.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/logout.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/logout";
|
||||
module: typeof import("../logout.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../register.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/register.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/register";
|
||||
module: typeof import("../register.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../theme.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/theme.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/theme";
|
||||
module: typeof import("../theme.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -5,6 +5,11 @@ const config: StorybookConfig = {
|
||||
'../stories/Configure.mdx',
|
||||
'../stories/SubmitButton.stories.tsx',
|
||||
'../stories/FormField.stories.tsx',
|
||||
'../stories/SectionCard.stories.tsx',
|
||||
'../stories/UsernameSection.stories.tsx',
|
||||
'../stories/PasswordSection.stories.tsx',
|
||||
'../stories/LoginForm.stories.tsx',
|
||||
'../stories/RegisterForm.stories.tsx',
|
||||
'../stories/Navbar.stories.tsx',
|
||||
'../stories/Toast.stories.tsx',
|
||||
'../stories/Themes.stories.tsx',
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Preview } from '@storybook/react-vite';
|
||||
import { createElement } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import '../app/app.css';
|
||||
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
} from '../app/features/theme/themes';
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
@@ -28,18 +32,23 @@ const preview: Preview = {
|
||||
? context.globals.theme
|
||||
: defaultThemeName;
|
||||
|
||||
return createElement(
|
||||
MemoryRouter,
|
||||
undefined,
|
||||
createElement(
|
||||
const content = createElement(
|
||||
'div',
|
||||
{
|
||||
'data-theme': theme,
|
||||
className: 'bg-base-200 p-6 text-base-content',
|
||||
},
|
||||
createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
|
||||
),
|
||||
);
|
||||
|
||||
// Stories driving `useSubmit`/`useNavigation` bring their own data
|
||||
// router (createMemoryRouter + RouterProvider), which can't nest
|
||||
// inside another <Router>.
|
||||
if (context.parameters.usesDataRouter) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return createElement(MemoryRouter, undefined, content);
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
} from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface NavbarProps {
|
||||
auth: {
|
||||
username: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function Navbar({ auth }: NavbarProps) {
|
||||
const navLinks = [
|
||||
{ to: '/theme', label: 'Theme' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="navbar-center hidden lg:flex gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="btn btn-ghost btn-sm">
|
||||
{auth.username}
|
||||
</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="btn btn-ghost btn-sm justify-start"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Description, Field, Label } from '@headlessui/react';
|
||||
|
||||
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
hintClassName?: string;
|
||||
};
|
||||
|
||||
export default function FormField({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
hintClassName,
|
||||
...inputProps
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean;
|
||||
idleText: string;
|
||||
submittingText: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubmitButton({
|
||||
isSubmitting,
|
||||
idleText,
|
||||
submittingText,
|
||||
className,
|
||||
}: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||
</>
|
||||
) : (
|
||||
idleText
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
export default function ToastProvider() {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-success)',
|
||||
secondary: 'var(--color-success-content)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-error)',
|
||||
secondary: 'var(--color-error-content)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
web/frontend/app/components/ui/forms/FormField.tsx
Normal file
42
web/frontend/app/components/ui/forms/FormField.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Description, Field, Label } from '@headlessui/react';
|
||||
|
||||
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
hintClassName?: string;
|
||||
};
|
||||
|
||||
const FormField = ({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
hintClassName,
|
||||
...inputProps
|
||||
}: FormFieldProps) => {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormField;
|
||||
28
web/frontend/app/components/ui/forms/SubmitButton.tsx
Normal file
28
web/frontend/app/components/ui/forms/SubmitButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean;
|
||||
idleText: string;
|
||||
submittingText: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isSubmitting, idleText, submittingText, className }: SubmitButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||
</>
|
||||
) : (
|
||||
idleText
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitButton;
|
||||
44
web/frontend/app/components/ui/navbar/NavLinks.tsx
Normal file
44
web/frontend/app/components/ui/navbar/NavLinks.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/theme', label: 'Theme' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||
];
|
||||
|
||||
interface NavLinksProps {
|
||||
variant: 'desktop' | 'mobile';
|
||||
showRegister: boolean;
|
||||
}
|
||||
|
||||
const NavLinks = ({ variant, showRegister }: NavLinksProps) => {
|
||||
if (variant === 'desktop') {
|
||||
return (
|
||||
<div className="navbar-center hidden lg:flex gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm justify-start">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{showRegister && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavLinks;
|
||||
93
web/frontend/app/components/ui/navbar/Navbar.tsx
Normal file
93
web/frontend/app/components/ui/navbar/Navbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
import NavLinks from './NavLinks';
|
||||
import UserMenu from './UserMenu';
|
||||
|
||||
interface NavbarProps {
|
||||
auth: {
|
||||
username: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const Navbar = ({ auth }: NavbarProps) => (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<NavLinks variant="desktop" showRegister={!auth} />
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link
|
||||
to="/register"
|
||||
className="btn btn-ghost btn-sm hidden sm:inline-flex"
|
||||
>
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<UserMenu username={auth.username} />
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<NavLinks variant="mobile" showRegister={!auth} />
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
|
||||
export default Navbar;
|
||||
30
web/frontend/app/components/ui/navbar/UserMenu.tsx
Normal file
30
web/frontend/app/components/ui/navbar/UserMenu.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface UserMenuProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
const UserMenu = ({ username }: UserMenuProps) => (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
export default UserMenu;
|
||||
28
web/frontend/app/components/ui/toast/ToastProvider.tsx
Normal file
28
web/frontend/app/components/ui/toast/ToastProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
const ToastProvider = () => {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className:
|
||||
'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-success)',
|
||||
secondary: 'var(--color-success-content)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-error)',
|
||||
secondary: 'var(--color-error-content)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastProvider;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const AuthFlowDemoCard = () => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">Auth Flow Demo</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This demo showcases the following authentication features:
|
||||
</p>
|
||||
<ul className="list">
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
||||
user details
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||
header
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Account Management</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
||||
<code className="kbd kbd-sm">/email</code>,{' '}
|
||||
<code className="kbd kbd-sm">/password</code>,{' '}
|
||||
<code className="kbd kbd-sm">/profile</code>, and DELETE{' '}
|
||||
<code className="kbd kbd-sm">/account</code> — see{' '}
|
||||
<Link to="/account" className="link">
|
||||
Account Settings
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuthFlowDemoCard;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react';
|
||||
import { Trash, WarningTriangle } from 'iconoir-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
interface DeleteAccountSectionProps {
|
||||
userAccountId: string;
|
||||
result: ActionResult | undefined;
|
||||
}
|
||||
|
||||
const DeleteAccountSection = ({ userAccountId, result }: DeleteAccountSectionProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'delete';
|
||||
|
||||
useSectionError(result, 'delete');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card bg-error/5 border border-error/30 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-error mt-1">
|
||||
<Trash className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">Delete Account</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Permanently delete your account. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-outline btn-sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
className="relative z-50"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel className="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-3">
|
||||
<WarningTriangle className="size-10 text-error" aria-hidden="true" />
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
Delete your account?
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This action is permanent and cannot be reversed. Account ID{' '}
|
||||
<span className="font-mono text-xs">{userAccountId}</span> and all
|
||||
associated data will be removed.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 w-full pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-sm w-full"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => submit({ intent: 'delete' }, { method: 'post' })}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" />{' '}
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Yes, delete my account'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm w-full"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAccountSection;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mail } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateEmailSchema, type UpdateEmailSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const EmailSection = ({ email, open, onToggle, result }: SectionProps & { email: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'email';
|
||||
|
||||
const emailForm = useForm<UpdateEmailSchema>({
|
||||
resolver: zodResolver(updateEmailSchema),
|
||||
defaultValues: { newEmail: email },
|
||||
});
|
||||
|
||||
useSectionError(result, 'email');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<Mail className="size-5" aria-hidden="true" />}
|
||||
title="Email Address"
|
||||
description="Change your email. You'll need to re-confirm the new address."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={emailForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'email' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="newEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="New Email"
|
||||
error={emailForm.formState.errors.newEmail?.message}
|
||||
{...emailForm.register('newEmail')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Email"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSection;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Lock } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updatePasswordSchema, type UpdatePasswordSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const PasswordSection = ({ open, onToggle, result }: SectionProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'password';
|
||||
|
||||
const passwordForm = useForm<UpdatePasswordSchema>({
|
||||
resolver: zodResolver(updatePasswordSchema),
|
||||
});
|
||||
|
||||
useSectionError(result, 'password');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<Lock className="size-5" aria-hidden="true" />}
|
||||
title="Password"
|
||||
description="Change the password used to sign in."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={passwordForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'password' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Current Password"
|
||||
error={passwordForm.formState.errors.currentPassword?.message}
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
<FormField
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="New Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={passwordForm.formState.errors.newPassword?.message}
|
||||
{...passwordForm.register('newPassword')}
|
||||
/>
|
||||
<FormField
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm New Password"
|
||||
error={passwordForm.formState.errors.confirmNewPassword?.message}
|
||||
{...passwordForm.register('confirmNewPassword')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Change Password"
|
||||
submittingText="Changing..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSection;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateProfileSchema, type UpdateProfileSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const ProfileSection = ({
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth,
|
||||
open,
|
||||
onToggle,
|
||||
result,
|
||||
}: SectionProps & { firstName: string; lastName: string; dateOfBirth: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'profile';
|
||||
|
||||
const profileForm = useForm<UpdateProfileSchema>({
|
||||
resolver: zodResolver(updateProfileSchema),
|
||||
defaultValues: { firstName, lastName, dateOfBirth },
|
||||
});
|
||||
|
||||
useSectionError(result, 'profile');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<User className="size-5" aria-hidden="true" />}
|
||||
title="Profile"
|
||||
description="Update your name and date of birth."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={profileForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'profile' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={profileForm.formState.errors.firstName?.message}
|
||||
{...profileForm.register('firstName')}
|
||||
/>
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={profileForm.formState.errors.lastName?.message}
|
||||
{...profileForm.register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
error={profileForm.formState.errors.dateOfBirth?.message}
|
||||
{...profileForm.register('dateOfBirth')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Profile"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSection;
|
||||
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal file
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface SectionCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SectionCard = ({ icon, title, description, open, onToggle, children }: SectionCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex w-full items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-base-content/60 mt-1">{icon}</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">{title}</h2>
|
||||
<p className="text-sm text-base-content/70">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary"
|
||||
checked={open}
|
||||
onChange={onToggle}
|
||||
aria-label={`Toggle ${title.toLowerCase()} form`}
|
||||
/>
|
||||
</div>
|
||||
{open && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SectionCard;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface SessionInfoCardProps {
|
||||
username: string;
|
||||
userAccountId: string;
|
||||
}
|
||||
|
||||
const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
||||
<p className="text-base-content/70">
|
||||
You are successfully authenticated. This is a protected page that requires a valid
|
||||
session.
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">Username</div>
|
||||
<div className="stat-value text-lg font-mono">{username}</div>
|
||||
</div>
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">User ID</div>
|
||||
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-actions mt-4">
|
||||
<Link to="/account" className="btn btn-outline btn-sm">
|
||||
Account Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SessionInfoCard;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateUsernameSchema, type UpdateUsernameSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const UsernameSection = ({
|
||||
username,
|
||||
open,
|
||||
onToggle,
|
||||
result,
|
||||
}: SectionProps & { username: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'username';
|
||||
|
||||
const usernameForm = useForm<UpdateUsernameSchema>({
|
||||
resolver: zodResolver(updateUsernameSchema),
|
||||
defaultValues: { newUsername: username },
|
||||
});
|
||||
|
||||
useSectionError(result, 'username');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<User className="size-5" aria-hidden="true" />}
|
||||
title="Username"
|
||||
description="Change the username you sign in with."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={usernameForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'username' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="newUsername"
|
||||
type="text"
|
||||
placeholder={username}
|
||||
label="New Username"
|
||||
error={usernameForm.formState.errors.newUsername?.message}
|
||||
{...usernameForm.register('newUsername')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Username"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameSection;
|
||||
10
web/frontend/app/features/account/hooks/useSectionError.ts
Normal file
10
web/frontend/app/features/account/hooks/useSectionError.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { showErrorToast } from '../../../components/ui/toast/toast';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
export const useSectionError = (result: ActionResult | undefined, intent: ActionResult['intent']) => {
|
||||
useEffect(() => {
|
||||
if (!result || result.intent !== intent) return;
|
||||
showErrorToast(result.error);
|
||||
}, [result, intent]);
|
||||
};
|
||||
269
web/frontend/app/features/account/routes/account.tsx
Normal file
269
web/frontend/app/features/account/routes/account.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Settings } from 'iconoir-react';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import { data, Link, redirect, useSearchParams } from 'react-router';
|
||||
import { showSuccessToast } from '../../../components/ui/toast/toast';
|
||||
import {
|
||||
commitSession,
|
||||
deleteAccount,
|
||||
destroySession,
|
||||
getSession,
|
||||
getUserAccount,
|
||||
requireAuth,
|
||||
updateEmail,
|
||||
updatePassword,
|
||||
updateProfile,
|
||||
updateUsername,
|
||||
} from '../../auth/auth.server';
|
||||
import DeleteAccountSection from '../components/DeleteAccountSection';
|
||||
import EmailSection from '../components/EmailSection';
|
||||
import PasswordSection from '../components/PasswordSection';
|
||||
import ProfileSection from '../components/ProfileSection';
|
||||
import UsernameSection from '../components/UsernameSection';
|
||||
import {
|
||||
updateEmailSchema,
|
||||
updatePasswordSchema,
|
||||
updateProfileSchema,
|
||||
updateUsernameSchema,
|
||||
} from '../schemas';
|
||||
import { initialSectionState, sectionReducer } from '../state';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
import type { Route } from './+types/account';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => [{ title: 'Account Settings | The Biergarten App' }];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
|
||||
try {
|
||||
const account = await getUserAccount(auth.userAccountId);
|
||||
details = {
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
email: account.email,
|
||||
// Trim the backend's ISO datetime down to the yyyy-MM-dd a date input expects.
|
||||
dateOfBirth: account.dateOfBirth.slice(0, 10),
|
||||
};
|
||||
} catch {
|
||||
// Fall through with blank fields; the update forms still work standalone.
|
||||
}
|
||||
|
||||
return { username: auth.username, userAccountId: auth.userAccountId, ...details };
|
||||
};
|
||||
|
||||
const redirectWithToast = (message: string, headers?: HeadersInit) =>
|
||||
redirect(`/account?toast=${encodeURIComponent(message)}`, headers ? { headers } : undefined);
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get('intent');
|
||||
|
||||
switch (intent) {
|
||||
case 'delete': {
|
||||
try {
|
||||
await deleteAccount(auth.accessToken);
|
||||
} catch (err) {
|
||||
return {
|
||||
intent: 'delete' as const,
|
||||
error: err instanceof Error ? err.message : 'Failed to delete account.',
|
||||
};
|
||||
}
|
||||
const session = await getSession(request);
|
||||
return redirect('/', { headers: { 'Set-Cookie': await destroySession(session) } });
|
||||
}
|
||||
|
||||
case 'username': {
|
||||
const result = updateUsernameSchema.safeParse({
|
||||
newUsername: formData.get('newUsername'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
const payload = await updateUsername(auth.accessToken, result.data.newUsername);
|
||||
const session = await getSession(request);
|
||||
session.set('username', payload.username);
|
||||
return redirectWithToast('Username updated successfully.', {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update username.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'email': {
|
||||
const result = updateEmailSchema.safeParse({ newEmail: formData.get('newEmail') });
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
const payload = await updateEmail(auth.accessToken, result.data.newEmail);
|
||||
return redirectWithToast(
|
||||
payload.emailConfirmed
|
||||
? 'Email updated successfully.'
|
||||
: 'Email updated. Please re-confirm your new address.',
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update email.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'profile': {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
await updateProfile(
|
||||
auth.accessToken,
|
||||
result.data.firstName,
|
||||
result.data.lastName,
|
||||
result.data.dateOfBirth,
|
||||
);
|
||||
return redirectWithToast('Profile updated successfully.');
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update profile.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'password': {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmNewPassword: formData.get('confirmNewPassword'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
await updatePassword(
|
||||
auth.accessToken,
|
||||
result.data.currentPassword,
|
||||
result.data.newPassword,
|
||||
);
|
||||
return redirectWithToast('Password changed successfully.');
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update password.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw data('Unknown request.', { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
const { username, userAccountId, firstName, lastName, email, dateOfBirth } = loaderData;
|
||||
const result = actionData as ActionResult | undefined;
|
||||
|
||||
const [sections, dispatch] = useReducer(sectionReducer, initialSectionState);
|
||||
// Bumped once per successful update so the section subtree below remounts
|
||||
// with fresh `key`s, picking up the new loader values (and, for
|
||||
// PasswordSection, clearing fields that have no persisted value to key
|
||||
// off) without needing an effect to imperatively reset each form.
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const toast = searchParams.get('toast');
|
||||
|
||||
// Collapsing the sections and bumping resetKey are state adjustments in
|
||||
// response to a changed prop (the `toast` search param), so they happen
|
||||
// directly during render - React's documented alternative to an effect
|
||||
// for this case - guarded by `handledToast` so it only runs once per
|
||||
// redirect. Showing the toast and clearing the param are genuine side
|
||||
// effects on an external system (react-hot-toast, the URL) and stay in
|
||||
// the effect below.
|
||||
const [handledToast, setHandledToast] = useState<string | null>(null);
|
||||
if (toast && toast !== handledToast) {
|
||||
setHandledToast(toast);
|
||||
dispatch({ type: 'CLOSE_ALL' });
|
||||
setResetKey((key) => key + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
showSuccessToast(toast);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('toast');
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [toast, setSearchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="size-7" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Account Settings</h1>
|
||||
<p className="text-base-content/70">
|
||||
Manage the account details for{' '}
|
||||
<span className="font-mono">{username}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsernameSection
|
||||
key={`username-${resetKey}`}
|
||||
username={username}
|
||||
open={sections.usernameOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_USERNAME_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<EmailSection
|
||||
key={`email-${resetKey}`}
|
||||
email={email}
|
||||
open={sections.emailOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_EMAIL_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<ProfileSection
|
||||
key={`profile-${resetKey}`}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
dateOfBirth={dateOfBirth}
|
||||
open={sections.profileOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_PROFILE_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<PasswordSection
|
||||
key={`password-${resetKey}`}
|
||||
open={sections.passwordOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_PASSWORD_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<DeleteAccountSection userAccountId={userAccountId} result={result} />
|
||||
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="link link-hover text-sm text-base-content/60 inline-block"
|
||||
>
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountPage;
|
||||
31
web/frontend/app/features/account/routes/dashboard.tsx
Normal file
31
web/frontend/app/features/account/routes/dashboard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { requireAuth } from '../../auth/auth.server';
|
||||
import AuthFlowDemoCard from '../components/AuthFlowDemoCard';
|
||||
import SessionInfoCard from '../components/SessionInfoCard';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Dashboard | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
return {
|
||||
username: auth.username,
|
||||
userAccountId: auth.userAccountId,
|
||||
};
|
||||
};
|
||||
|
||||
const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { username, userAccountId } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
||||
<AuthFlowDemoCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
48
web/frontend/app/features/account/schemas.ts
Normal file
48
web/frontend/app/features/account/schemas.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const updateUsernameSchema = z.object({
|
||||
newUsername: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(64, 'Username must be at most 64 characters')
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Only letters, numbers, dots, underscores, and hyphens'),
|
||||
});
|
||||
|
||||
export type UpdateUsernameSchema = z.infer<typeof updateUsernameSchema>;
|
||||
|
||||
export const updateEmailSchema = z.object({
|
||||
newEmail: z.string().email('Invalid email address').max(128, 'Email is too long'),
|
||||
});
|
||||
|
||||
export type UpdateEmailSchema = z.infer<typeof updateEmailSchema>;
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
firstName: z.string().min(1, 'First name is required').max(128, 'First name is too long'),
|
||||
lastName: z.string().min(1, 'Last name is required').max(128, 'Last name is too long'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
});
|
||||
|
||||
export type UpdateProfileSchema = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
export const updatePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number')
|
||||
.regex(/[^a-zA-Z0-9]/, 'Password must contain a special character'),
|
||||
confirmNewPassword: z.string().min(1, 'Please confirm your new password'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmNewPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmNewPassword'],
|
||||
})
|
||||
.refine((data) => data.newPassword !== data.currentPassword, {
|
||||
message: 'New password must be different from the current password',
|
||||
path: ['newPassword'],
|
||||
});
|
||||
|
||||
export type UpdatePasswordSchema = z.infer<typeof updatePasswordSchema>;
|
||||
25
web/frontend/app/features/account/state.ts
Normal file
25
web/frontend/app/features/account/state.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { SectionAction, SectionState } from './types';
|
||||
|
||||
export const initialSectionState: SectionState = {
|
||||
usernameOpen: false,
|
||||
emailOpen: false,
|
||||
profileOpen: false,
|
||||
passwordOpen: false,
|
||||
};
|
||||
|
||||
export const sectionReducer = (state: SectionState, action: SectionAction): SectionState => {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_USERNAME_VISIBILITY':
|
||||
return { ...initialSectionState, usernameOpen: !state.usernameOpen };
|
||||
case 'TOGGLE_EMAIL_VISIBILITY':
|
||||
return { ...initialSectionState, emailOpen: !state.emailOpen };
|
||||
case 'TOGGLE_PROFILE_VISIBILITY':
|
||||
return { ...initialSectionState, profileOpen: !state.profileOpen };
|
||||
case 'TOGGLE_PASSWORD_VISIBILITY':
|
||||
return { ...initialSectionState, passwordOpen: !state.passwordOpen };
|
||||
case 'CLOSE_ALL':
|
||||
return initialSectionState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
31
web/frontend/app/features/account/types.ts
Normal file
31
web/frontend/app/features/account/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Success never reaches the component as action data - every intent redirects
|
||||
// back to this route (with a `?toast=` message) instead of returning a
|
||||
// payload, so the only shape an action can hand back here is an error.
|
||||
export interface ActionResult {
|
||||
intent: 'username' | 'email' | 'profile' | 'password' | 'delete';
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SectionState {
|
||||
usernameOpen: boolean;
|
||||
emailOpen: boolean;
|
||||
profileOpen: boolean;
|
||||
passwordOpen: boolean;
|
||||
}
|
||||
|
||||
export type ActionType =
|
||||
| 'TOGGLE_USERNAME_VISIBILITY'
|
||||
| 'TOGGLE_EMAIL_VISIBILITY'
|
||||
| 'TOGGLE_PROFILE_VISIBILITY'
|
||||
| 'TOGGLE_PASSWORD_VISIBILITY'
|
||||
| 'CLOSE_ALL';
|
||||
|
||||
export interface SectionAction {
|
||||
type: ActionType;
|
||||
}
|
||||
|
||||
export interface SectionProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
result: ActionResult | undefined;
|
||||
}
|
||||
267
web/frontend/app/features/auth/auth.server.ts
Normal file
267
web/frontend/app/features/auth/auth.server.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { createCookieSessionStorage, redirect } from 'react-router';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
refreshToken: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface RegistrationPayload extends LoginPayload {
|
||||
confirmationEmailSent: boolean;
|
||||
}
|
||||
|
||||
interface UpdateUsernamePayload {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface UpdateEmailPayload {
|
||||
userAccountId: string;
|
||||
email: string;
|
||||
emailConfirmed: boolean;
|
||||
}
|
||||
|
||||
interface UpdateProfilePayload {
|
||||
userAccountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
}
|
||||
|
||||
interface UpdatePasswordPayload {
|
||||
userAccountId: string;
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
export interface UserAccountDetails {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dateOfBirth: string;
|
||||
}
|
||||
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
});
|
||||
|
||||
export const getSession = async (request: Request) => {
|
||||
return sessionStorage.getSession(request.headers.get('Cookie'));
|
||||
};
|
||||
|
||||
export const commitSession = async (session: Awaited<ReturnType<typeof getSession>>) => {
|
||||
return sessionStorage.commitSession(session);
|
||||
};
|
||||
|
||||
export const destroySession = async (session: Awaited<ReturnType<typeof getSession>>) => {
|
||||
return sessionStorage.destroySession(session);
|
||||
};
|
||||
|
||||
export const requireAuth = async (request: Request): Promise<AuthTokens> => {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
const refreshToken = session.get('refreshToken');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
};
|
||||
|
||||
export const getOptionalAuth = async (request: Request): Promise<AuthTokens | null> => {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
|
||||
if (!accessToken) return null;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: session.get('refreshToken'),
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
};
|
||||
|
||||
export const login = async (username: string, password: string) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Login failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const register = async (body: {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dateOfBirth: string;
|
||||
password: string;
|
||||
}) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Registration failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<RegistrationPayload> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const refreshTokens = async (refreshToken: string) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Refresh-Token': refreshToken },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const getUserAccount = async (userAccountId: string): Promise<UserAccountDetails> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Failed to load account details (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const confirmEmail = async (token: string, accessToken: string) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Confirmation failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
const authorizedRequest = async <T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
init: { method: string; body?: unknown },
|
||||
): Promise<T> => {
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: init.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: init.body ? JSON.stringify(init.body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<T> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const updateUsername = async (accessToken: string, newUsername: string) => {
|
||||
return authorizedRequest<UpdateUsernamePayload>('/api/auth/username', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { newUsername },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateEmail = async (accessToken: string, newEmail: string) => {
|
||||
return authorizedRequest<UpdateEmailPayload>('/api/auth/email', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { newEmail },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateProfile = async (
|
||||
accessToken: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
dateOfBirth: string,
|
||||
) => {
|
||||
return authorizedRequest<UpdateProfilePayload>('/api/auth/profile', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { firstName, lastName, dateOfBirth },
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePassword = async (
|
||||
accessToken: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
) => {
|
||||
return authorizedRequest<UpdatePasswordPayload>('/api/auth/password', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { currentPassword, newPassword },
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAccount = async (accessToken: string) => {
|
||||
await authorizedRequest<null>('/api/auth/account', accessToken, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const createAuthSession = async (payload: LoginPayload, redirectTo: string) => {
|
||||
const session = await sessionStorage.getSession();
|
||||
session.set('accessToken', payload.accessToken);
|
||||
session.set('refreshToken', payload.refreshToken);
|
||||
session.set('userAccountId', payload.userAccountId);
|
||||
session.set('username', payload.username);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: { 'Set-Cookie': await commitSession(session) },
|
||||
});
|
||||
};
|
||||
25
web/frontend/app/features/auth/components/ConfirmFailure.tsx
Normal file
25
web/frontend/app/features/auth/components/ConfirmFailure.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface ConfirmFailureProps {
|
||||
error: string;
|
||||
}
|
||||
|
||||
const ConfirmFailure = ({ error }: ConfirmFailureProps) => (
|
||||
<>
|
||||
<div className="text-error text-6xl">✕</div>
|
||||
<h1 className="card-title text-2xl">Confirmation Failed</h1>
|
||||
<div role="alert" className="alert alert-error alert-soft w-full">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<p className="text-base-content/70 text-sm">
|
||||
The confirmation link may have expired (valid for 30 minutes) or already been used.
|
||||
</p>
|
||||
<div className="card-actions w-full pt-2 flex-col gap-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ConfirmFailure;
|
||||
26
web/frontend/app/features/auth/components/ConfirmSuccess.tsx
Normal file
26
web/frontend/app/features/auth/components/ConfirmSuccess.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface ConfirmSuccessProps {
|
||||
confirmedDate: string;
|
||||
}
|
||||
|
||||
const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
|
||||
<>
|
||||
<div className="text-success text-6xl">✓</div>
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||
Confirmed at
|
||||
</span>
|
||||
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="card-actions w-full pt-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ConfirmSuccess;
|
||||
18
web/frontend/app/features/auth/components/LoginCallout.tsx
Normal file
18
web/frontend/app/features/auth/components/LoginCallout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const LoginCallout = () => (
|
||||
<>
|
||||
<div className="divider text-xs">Already have an account?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default LoginCallout;
|
||||
44
web/frontend/app/features/auth/components/LoginForm.tsx
Normal file
44
web/frontend/app/features/auth/components/LoginForm.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import type { LoginSchema } from '../schemas';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||
formState: FormState<LoginSchema>;
|
||||
register: UseFormRegister<{ username: string; password: string }>;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const LoginForm = (props: LoginFormProps) => (
|
||||
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
error={props.formState.errors.username?.message}
|
||||
{...props.register('username')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
error={props.formState.errors.password?.message}
|
||||
{...props.register('password')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={props.submitting}
|
||||
idleText="Sign In"
|
||||
submittingText="Signing in..."
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
export default LoginForm;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HomeSimpleDoor, UserPlus } from 'iconoir-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const RegisterCallout = () => (
|
||||
<>
|
||||
<div className="divider text-xs">New here?</div>
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||
<UserPlus className="size-4" aria-hidden="true" />
|
||||
Create an account
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||
>
|
||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default RegisterCallout;
|
||||
97
web/frontend/app/features/auth/components/RegisterForm.tsx
Normal file
97
web/frontend/app/features/auth/components/RegisterForm.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import type { RegisterSchema } from '../schemas';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||
formState: FormState<RegisterSchema>;
|
||||
register: UseFormRegister<RegisterSchema>;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const RegisterForm = (props: RegisterFormProps) => (
|
||||
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
hint="3-64 characters, alphanumeric and . _ -"
|
||||
error={props.formState.errors.username?.message}
|
||||
{...props.register('username')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={props.formState.errors.firstName?.message}
|
||||
{...props.register('firstName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={props.formState.errors.lastName?.message}
|
||||
{...props.register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="Email"
|
||||
error={props.formState.errors.email?.message}
|
||||
{...props.register('email')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
hint="Must be 19 years or older"
|
||||
error={props.formState.errors.dateOfBirth?.message}
|
||||
{...props.register('dateOfBirth')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={props.formState.errors.password?.message}
|
||||
{...props.register('password')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm Password"
|
||||
error={props.formState.errors.confirmPassword?.message}
|
||||
{...props.register('confirmPassword')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={props.submitting}
|
||||
idleText="Create Account"
|
||||
submittingText="Creating account..."
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
export default RegisterForm;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { showErrorToast } from '../../../components/ui/toast/toast';
|
||||
|
||||
export const useActionErrorToast = (error: string | undefined | null) => {
|
||||
useEffect(() => {
|
||||
if (error) showErrorToast(error);
|
||||
}, [error]);
|
||||
};
|
||||
49
web/frontend/app/features/auth/routes/confirm.tsx
Normal file
49
web/frontend/app/features/auth/routes/confirm.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { confirmEmail, requireAuth } from '../auth.server';
|
||||
import ConfirmFailure from '../components/ConfirmFailure';
|
||||
import ConfirmSuccess from '../components/ConfirmSuccess';
|
||||
import type { Route } from './+types/confirm';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Confirm Email | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return { success: false as const, error: 'Missing confirmation token.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await confirmEmail(token, auth.accessToken);
|
||||
return {
|
||||
success: true as const,
|
||||
confirmedDate: payload.confirmedDate,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: err instanceof Error ? err.message : 'Confirmation failed.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Confirm = ({ loaderData }: Route.ComponentProps) => {
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-4">
|
||||
{loaderData.success ? (
|
||||
<ConfirmSuccess confirmedDate={loaderData.confirmedDate} />
|
||||
) : (
|
||||
<ConfirmFailure error={loaderData.error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Confirm;
|
||||
88
web/frontend/app/features/auth/routes/login.tsx
Normal file
88
web/frontend/app/features/auth/routes/login.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { LogIn } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import { createAuthSession, getOptionalAuth, login } from '../auth.server';
|
||||
import LoginForm from '../components/LoginForm';
|
||||
import RegisterCallout from '../components/RegisterCallout';
|
||||
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
||||
import { loginSchema, type LoginSchema } from '../schemas';
|
||||
import type { Route } from './+types/login';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Login | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
};
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const result = loginSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await login(result.data.username, result.data.password);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Login failed.' };
|
||||
}
|
||||
};
|
||||
|
||||
const Login = ({ actionData }: Route.ComponentProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<LoginSchema>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useActionErrorToast(actionData?.error);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center gap-2">
|
||||
<LogIn className="size-7" aria-hidden="true" />
|
||||
Login
|
||||
</h1>
|
||||
<p className="text-base-content/70">Sign in to your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoginForm
|
||||
onSubmit={onSubmit}
|
||||
formState={formState}
|
||||
register={register}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<RegisterCallout />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
10
web/frontend/app/features/auth/routes/logout.tsx
Normal file
10
web/frontend/app/features/auth/routes/logout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { destroySession, getSession } from '../auth.server';
|
||||
import type { Route } from './+types/logout';
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const session = await getSession(request);
|
||||
return redirect('/', {
|
||||
headers: { 'Set-Cookie': await destroySession(session) },
|
||||
});
|
||||
};
|
||||
106
web/frontend/app/features/auth/routes/register.tsx
Normal file
106
web/frontend/app/features/auth/routes/register.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import { createAuthSession, getOptionalAuth, register } from '../auth.server';
|
||||
import LoginCallout from '../components/LoginCallout';
|
||||
import RegisterForm from '../components/RegisterForm';
|
||||
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
||||
import { registerSchema, type RegisterSchema } from '../schemas';
|
||||
import type { Route } from './+types/register';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Register | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
};
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const result = registerSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
email: formData.get('email'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
password: formData.get('password'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors = result.error.flatten().fieldErrors as Record<
|
||||
keyof RegisterSchema,
|
||||
string[] | undefined
|
||||
>;
|
||||
return { error: null, fieldErrors };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
username: result.data.username,
|
||||
firstName: result.data.firstName,
|
||||
lastName: result.data.lastName,
|
||||
email: result.data.email,
|
||||
dateOfBirth: result.data.dateOfBirth,
|
||||
password: result.data.password,
|
||||
};
|
||||
const payload = await register(body);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Registration failed.',
|
||||
fieldErrors: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Register = ({ actionData }: Route.ComponentProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register: field,
|
||||
handleSubmit,
|
||||
formState,
|
||||
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useActionErrorToast(actionData?.error);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center">Register</h1>
|
||||
<p className="text-base-content/70">Create your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RegisterForm
|
||||
onSubmit={onSubmit}
|
||||
formState={formState}
|
||||
register={field}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<LoginCallout />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
33
web/frontend/app/features/auth/schemas.ts
Normal file
33
web/frontend/app/features/auth/schemas.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must be at most 20 characters'),
|
||||
firstName: z.string().min(1, 'First name is required'),
|
||||
lastName: z.string().min(1, 'Last name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||
55
web/frontend/app/features/breweries/breweries.server.ts
Normal file
55
web/frontend/app/features/breweries/breweries.server.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface BreweryLocation {
|
||||
breweryPostLocationId: string;
|
||||
breweryPostId: string;
|
||||
cityId: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string | null;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface Brewery {
|
||||
breweryPostId: string;
|
||||
postedById: string;
|
||||
breweryName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
location: BreweryLocation | null;
|
||||
}
|
||||
|
||||
export const getBreweries = async (limit?: number, offset?: number): Promise<Brewery[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== undefined) params.set('limit', String(limit));
|
||||
if (offset !== undefined) params.set('offset', String(offset));
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load breweries (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery[]> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load brewery (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router';
|
||||
import type { Brewery } from '../breweries.server';
|
||||
|
||||
interface BreweryCardProps {
|
||||
brewery: Brewery;
|
||||
}
|
||||
|
||||
const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
||||
<Link
|
||||
to={`/breweries/${brewery.breweryPostId}`}
|
||||
className="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{brewery.breweryName}</h2>
|
||||
<p className="text-base-content/70 line-clamp-3">{brewery.description}</p>
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/50 mt-2">
|
||||
{brewery.location.addressLine1}
|
||||
{brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default BreweryCard;
|
||||
39
web/frontend/app/features/breweries/routes/breweries.tsx
Normal file
39
web/frontend/app/features/breweries/routes/breweries.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import BreweryCard from '../components/BreweryCard';
|
||||
import { getBreweries } from '../breweries.server';
|
||||
import type { Route } from './+types/breweries';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Breweries | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
const breweries = await getBreweries();
|
||||
return { breweries };
|
||||
};
|
||||
|
||||
const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { breweries } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
||||
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
|
||||
|
||||
{breweries.length === 0 ? (
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>No breweries have been posted yet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{breweries.map((brewery) => (
|
||||
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breweries;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { data, Link } from 'react-router';
|
||||
import { getBreweryById } from '../breweries.server';
|
||||
import type { Route } from './+types/brewery-detail';
|
||||
|
||||
export const loader = async ({ params }: Route.LoaderArgs) => {
|
||||
const brewery = await getBreweryById(params.id);
|
||||
if (!brewery) {
|
||||
throw data('Brewery not found.', { status: 404 });
|
||||
}
|
||||
|
||||
return { brewery };
|
||||
};
|
||||
|
||||
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { brewery } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4 max-w-2xl">
|
||||
<Link
|
||||
to="/breweries"
|
||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||
>
|
||||
← Back to Breweries
|
||||
</Link>
|
||||
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h1 className="card-title text-3xl">{brewery.breweryName}</h1>
|
||||
<p className="text-base-content/70 whitespace-pre-line">
|
||||
{brewery.description}
|
||||
</p>
|
||||
|
||||
{brewery.location && (
|
||||
<div className="bg-base-200 rounded-box p-4 mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
|
||||
Location
|
||||
</p>
|
||||
<p>{brewery.location.addressLine1}</p>
|
||||
{brewery.location.addressLine2 && (
|
||||
<p>{brewery.location.addressLine2}</p>
|
||||
)}
|
||||
<p>{brewery.location.postalCode}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-base-content/40 mt-4">
|
||||
Posted {new Date(brewery.createdAt).toLocaleDateString()}
|
||||
{brewery.updatedAt &&
|
||||
` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryDetail;
|
||||
18
web/frontend/app/features/catalog/routes/beer-styles.tsx
Normal file
18
web/frontend/app/features/catalog/routes/beer-styles.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Route } from './+types/beer-styles';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Beer Styles | The Biergarten App' }];
|
||||
};
|
||||
|
||||
const BeerStyles = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerStyles;
|
||||
18
web/frontend/app/features/catalog/routes/beers.tsx
Normal file
18
web/frontend/app/features/catalog/routes/beers.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Route } from './+types/beers';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Beers | The Biergarten App' }];
|
||||
};
|
||||
|
||||
const Beers = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Beers;
|
||||
56
web/frontend/app/features/home/routes/home.tsx
Normal file
56
web/frontend/app/features/home/routes/home.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getOptionalAuth } from '../../auth/auth.server';
|
||||
import type { Route } from './+types/home';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => [
|
||||
{ title: 'The Biergarten App' },
|
||||
{ name: 'description', content: 'Welcome to The Biergarten App' },
|
||||
];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { username: auth?.username ?? null };
|
||||
};
|
||||
|
||||
const Home = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { username } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="hero-content text-center">
|
||||
<div className="max-w-md space-y-6">
|
||||
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
||||
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
||||
|
||||
{username ? (
|
||||
<>
|
||||
<p className="text-base-content/80">
|
||||
Welcome back,{' '}
|
||||
<span className="font-semibold text-primary">{username}</span>!
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/dashboard" className="btn btn-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link to="/logout" className="btn btn-ghost">
|
||||
Logout
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/login" className="btn btn-primary">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-outline">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
87
web/frontend/app/features/theme/components/ThemeSwitcher.tsx
Normal file
87
web/frontend/app/features/theme/components/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
type ThemeName,
|
||||
themeStorageKey,
|
||||
} from '../themes';
|
||||
|
||||
const applyTheme = (theme: ThemeName) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(themeStorageKey, theme);
|
||||
};
|
||||
|
||||
const ThemeSwitcher = () => {
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultThemeName;
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem(themeStorageKey);
|
||||
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
||||
});
|
||||
|
||||
const activeTheme =
|
||||
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
|
||||
<p className="text-base-content/70">
|
||||
Four themes, four moods — from the sun-bleached clarity of a Weizen
|
||||
afternoon to the deep berry dark of a Cassis barrel. Every theme shares the
|
||||
same semantic token structure so components stay consistent while the
|
||||
atmosphere shifts completely.
|
||||
</p>
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>
|
||||
Active theme: <strong>{activeTheme.label}</strong> — {activeTheme.vibe}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||
|
||||
<div
|
||||
className="join join-vertical sm:join-horizontal"
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitcher;
|
||||
99
web/frontend/app/features/theme/routes/theme.tsx
Normal file
99
web/frontend/app/features/theme/routes/theme.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||
import type { Route } from './+types/theme';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [
|
||||
{ title: 'Theme | The Biergarten App' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Theme guide and switcher for The Biergarten App',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const ThemePage = () => {
|
||||
return (
|
||||
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<ThemeSwitcher />
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Brand colors</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-primary p-3 text-primary-content">
|
||||
Primary
|
||||
</div>
|
||||
<div className="rounded-box bg-secondary p-3 text-secondary-content">
|
||||
Secondary
|
||||
</div>
|
||||
<div className="rounded-box bg-accent p-3 text-accent-content">
|
||||
Accent
|
||||
</div>
|
||||
<div className="rounded-box bg-neutral p-3 text-neutral-content">
|
||||
Neutral
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Status colors</h3>
|
||||
<div className="space-y-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-info p-3 text-info-content">
|
||||
Info
|
||||
</div>
|
||||
<div className="rounded-box bg-success p-3 text-success-content">
|
||||
Success
|
||||
</div>
|
||||
<div className="rounded-box bg-warning p-3 text-warning-content">
|
||||
Warning
|
||||
</div>
|
||||
<div className="rounded-box bg-error p-3 text-error-content">
|
||||
Error
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Core style outline</h3>
|
||||
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
|
||||
<li>Warm serif headings paired with clear sans-serif body text</li>
|
||||
<li>Rounded, tactile surfaces with subtle depth and grain</li>
|
||||
<li>
|
||||
Semantic token usage to keep contrast consistent in both themes
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Component preview</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button className="btn btn-primary">Primary action</button>
|
||||
<button className="btn btn-secondary">Secondary action</button>
|
||||
<button className="btn btn-accent">Accent action</button>
|
||||
<button className="btn btn-ghost">Ghost action</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div role="alert" className="alert alert-success alert-soft">
|
||||
<span>Theme tokens are applied consistently.</span>
|
||||
</div>
|
||||
<div role="alert" className="alert alert-warning alert-soft">
|
||||
<span>Use semantic colors over hard-coded color values.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemePage;
|
||||
38
web/frontend/app/features/theme/themes.ts
Normal file
38
web/frontend/app/features/theme/themes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type ThemeName =
|
||||
'biergarten-lager' | 'biergarten-stout' | 'biergarten-cassis' | 'biergarten-weizen';
|
||||
|
||||
export interface ThemeOption {
|
||||
value: ThemeName;
|
||||
label: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
||||
export const themeStorageKey = 'biergarten-theme';
|
||||
|
||||
export const biergartenThemes: ThemeOption[] = [
|
||||
{
|
||||
value: 'biergarten-lager',
|
||||
label: 'Biergarten Lager',
|
||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-stout',
|
||||
label: 'Biergarten Stout',
|
||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-cassis',
|
||||
label: 'Biergarten Cassis',
|
||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-weizen',
|
||||
label: 'Biergarten Weizen',
|
||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||
},
|
||||
];
|
||||
|
||||
export const isBiergartenTheme = (value: string | null | undefined): value is ThemeName => {
|
||||
return biergartenThemes.some((theme) => theme.value === value);
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
import { createCookieSessionStorage, redirect } from 'react-router';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
refreshToken: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface RegistrationPayload extends LoginPayload {
|
||||
confirmationEmailSent: boolean;
|
||||
}
|
||||
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
});
|
||||
|
||||
export async function getSession(request: Request) {
|
||||
return sessionStorage.getSession(request.headers.get('Cookie'));
|
||||
}
|
||||
|
||||
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
return sessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
return sessionStorage.destroySession(session);
|
||||
}
|
||||
|
||||
export async function requireAuth(request: Request): Promise<AuthTokens> {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
const refreshToken = session.get('refreshToken');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOptionalAuth(request: Request): Promise<AuthTokens | null> {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
|
||||
if (!accessToken) return null;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: session.get('refreshToken'),
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Login failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function register(body: {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dateOfBirth: string;
|
||||
password: string;
|
||||
}) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Registration failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<RegistrationPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function refreshTokens(refreshToken: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function confirmEmail(token: string, accessToken: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Confirmation failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function createAuthSession(payload: LoginPayload, redirectTo: string) {
|
||||
const session = await sessionStorage.getSession();
|
||||
session.set('accessToken', payload.accessToken);
|
||||
session.set('refreshToken', payload.refreshToken);
|
||||
session.set('userAccountId', payload.userAccountId);
|
||||
session.set('username', payload.username);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: { 'Set-Cookie': await commitSession(session) },
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface BreweryLocation {
|
||||
breweryPostLocationId: string;
|
||||
breweryPostId: string;
|
||||
cityId: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string | null;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface Brewery {
|
||||
breweryPostId: string;
|
||||
postedById: string;
|
||||
breweryName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
location: BreweryLocation | null;
|
||||
}
|
||||
|
||||
export async function getBreweries(limit?: number, offset?: number): Promise<Brewery[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== undefined) params.set('limit', String(limit));
|
||||
if (offset !== undefined) params.set('offset', String(offset));
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load breweries (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery[]> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function getBreweryById(id: string): Promise<Brewery | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load brewery (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must be at most 20 characters'),
|
||||
firstName: z.string().min(1, 'First name is required'),
|
||||
lastName: z.string().min(1, 'Last name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||
@@ -1,41 +0,0 @@
|
||||
export type ThemeName =
|
||||
| 'biergarten-lager'
|
||||
| 'biergarten-stout'
|
||||
| 'biergarten-cassis'
|
||||
| 'biergarten-weizen';
|
||||
|
||||
export interface ThemeOption {
|
||||
value: ThemeName;
|
||||
label: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
||||
export const themeStorageKey = 'biergarten-theme';
|
||||
|
||||
export const biergartenThemes: ThemeOption[] = [
|
||||
{
|
||||
value: 'biergarten-lager',
|
||||
label: 'Biergarten Lager',
|
||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-stout',
|
||||
label: 'Biergarten Stout',
|
||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-cassis',
|
||||
label: 'Biergarten Cassis',
|
||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-weizen',
|
||||
label: 'Biergarten Weizen',
|
||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||
},
|
||||
];
|
||||
|
||||
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
|
||||
return biergartenThemes.some((theme) => theme.value === value);
|
||||
}
|
||||
@@ -9,9 +9,19 @@ import {
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import './app.css';
|
||||
import Navbar from './components/Navbar';
|
||||
import ToastProvider from './components/toast/ToastProvider';
|
||||
import { getOptionalAuth } from './lib/auth.server';
|
||||
import Navbar from './components/ui/navbar/Navbar';
|
||||
import ToastProvider from './components/ui/toast/ToastProvider';
|
||||
import { getOptionalAuth } from './features/auth/auth.server';
|
||||
import { themeStorageKey } from './features/theme/themes';
|
||||
|
||||
const themeInitScript = `
|
||||
(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem(${JSON.stringify(themeStorageKey)});
|
||||
if (theme) document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
@@ -31,7 +41,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
return { auth };
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -39,6 +49,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
@@ -47,9 +58,9 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function App({ loaderData }: Route.ComponentProps) {
|
||||
const App = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { auth } = loaderData;
|
||||
return (
|
||||
<>
|
||||
@@ -58,9 +69,11 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
export default App;
|
||||
|
||||
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
|
||||
let message = 'Oops!';
|
||||
let details = 'An unexpected error occurred.';
|
||||
let stack: string | undefined;
|
||||
@@ -87,4 +100,4 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { type RouteConfig, index, route } from '@react-router/dev/routes';
|
||||
|
||||
export default [
|
||||
index('routes/home.tsx'),
|
||||
route('theme', 'routes/theme.tsx'),
|
||||
route('login', 'routes/login.tsx'),
|
||||
route('register', 'routes/register.tsx'),
|
||||
route('logout', 'routes/logout.tsx'),
|
||||
route('dashboard', 'routes/dashboard.tsx'),
|
||||
route('confirm', 'routes/confirm.tsx'),
|
||||
route('beers', 'routes/beers.tsx'),
|
||||
route('breweries', 'routes/breweries.tsx'),
|
||||
route('beer-styles', 'routes/beer-styles.tsx'),
|
||||
index('features/home/routes/home.tsx'),
|
||||
route('theme', 'features/theme/routes/theme.tsx'),
|
||||
route('login', 'features/auth/routes/login.tsx'),
|
||||
route('register', 'features/auth/routes/register.tsx'),
|
||||
route('logout', 'features/auth/routes/logout.tsx'),
|
||||
route('dashboard', 'features/account/routes/dashboard.tsx'),
|
||||
route('account', 'features/account/routes/account.tsx'),
|
||||
route('confirm', 'features/auth/routes/confirm.tsx'),
|
||||
route('beers', 'features/catalog/routes/beers.tsx'),
|
||||
route('breweries', 'features/breweries/routes/breweries.tsx'),
|
||||
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
||||
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Route } from './+types/beer-styles';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Beer Styles | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export default function BeerStyles() {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Route } from './+types/beers';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Beers | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export default function Beers() {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getBreweries } from '../lib/breweries.server';
|
||||
import type { Route } from './+types/breweries';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Breweries | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const breweries = await getBreweries();
|
||||
return { breweries };
|
||||
}
|
||||
|
||||
export default function Breweries({ loaderData }: Route.ComponentProps) {
|
||||
const { breweries } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
||||
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
|
||||
|
||||
{breweries.length === 0 ? (
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>No breweries have been posted yet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{breweries.map((brewery) => (
|
||||
<Link
|
||||
key={brewery.breweryPostId}
|
||||
to={`/breweries/${brewery.breweryPostId}`}
|
||||
className="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{brewery.breweryName}</h2>
|
||||
<p className="text-base-content/70 line-clamp-3">
|
||||
{brewery.description}
|
||||
</p>
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/50 mt-2">
|
||||
{brewery.location.addressLine1}
|
||||
{brewery.location.addressLine2
|
||||
? `, ${brewery.location.addressLine2}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Link, data } from 'react-router';
|
||||
import { getBreweryById } from '../lib/breweries.server';
|
||||
import type { Route } from './+types/brewery-detail';
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
return [{ title: `${data?.brewery.breweryName ?? 'Brewery'} | The Biergarten App` }];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const brewery = await getBreweryById(params.id);
|
||||
if (!brewery) {
|
||||
throw data('Brewery not found.', { status: 404 });
|
||||
}
|
||||
|
||||
return { brewery };
|
||||
}
|
||||
|
||||
export default function BreweryDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { brewery } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4 max-w-2xl">
|
||||
<Link
|
||||
to="/breweries"
|
||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||
>
|
||||
← Back to Breweries
|
||||
</Link>
|
||||
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h1 className="card-title text-3xl">{brewery.breweryName}</h1>
|
||||
<p className="text-base-content/70 whitespace-pre-line">{brewery.description}</p>
|
||||
|
||||
{brewery.location && (
|
||||
<div className="bg-base-200 rounded-box p-4 mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
|
||||
Location
|
||||
</p>
|
||||
<p>{brewery.location.addressLine1}</p>
|
||||
{brewery.location.addressLine2 && <p>{brewery.location.addressLine2}</p>}
|
||||
<p>{brewery.location.postalCode}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-base-content/40 mt-4">
|
||||
Posted {new Date(brewery.createdAt).toLocaleDateString()}
|
||||
{brewery.updatedAt &&
|
||||
` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { showErrorToast, showSuccessToast } from '../components/toast/toast';
|
||||
import { confirmEmail, requireAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/confirm';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Confirm Email | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await requireAuth(request);
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return { success: false as const, error: 'Missing confirmation token.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await confirmEmail(token, auth.accessToken);
|
||||
return {
|
||||
success: true as const,
|
||||
confirmedDate: payload.confirmedDate,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: err instanceof Error ? err.message : 'Confirmation failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Confirm({ loaderData }: Route.ComponentProps) {
|
||||
useEffect(() => {
|
||||
if (loaderData.success) {
|
||||
showSuccessToast('Email confirmed successfully.');
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorToast(loaderData.error);
|
||||
}, [loaderData]);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-4">
|
||||
{loaderData.success ? (
|
||||
<>
|
||||
<div className="text-success text-6xl">✓</div>
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<p className="text-base-content/70">
|
||||
Your email address has been successfully verified.
|
||||
</p>
|
||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||
Confirmed at
|
||||
</span>
|
||||
<p className="font-mono mt-1">
|
||||
{new Date(loaderData.confirmedDate).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card-actions w-full pt-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-error text-6xl">✕</div>
|
||||
<h1 className="card-title text-2xl">Confirmation Failed</h1>
|
||||
<div role="alert" className="alert alert-error alert-soft w-full">
|
||||
<span>{loaderData.error}</span>
|
||||
</div>
|
||||
<p className="text-base-content/70 text-sm">
|
||||
The confirmation link may have expired (valid for 30 minutes) or already
|
||||
been used.
|
||||
</p>
|
||||
<div className="card-actions w-full pt-2 flex-col gap-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { requireAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Dashboard | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await requireAuth(request);
|
||||
return {
|
||||
username: auth.username,
|
||||
userAccountId: auth.userAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard({ loaderData }: Route.ComponentProps) {
|
||||
const { username, userAccountId } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
||||
<p className="text-base-content/70">
|
||||
You are successfully authenticated. This is a protected page that requires a
|
||||
valid session.
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">Username</div>
|
||||
<div className="stat-value text-lg font-mono">{username}</div>
|
||||
</div>
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">User ID</div>
|
||||
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">Auth Flow Demo</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This demo showcases the following authentication features:
|
||||
</p>
|
||||
<ul className="list">
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with
|
||||
full user details
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with
|
||||
refresh token
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getOptionalAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/home';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: 'The Biergarten App' },
|
||||
{ name: 'description', content: 'Welcome to The Biergarten App' },
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { username: auth?.username ?? null };
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { username } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="hero-content text-center">
|
||||
<div className="max-w-md space-y-6">
|
||||
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
||||
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
||||
|
||||
{username ? (
|
||||
<>
|
||||
<p className="text-base-content/80">
|
||||
Welcome back, <span className="font-semibold text-primary">{username}</span>
|
||||
!
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/dashboard" className="btn btn-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link to="/logout" className="btn btn-ghost">
|
||||
Logout
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/login" className="btn btn-primary">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-outline">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { HomeSimpleDoor, LogIn, UserPlus } from 'iconoir-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../components/forms/FormField';
|
||||
import SubmitButton from '../components/forms/SubmitButton';
|
||||
import { showErrorToast } from '../components/toast/toast';
|
||||
import { createAuthSession, getOptionalAuth, login } from '../lib/auth.server';
|
||||
import { loginSchema, type LoginSchema } from '../lib/schemas';
|
||||
import type { Route } from './+types/login';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Login | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const result = loginSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await login(result.data.username, result.data.password);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Login failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login({ actionData }: Route.ComponentProps) {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
showErrorToast(actionData.error);
|
||||
}
|
||||
}, [actionData?.error]);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center gap-2">
|
||||
<LogIn className="size-7" aria-hidden="true" />
|
||||
Login
|
||||
</h1>
|
||||
<p className="text-base-content/70">Sign in to your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Sign In"
|
||||
submittingText="Signing in..."
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="divider text-xs">New here?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||
<UserPlus className="size-4" aria-hidden="true" />
|
||||
Create an account
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||
>
|
||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { destroySession, getSession } from '../lib/auth.server';
|
||||
import type { Route } from './+types/logout';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
return redirect('/', {
|
||||
headers: { 'Set-Cookie': await destroySession(session) },
|
||||
});
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../components/forms/FormField';
|
||||
import SubmitButton from '../components/forms/SubmitButton';
|
||||
import { showErrorToast } from '../components/toast/toast';
|
||||
import { createAuthSession, getOptionalAuth, register } from '../lib/auth.server';
|
||||
import { registerSchema, type RegisterSchema } from '../lib/schemas';
|
||||
import type { Route } from './+types/register';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Register | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const result = registerSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
email: formData.get('email'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
password: formData.get('password'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
return { error: null, fieldErrors };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
username: result.data.username,
|
||||
firstName: result.data.firstName,
|
||||
lastName: result.data.lastName,
|
||||
email: result.data.email,
|
||||
dateOfBirth: result.data.dateOfBirth,
|
||||
password: result.data.password,
|
||||
};
|
||||
const payload = await register(body);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Registration failed.',
|
||||
fieldErrors: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Register({ actionData }: Route.ComponentProps) {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register: field,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
showErrorToast(actionData.error);
|
||||
}
|
||||
}, [actionData?.error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center">Register</h1>
|
||||
<p className="text-base-content/70">Create your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
hint="3-64 characters, alphanumeric and . _ -"
|
||||
error={errors.username?.message}
|
||||
{...field('username')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={errors.firstName?.message}
|
||||
{...field('firstName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={errors.lastName?.message}
|
||||
{...field('lastName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="Email"
|
||||
error={errors.email?.message}
|
||||
{...field('email')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
hint="Must be 19 years or older"
|
||||
error={errors.dateOfBirth?.message}
|
||||
{...field('dateOfBirth')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={errors.password?.message}
|
||||
{...field('password')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...field('confirmPassword')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Create Account"
|
||||
submittingText="Creating account..."
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="divider text-xs">Already have an account?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
themeStorageKey,
|
||||
type ThemeName,
|
||||
} from '../lib/themes';
|
||||
import type { Route } from './+types/theme';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: 'Theme | The Biergarten App' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Theme guide and switcher for The Biergarten App',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeName) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(themeStorageKey, theme);
|
||||
}
|
||||
|
||||
export default function ThemePage() {
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultThemeName;
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem(themeStorageKey);
|
||||
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(selectedTheme);
|
||||
}, [selectedTheme]);
|
||||
|
||||
const activeTheme =
|
||||
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
|
||||
<p className="text-base-content/70">
|
||||
Four themes, four moods — from the sun-bleached clarity of a Weizen afternoon
|
||||
to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
|
||||
token structure so components stay consistent while the atmosphere shifts
|
||||
completely.
|
||||
</p>
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>
|
||||
Active theme: <strong>{activeTheme.label}</strong> — {activeTheme.vibe}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||
|
||||
<div
|
||||
className="join join-vertical sm:join-horizontal"
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Brand colors</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-primary p-3 text-primary-content">
|
||||
Primary
|
||||
</div>
|
||||
<div className="rounded-box bg-secondary p-3 text-secondary-content">
|
||||
Secondary
|
||||
</div>
|
||||
<div className="rounded-box bg-accent p-3 text-accent-content">Accent</div>
|
||||
<div className="rounded-box bg-neutral p-3 text-neutral-content">
|
||||
Neutral
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Status colors</h3>
|
||||
<div className="space-y-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-info p-3 text-info-content">Info</div>
|
||||
<div className="rounded-box bg-success p-3 text-success-content">
|
||||
Success
|
||||
</div>
|
||||
<div className="rounded-box bg-warning p-3 text-warning-content">
|
||||
Warning
|
||||
</div>
|
||||
<div className="rounded-box bg-error p-3 text-error-content">Error</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Core style outline</h3>
|
||||
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
|
||||
<li>Warm serif headings paired with clear sans-serif body text</li>
|
||||
<li>Rounded, tactile surfaces with subtle depth and grain</li>
|
||||
<li>Semantic token usage to keep contrast consistent in both themes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Component preview</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button className="btn btn-primary">Primary action</button>
|
||||
<button className="btn btn-secondary">Secondary action</button>
|
||||
<button className="btn btn-accent">Accent action</button>
|
||||
<button className="btn btn-ghost">Ghost action</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div role="alert" className="alert alert-success alert-soft">
|
||||
<span>Theme tokens are applied consistently.</span>
|
||||
</div>
|
||||
<div role="alert" className="alert alert-warning alert-soft">
|
||||
<span>Use semantic colors over hard-coded color values.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import storybook from 'eslint-plugin-storybook';
|
||||
|
||||
import js from '@eslint/js';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
@@ -33,6 +34,12 @@ export default tseslint.config(
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
react,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: '19.2',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
@@ -45,6 +52,14 @@ export default tseslint.config(
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'func-style': ['error', 'expression'],
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{
|
||||
namedComponents: 'arrow-function',
|
||||
unnamedComponents: 'arrow-function',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
|
||||
5985
web/frontend/package-lock.json
generated
5985
web/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,51 +18,56 @@
|
||||
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@react-router/dev": "^7.13.1",
|
||||
"@react-router/express": "^7.13.1",
|
||||
"@react-router/node": "^7.13.1",
|
||||
"iconoir-react": "^7.11.0",
|
||||
"isbot": "^5.1.36",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@react-router/dev": "^8.3.0",
|
||||
"@react-router/express": "^8.3.0",
|
||||
"@react-router/node": "^8.3.0",
|
||||
"iconoir-react": "^7.11.1",
|
||||
"isbot": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router": "^7.13.1",
|
||||
"zod": "^4.3.6"
|
||||
"react-router": "^8.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@storybook/addon-a11y": "^10.2.19",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@storybook/addon-a11y": "^10.5.10",
|
||||
"@storybook/addon-docs": "^10.2.19",
|
||||
"@storybook/addon-onboarding": "^10.2.19",
|
||||
"@storybook/addon-onboarding": "^10.5.10",
|
||||
"@storybook/addon-vitest": "^10.2.19",
|
||||
"@storybook/react-vite": "^10.2.19",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"daisyui": "^5.5.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-storybook": "^10.2.19",
|
||||
"globals": "^17.4.0",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "^10.2.19",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
"globals": "^17.11.0",
|
||||
"playwright": "^1.62.1",
|
||||
"postcss": "^8.5.26",
|
||||
"prettier": "^3.9.6",
|
||||
"storybook": "^10.5.10",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^7.0.8",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ const port = process.env.STORYBOOK_PORT ?? '6006';
|
||||
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/playwright',
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
@@ -17,4 +16,5 @@ export default defineConfig({
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: 'storybook', testDir: './tests/playwright' }],
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ This Storybook is scoped to real app UI only:
|
||||
|
||||
- `SubmitButton`
|
||||
- `FormField`
|
||||
- `SectionCard`
|
||||
- `Navbar`
|
||||
- `Themes` gallery
|
||||
|
||||
@@ -34,6 +35,13 @@ Run:
|
||||
- `npm run build-storybook -- --test`
|
||||
- `npm run test:storybook:playwright`
|
||||
|
||||
### Running with a visible browser
|
||||
|
||||
Both layers default to headless. To watch the browser while debugging:
|
||||
|
||||
- Playwright layer: `npx playwright test -c playwright.storybook.config.ts --headed`, or `--ui` for the interactive step-through runner.
|
||||
- Vitest/story `play` layer: `npx vitest run --project storybook --browser.headless=false` (drop `run` and add `--ui` for Vitest's own UI). The default is set in `vite.config.ts`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Add stories only for reusable app components.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import FormField from '../app/components/forms/FormField';
|
||||
import FormField from '../app/components/ui/forms/FormField';
|
||||
|
||||
const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`;
|
||||
|
||||
@@ -52,7 +52,10 @@ export const WithError: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute(
|
||||
'aria-invalid',
|
||||
'true',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
67
web/frontend/stories/LoginForm.stories.tsx
Normal file
67
web/frontend/stories/LoginForm.stories.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import LoginForm from '../app/features/auth/components/LoginForm';
|
||||
import { loginSchema, type LoginSchema } from '../app/features/auth/schemas';
|
||||
|
||||
const loginFormDescription = `Renders the real \`LoginForm\` component wired to the real \`loginSchema\` (zod) through the same react-hook-form setup used on the login page, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
|
||||
|
||||
const LoginFormHarness = () => {
|
||||
const [submitted, setSubmitted] = useState<LoginSchema | null>(null);
|
||||
const form = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
|
||||
<LoginForm
|
||||
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
|
||||
formState={form.formState}
|
||||
register={form.register}
|
||||
submitting={false}
|
||||
/>
|
||||
{submitted ? (
|
||||
<p data-testid="submit-result">Signed in as {submitted.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Forms/LoginForm',
|
||||
component: LoginFormHarness,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: loginFormDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof LoginFormHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Empty: Story = {};
|
||||
|
||||
export const RejectsEmptySubmission: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
|
||||
await expect(canvas.getByText(/username is required/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/password is required/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AcceptsValidLogin: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(canvas.getByLabelText(/username/i), 'hans');
|
||||
await userEvent.type(canvas.getByLabelText(/password/i), 'correct-horse-battery');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
|
||||
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('hans');
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import Navbar from '../app/components/Navbar';
|
||||
import Navbar from '../app/components/ui/navbar/Navbar';
|
||||
|
||||
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
|
||||
|
||||
|
||||
132
web/frontend/stories/PasswordSection.stories.tsx
Normal file
132
web/frontend/stories/PasswordSection.stories.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import PasswordSection from '../app/features/account/components/PasswordSection';
|
||||
import { updatePasswordSchema } from '../app/features/account/schemas';
|
||||
import type { ActionResult } from '../app/features/account/types';
|
||||
|
||||
const passwordSectionDescription = `Renders the real \`PasswordSection\` component (the account page's collapsible password-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updatePasswordSchema\` (zod) cross-field rules. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`;
|
||||
|
||||
const PasswordSectionRoute = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const result = useActionData() as ActionResult | undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<PasswordSection open={open} onToggle={() => setOpen((value) => !value)} result={result} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PasswordSectionHarness = () => {
|
||||
const [router] = useState(() =>
|
||||
createMemoryRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <PasswordSectionRoute />,
|
||||
action: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const parsed = updatePasswordSchema.safeParse({
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmNewPassword: formData.get('confirmNewPassword'),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
intent: 'password',
|
||||
error: parsed.error.issues[0].message,
|
||||
} satisfies ActionResult;
|
||||
}
|
||||
|
||||
return redirect('/success');
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/success',
|
||||
element: <p data-testid="redirect-success">Password changed successfully.</p>,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Account/PasswordSection',
|
||||
component: PasswordSectionHarness,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
usesDataRouter: true,
|
||||
docs: {
|
||||
description: {
|
||||
component: passwordSectionDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof PasswordSectionHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Open: Story = {};
|
||||
|
||||
const fillPasswordForm = async (
|
||||
canvas: ReturnType<typeof within>,
|
||||
values: { current: string; next: string; confirm: string },
|
||||
) => {
|
||||
await userEvent.type(canvas.getByLabelText(/current password/i), values.current);
|
||||
await userEvent.type(canvas.getByLabelText('New Password'), values.next);
|
||||
await userEvent.type(canvas.getByLabelText(/confirm new password/i), values.confirm);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /change password/i }));
|
||||
};
|
||||
|
||||
export const RejectsShortPassword: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillPasswordForm(canvas, {
|
||||
current: 'OldPassword1!',
|
||||
next: 'short',
|
||||
confirm: 'short',
|
||||
});
|
||||
await expect(canvas.getByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsMismatchedConfirmation: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillPasswordForm(canvas, {
|
||||
current: 'OldPassword1!',
|
||||
next: 'NewPassword2!',
|
||||
confirm: 'NewPassword3!',
|
||||
});
|
||||
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsReusingCurrentPassword: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillPasswordForm(canvas, {
|
||||
current: 'SamePassword1!',
|
||||
next: 'SamePassword1!',
|
||||
confirm: 'SamePassword1!',
|
||||
});
|
||||
await expect(canvas.getByText(/different from the current password/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AcceptsValidPasswordChange: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillPasswordForm(canvas, {
|
||||
current: 'OldPassword1!',
|
||||
next: 'NewPassword2!',
|
||||
confirm: 'NewPassword2!',
|
||||
});
|
||||
await expect(canvas.getByTestId('redirect-success')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
116
web/frontend/stories/RegisterForm.stories.tsx
Normal file
116
web/frontend/stories/RegisterForm.stories.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
||||
import RegisterForm from '../app/features/auth/components/RegisterForm';
|
||||
import { registerSchema, type RegisterSchema } from '../app/features/auth/schemas';
|
||||
|
||||
const registerFormDescription = `Renders the real \`RegisterForm\` component wired to the real \`registerSchema\` (zod) through the same react-hook-form setup used on the register page, including its cross-field rule that the confirmation must match the password, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
|
||||
|
||||
const RegisterFormHarness = () => {
|
||||
const [submitted, setSubmitted] = useState<RegisterSchema | null>(null);
|
||||
const form = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-lg space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
|
||||
<RegisterForm
|
||||
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
|
||||
formState={form.formState}
|
||||
register={form.register}
|
||||
submitting={false}
|
||||
/>
|
||||
{submitted ? <p data-testid="submit-result">Registered {submitted.username}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Forms/RegisterForm',
|
||||
component: RegisterFormHarness,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: registerFormDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RegisterFormHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Empty: Story = {};
|
||||
|
||||
const fillValidRegistration = async (canvas: ReturnType<typeof within>) => {
|
||||
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
||||
await userEvent.type(canvas.getByLabelText(/first name/i), 'Jane');
|
||||
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
|
||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
|
||||
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
|
||||
target: { value: '2000-01-15' },
|
||||
});
|
||||
await userEvent.type(canvas.getByLabelText('Password'), 'StrongPass1!');
|
||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!');
|
||||
};
|
||||
|
||||
export const RejectsEmptySubmission: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(
|
||||
canvas.getByText(/username must be at least 3 characters/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/first name is required/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsInvalidEmail: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await userEvent.clear(canvas.getByLabelText(/^email$/i));
|
||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByText(/invalid email address/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsWeakPassword: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await userEvent.clear(canvas.getByLabelText('Password'));
|
||||
await userEvent.type(canvas.getByLabelText('Password'), 'weakpassword');
|
||||
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
|
||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'weakpassword');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByText(/uppercase letter/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsMismatchedConfirmPassword: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
|
||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'Different1!');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
|
||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AcceptsValidRegistration: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await fillValidRegistration(canvas);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('jane_doe');
|
||||
},
|
||||
};
|
||||
75
web/frontend/stories/SectionCard.stories.tsx
Normal file
75
web/frontend/stories/SectionCard.stories.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Lock } from 'iconoir-react';
|
||||
import { useState } from 'react';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import SectionCard from '../app/features/account/components/SectionCard';
|
||||
|
||||
const sectionCardDescription = `Collapsible card used on the account settings page to group a labeled form behind a toggle. These stories cover the closed and open states, plus the toggle interaction, so you can review spacing, icon alignment, and disclosure behavior in isolation.`;
|
||||
|
||||
const SectionCardDemo = (props: React.ComponentProps<typeof SectionCard>) => {
|
||||
const [open, setOpen] = useState(props.open);
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<SectionCard {...props} open={open} onToggle={() => setOpen((value) => !value)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Account/SectionCard',
|
||||
component: SectionCard,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
icon: <Lock className="size-5" aria-hidden="true" />,
|
||||
title: 'Password',
|
||||
description: 'Change the password used to sign in.',
|
||||
open: false,
|
||||
onToggle: () => undefined,
|
||||
children: <p className="text-sm">Form fields go here.</p>,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: sectionCardDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => <SectionCardDemo {...args} />,
|
||||
} satisfies Meta<typeof SectionCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Closed: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('Password')).toBeInTheDocument();
|
||||
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('Form fields go here.')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ToggleInteraction: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = canvas.getByLabelText(/toggle password form/i);
|
||||
|
||||
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
await expect(canvas.getByText('Form fields go here.')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import SubmitButton from '../app/components/forms/SubmitButton';
|
||||
import SubmitButton from '../app/components/ui/forms/SubmitButton';
|
||||
|
||||
const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`;
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { biergartenThemes } from '../app/lib/themes';
|
||||
import { biergartenThemes } from '../app/features/theme/themes';
|
||||
|
||||
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
|
||||
|
||||
function ThemeSwatch({ label, className }: { label: string; className: string }) {
|
||||
const ThemeSwatch = ({ label, className }: { label: string; className: string }) => {
|
||||
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
|
||||
function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) {
|
||||
const CssVarSwatch = ({ label, bg, color }: { label: string; bg: string; color: string }) => {
|
||||
return (
|
||||
<div
|
||||
className="rounded-box p-3 text-sm font-medium"
|
||||
@@ -18,9 +18,9 @@ function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color:
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function TextTokenSample({
|
||||
const TextTokenSample = ({
|
||||
label,
|
||||
background,
|
||||
text,
|
||||
@@ -28,7 +28,7 @@ function TextTokenSample({
|
||||
label: string;
|
||||
background: string;
|
||||
text: string;
|
||||
}) {
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
@@ -39,9 +39,9 @@ function TextTokenSample({
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) {
|
||||
const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe: string }) => {
|
||||
return (
|
||||
<section
|
||||
data-theme={value}
|
||||
@@ -60,7 +60,10 @@ function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
|
||||
<ThemeSwatch label="Secondary" className="bg-secondary text-secondary-content" />
|
||||
<ThemeSwatch
|
||||
label="Secondary"
|
||||
className="bg-secondary text-secondary-content"
|
||||
/>
|
||||
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
|
||||
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
|
||||
</div>
|
||||
@@ -122,7 +125,7 @@ function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Themes/Biergarten Themes',
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
import ToastProvider from '../app/components/toast/ToastProvider';
|
||||
import ToastProvider from '../app/components/ui/toast/ToastProvider';
|
||||
import {
|
||||
dismissToasts,
|
||||
showErrorToast,
|
||||
showInfoToast,
|
||||
showSuccessToast,
|
||||
} from '../app/components/toast/toast';
|
||||
} from '../app/components/ui/toast/toast';
|
||||
|
||||
const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`;
|
||||
|
||||
function ToastDemo() {
|
||||
const ToastDemo = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToastProvider />
|
||||
@@ -47,7 +47,7 @@ function ToastDemo() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Feedback/Toast',
|
||||
|
||||
107
web/frontend/stories/UsernameSection.stories.tsx
Normal file
107
web/frontend/stories/UsernameSection.stories.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import UsernameSection from '../app/features/account/components/UsernameSection';
|
||||
import { updateUsernameSchema } from '../app/features/account/schemas';
|
||||
import type { ActionResult } from '../app/features/account/types';
|
||||
|
||||
const usernameSectionDescription = `Renders the real \`UsernameSection\` component (the account page's collapsible username-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updateUsernameSchema\` (zod) validation. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`;
|
||||
|
||||
const UsernameSectionRoute = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const result = useActionData() as ActionResult | undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<UsernameSection
|
||||
username="jane_doe"
|
||||
open={open}
|
||||
onToggle={() => setOpen((value) => !value)}
|
||||
result={result}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsernameSectionHarness = () => {
|
||||
const [router] = useState(() =>
|
||||
createMemoryRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <UsernameSectionRoute />,
|
||||
action: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const parsed = updateUsernameSchema.safeParse({
|
||||
newUsername: formData.get('newUsername'),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
intent: 'username',
|
||||
error: parsed.error.issues[0].message,
|
||||
} satisfies ActionResult;
|
||||
}
|
||||
|
||||
return redirect('/success');
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/success',
|
||||
element: <p data-testid="redirect-success">Username updated successfully.</p>,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Account/UsernameSection',
|
||||
component: UsernameSectionHarness,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
usesDataRouter: true,
|
||||
docs: {
|
||||
description: {
|
||||
component: usernameSectionDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof UsernameSectionHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Open: Story = {};
|
||||
|
||||
export const RejectsShortUsername: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.clear(canvas.getByLabelText(/new username/i));
|
||||
await userEvent.type(canvas.getByLabelText(/new username/i), 'ab');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
|
||||
await expect(canvas.getByText(/at least 3 characters/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RejectsInvalidCharacters: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.clear(canvas.getByLabelText(/new username/i));
|
||||
await userEvent.type(canvas.getByLabelText(/new username/i), 'bad user!');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
|
||||
await expect(canvas.getByText(/letters, numbers, dots, underscores/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AcceptsValidUsername: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.clear(canvas.getByLabelText(/new username/i));
|
||||
await userEvent.type(canvas.getByLabelText(/new username/i), 'valid.user-99');
|
||||
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
|
||||
await expect(canvas.getByTestId('redirect-success')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user