@atelic-action/ui 0.17.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +432 -45
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Note.tsx +9 -39
  104. package/src/artifact/Paragraph.tsx +65 -0
  105. package/src/artifact/Read.tsx +406 -0
  106. package/src/artifact/Writeup.tsx +7 -7
  107. package/src/artifact/index.ts +1 -0
  108. package/src/chrome/SiteShell.tsx +141 -0
  109. package/src/chrome/index.ts +2 -0
  110. package/src/components/Disclosure.tsx +80 -14
  111. package/src/components/GateLogin.tsx +1 -1
  112. package/src/components/Watermark.tsx +1 -1
  113. package/src/components/index.ts +6 -2
  114. package/src/gate/index.ts +1 -1
  115. package/src/gate/middleware.ts +17 -6
  116. package/src/gate/wall.ts +60 -2
  117. package/src/lib/basePathAudit.ts +96 -0
  118. package/src/lib/displayDate.ts +25 -0
  119. package/src/lib/honeypot.ts +11 -0
  120. package/src/lib/hours.ts +28 -0
  121. package/src/lib/sitemap.ts +34 -0
  122. package/src/lib/trackEvent.ts +26 -0
  123. package/src/lib/useOpenHeight.ts +28 -0
  124. package/src/primitives/Button.tsx +3 -1
  125. package/src/primitives/Chip.tsx +3 -1
  126. package/src/primitives/ImageFrame.tsx +48 -0
  127. package/src/primitives/ProseBlocks.tsx +67 -0
  128. package/src/primitives/QuoteCard.tsx +20 -0
  129. package/src/primitives/StarRow.tsx +37 -0
  130. package/src/primitives/TickList.tsx +24 -0
  131. package/src/primitives/index.ts +5 -0
  132. package/src/routing/BasePathProvider.tsx +47 -0
  133. package/src/routing/basePath.ts +57 -0
  134. package/src/routing/index.ts +8 -0
  135. package/src/scripts/check-base-path.ts +130 -0
  136. package/src/scripts/clean-sitemap.ts +27 -0
  137. package/src/scripts/contact-canary.ts +98 -0
  138. package/src/scripts/serve-static.ts +29 -3
  139. package/src/sections/BlogIndex.tsx +57 -0
  140. package/src/sections/BlogPost.tsx +65 -0
  141. package/src/sections/CTABanner.tsx +93 -0
  142. package/src/sections/CTASection.tsx +60 -0
  143. package/src/sections/ComparisonTable.tsx +114 -0
  144. package/src/sections/ContactForm.tsx +163 -0
  145. package/src/sections/ContactInfoCard.tsx +92 -0
  146. package/src/sections/CredentialList.tsx +28 -0
  147. package/src/sections/DeviceShowcase.tsx +39 -0
  148. package/src/sections/ExpandList.tsx +58 -0
  149. package/src/sections/FAQBrowser.tsx +283 -0
  150. package/src/sections/FeatureDetail.tsx +60 -0
  151. package/src/sections/FeatureGrid.tsx +83 -0
  152. package/src/sections/Gallery.tsx +84 -0
  153. package/src/sections/Hero.tsx +110 -0
  154. package/src/sections/HoursTable.tsx +23 -0
  155. package/src/sections/JumpRail.tsx +35 -0
  156. package/src/sections/LegalPage.tsx +43 -0
  157. package/src/sections/LocationSection.tsx +66 -0
  158. package/src/sections/Locations.tsx +71 -0
  159. package/src/sections/LogoMarquee.tsx +27 -0
  160. package/src/sections/MapEmbed.tsx +75 -0
  161. package/src/sections/MediaBand.tsx +20 -0
  162. package/src/sections/MenuList.tsx +44 -0
  163. package/src/sections/PageHero.tsx +25 -0
  164. package/src/sections/PressQuotes.tsx +51 -0
  165. package/src/sections/PricingBand.tsx +43 -0
  166. package/src/sections/PricingCard.tsx +36 -0
  167. package/src/sections/ProcessSteps.tsx +45 -0
  168. package/src/sections/ProfileCards.tsx +73 -0
  169. package/src/sections/RevealSlider.tsx +156 -0
  170. package/src/sections/ReviewCarousel.tsx +223 -0
  171. package/src/sections/ReviewWall.tsx +220 -0
  172. package/src/sections/SocialStrip.tsx +83 -0
  173. package/src/sections/SplitSection.tsx +56 -0
  174. package/src/sections/index.ts +76 -0
  175. package/src/sections/siteConfig.ts +201 -0
  176. package/src/styles/app.css +673 -0
  177. package/src/styles/artifact.css +759 -401
  178. package/src/styles/base.css +49 -47
  179. package/src/styles/chrome.css +71 -71
  180. package/src/styles/components.css +150 -140
  181. package/src/styles/gate.css +29 -29
  182. package/src/styles/primitives.css +190 -25
  183. package/src/styles/sections.css +2652 -0
  184. package/src/styles/star-rating.css +2 -2
  185. package/src/vite/index.ts +9 -2
  186. package/src/vite/marketing-config.ts +128 -0
  187. package/src/components/Collapsible.tsx +0 -43
  188. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Generic date and time phrasing shared across features. Domain vocab
3
+ * (sport verbs, kitchen fractions) stays with its feature.
4
+ */
5
+
6
+ const DAY_MS = 86_400_000;
7
+
8
+ /** A date only string (YYYY-MM-DD) is a local calendar day, not UTC midnight. */
9
+ function parse(iso: string): Date {
10
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
11
+ return day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(iso);
12
+ }
13
+
14
+ /**
15
+ * Compact `H:MM` (or `Nm`) duration. Differs from `formatDuration` in
16
+ * src/features/activities — that one is the full `HH:MM:SS`. This one is
17
+ * meant for tight UI (stats grid, card mod lines).
18
+ */
19
+ export function fmtTimeShort(seconds: number): string {
20
+ const h = Math.floor(seconds / 3600);
21
+ const m = Math.floor((seconds % 3600) / 60);
22
+ return h > 0 ? `${h}:${m.toString().padStart(2, "0")}` : `${m}m`;
23
+ }
24
+
25
+ export function shortDate(iso: string): string {
26
+ const d = parse(iso);
27
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
28
+ }
29
+
30
+ /**
31
+ * Phrase a date relative to today: `today`, `yesterday`, weekday name for the
32
+ * past week, then `Mon Day` afterwards. Used by the Today salut line and the
33
+ * Training card mod row.
34
+ */
35
+ export function relDay(iso: string, now: Date = new Date()): string {
36
+ const then = parse(iso);
37
+ const startOfNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
38
+ const startOfThen = new Date(then.getFullYear(), then.getMonth(), then.getDate()).getTime();
39
+ const diffDays = Math.round((startOfNow - startOfThen) / DAY_MS);
40
+ if (diffDays === 0) return "today";
41
+ if (diffDays === 1) return "yesterday";
42
+ if (diffDays > 1 && diffDays < 7) return then.toLocaleDateString("en-US", { weekday: "long" });
43
+ return then.toLocaleDateString("en-US", { month: "short", day: "numeric" });
44
+ }
45
+
46
+ export function partOfDay(d: Date = new Date()): string {
47
+ const h = d.getHours();
48
+ if (h < 5) return "Late night";
49
+ if (h < 12) return "Morning";
50
+ if (h < 17) return "Afternoon";
51
+ if (h < 21) return "Evening";
52
+ return "Tonight";
53
+ }
54
+
55
+ /**
56
+ * Compact local time of day ("9:38 am").
57
+ */
58
+ export function formatTimeOfDay(iso: string): string {
59
+ const d = new Date(iso);
60
+ return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase();
61
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The transport: how this app talks to its api. Everything technology specific
3
+ * about the wire lives here (base URL, bearer token, proactive and reactive
4
+ * refresh, the snake_case to camelCase transform, error shapes). Resource
5
+ * modules under src/features/<feature>/api.ts build on `apiFetch` and never
6
+ * touch `fetch` themselves; swapping the backend means swapping this file and
7
+ * the session module beside it, not the features. The base URL is the app's
8
+ * `apiBaseUrl`, read on every call.
9
+ */
10
+
11
+ import { getAppConfig } from "../config";
12
+ import { useAuthStore } from "../stores/authStore";
13
+ import { ensureValidToken, handleUnauthorized } from "./tokenRefresh";
14
+ import { transformKeys } from "./transformKeys";
15
+
16
+ /**
17
+ * Auth endpoints that should NOT trigger token validation.
18
+ * These endpoints are used during login when no valid token exists.
19
+ */
20
+ const AUTH_ENDPOINTS = ["/v1/auth/token"] as const;
21
+
22
+ /**
23
+ * Check if an endpoint is an auth endpoint that should skip token validation.
24
+ */
25
+ function isAuthEndpoint(endpoint: string): boolean {
26
+ const pathname = endpoint.split("?")[0];
27
+ return AUTH_ENDPOINTS.some((authPath) => pathname === authPath);
28
+ }
29
+
30
+ /**
31
+ * The human-readable half of an error response. Rails renders validation
32
+ * failures as an `errors` array (one message per problem) and everything else
33
+ * as a single `error` string, so both shapes have to reach the toast -- without
34
+ * the array, a 422 the server explained carefully shows up as a bare status.
35
+ */
36
+ function errorMessage(errorData: unknown, response: Response): string {
37
+ const body = errorData as { error?: string; message?: string; errors?: unknown } | null;
38
+
39
+ if (body?.error) return body.error;
40
+ if (body?.message) return body.message;
41
+ if (Array.isArray(body?.errors) && body.errors.length > 0) {
42
+ return body.errors.map((entry) => String(entry)).join(". ");
43
+ }
44
+ return `${response.status} ${response.statusText}`;
45
+ }
46
+
47
+ /**
48
+ * Make a fetch request with the current auth token.
49
+ */
50
+ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
51
+ const token = useAuthStore.getState().token;
52
+
53
+ return fetch(url, {
54
+ ...options,
55
+ headers: {
56
+ "Content-Type": "application/json",
57
+ ...(token && { Authorization: `Bearer ${token}` }),
58
+ ...options.headers,
59
+ },
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Parse a successful response. A 204 or an empty body (a DELETE the api
65
+ * answered with no content) is a success with nothing to read, not a parse
66
+ * error.
67
+ */
68
+ async function readBody<T>(response: Response): Promise<T> {
69
+ if (response.status === 204) return undefined as T;
70
+ const text = await response.text();
71
+ if (text === "") return undefined as T;
72
+ return transformKeys<T>(JSON.parse(text));
73
+ }
74
+
75
+ /**
76
+ * A read that needs no session: what a public page (see publicRoutes.ts)
77
+ * loads for a visitor who is not signed in. No bearer token, no refresh, no
78
+ * logout on failure; the same error shapes and the same camelCase transform
79
+ * as `apiFetch`. A signed in reader uses it too, since the endpoint serves
80
+ * the same public subset either way and a stale token must never break a
81
+ * page that needs none. GET only by design: nothing public writes.
82
+ */
83
+ export async function publicFetch<T>(endpoint: string): Promise<T> {
84
+ const response = await fetch(`${getAppConfig().apiBaseUrl}${endpoint}`, {
85
+ headers: { Accept: "application/json" },
86
+ });
87
+
88
+ if (!response.ok) {
89
+ const errorData = await response.json().catch(() => null);
90
+ throw new Error(`API error: ${errorMessage(errorData, response)}`);
91
+ }
92
+
93
+ return transformKeys<T>(await response.json());
94
+ }
95
+
96
+ /**
97
+ * Base fetch wrapper for API calls.
98
+ * Handles authentication headers, proactive token refresh, and 401 retry.
99
+ */
100
+ export async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
101
+ const url = `${getAppConfig().apiBaseUrl}${endpoint}`;
102
+ const skipAuth = isAuthEndpoint(endpoint);
103
+
104
+ // Proactive refresh: skip for auth endpoints (login/refresh)
105
+ if (!skipAuth && !(await ensureValidToken())) {
106
+ throw new Error("Session expired. Please log in again.");
107
+ }
108
+
109
+ let response = await fetchWithAuth(url, options);
110
+
111
+ // Reactive refresh: skip for auth endpoints to avoid refresh loops
112
+ if (!skipAuth && response.status === 401) {
113
+ const refreshed = await handleUnauthorized();
114
+ if (refreshed) {
115
+ // Retry with new token
116
+ response = await fetchWithAuth(url, options);
117
+ } else {
118
+ // Refresh failed, user was logged out
119
+ throw new Error("Session expired. Please log in again.");
120
+ }
121
+ }
122
+
123
+ // Handle 401 errors from the retry. An auth endpoint's own 401 falls through
124
+ // to the error branch below, so the server's message reaches the caller.
125
+ if (response.status === 401 && !skipAuth) {
126
+ useAuthStore.getState().logout();
127
+ throw new Error("Session expired. Please log in again.");
128
+ }
129
+
130
+ if (!response.ok) {
131
+ const errorData = await response.json().catch(() => null);
132
+ throw new Error(`API error: ${errorMessage(errorData, response)}`);
133
+ }
134
+
135
+ return readBody<T>(response);
136
+ }
137
+
138
+ /**
139
+ * POST a multipart/form-data body (e.g. a file upload).
140
+ *
141
+ * Distinct from apiFetch because the browser must set the multipart boundary
142
+ * itself: we deliberately omit Content-Type and pass the FormData through
143
+ * untouched (no JSON.stringify, no toSnakeCase, which would corrupt it). Auth,
144
+ * the proactive/reactive token refresh, and the camelCase response transform
145
+ * all mirror apiFetch.
146
+ */
147
+ export async function uploadFormData<T>(endpoint: string, formData: FormData): Promise<T> {
148
+ const url = `${getAppConfig().apiBaseUrl}${endpoint}`;
149
+
150
+ if (!(await ensureValidToken())) {
151
+ throw new Error("Session expired. Please log in again.");
152
+ }
153
+
154
+ const send = () => {
155
+ const token = useAuthStore.getState().token;
156
+ return fetch(url, {
157
+ method: "POST",
158
+ body: formData,
159
+ headers: { ...(token && { Authorization: `Bearer ${token}` }) },
160
+ });
161
+ };
162
+
163
+ let response = await send();
164
+
165
+ if (response.status === 401) {
166
+ const refreshed = await handleUnauthorized();
167
+ if (refreshed) {
168
+ response = await send();
169
+ } else {
170
+ throw new Error("Session expired. Please log in again.");
171
+ }
172
+ }
173
+
174
+ if (response.status === 401) {
175
+ useAuthStore.getState().logout();
176
+ throw new Error("Session expired. Please log in again.");
177
+ }
178
+
179
+ if (!response.ok) {
180
+ const errorData = await response.json().catch(() => null);
181
+ throw new Error(`API error: ${errorMessage(errorData, response)}`);
182
+ }
183
+
184
+ return readBody<T>(response);
185
+ }
186
+
187
+ /**
188
+ * Build a query string from filter parameters.
189
+ * Arrays are joined with commas. Undefined/null values are skipped.
190
+ */
191
+ export function buildQueryString(
192
+ params: Record<string, string | number | string[] | undefined | null>,
193
+ ): string {
194
+ const searchParams = new URLSearchParams();
195
+ for (const [key, value] of Object.entries(params)) {
196
+ if (value === undefined || value === null) continue;
197
+ if (Array.isArray(value)) {
198
+ if (value.length > 0) searchParams.set(key, value.join(","));
199
+ } else {
200
+ searchParams.set(key, String(value));
201
+ }
202
+ }
203
+ const queryString = searchParams.toString();
204
+ return queryString ? `?${queryString}` : "";
205
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `<prefix>-last-auth` cookie. Pure UX hint for the welcome back greeting.
3
+ * Does NOT grant any session; the JWT in localStorage does that. Read at the
4
+ * unauthenticated `/` to switch between first time and returning visitor copy;
5
+ * written on every successful sign in.
6
+ */
7
+
8
+ import { getAppConfig } from "../config";
9
+
10
+ const cookieName = () => `${getAppConfig().storagePrefix}-last-auth`;
11
+ const COOKIE_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; // 90 days
12
+
13
+ export interface LastAuth {
14
+ email: string;
15
+ provider: string;
16
+ }
17
+
18
+ export function readLastAuth(): LastAuth | null {
19
+ if (typeof document === "undefined") return null;
20
+
21
+ const name = cookieName();
22
+ const match = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`));
23
+ if (!match) return null;
24
+
25
+ const raw = match.slice(name.length + 1);
26
+ try {
27
+ const parsed = JSON.parse(decodeURIComponent(raw));
28
+ if (typeof parsed?.email === "string" && typeof parsed?.provider === "string") {
29
+ return parsed;
30
+ }
31
+ return null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function writeLastAuth(value: LastAuth): void {
38
+ if (typeof document === "undefined") return;
39
+
40
+ const encoded = encodeURIComponent(JSON.stringify(value));
41
+ const isSecure = typeof window !== "undefined" && window.location.protocol === "https:";
42
+ const secureFlag = isSecure ? "; Secure" : "";
43
+ // biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is missing from browsers the app serves
44
+ document.cookie = `${cookieName()}=${encoded}; Max-Age=${COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Lax${secureFlag}`;
45
+ }
@@ -0,0 +1,32 @@
1
+ import { getAppConfig } from "../config";
2
+
3
+ /**
4
+ * Start Google sign in. Submits a hidden POST form to OmniAuth's request
5
+ * phase rather than navigating via window.location.href, matching OmniAuth
6
+ * 2.0's POST-only default for resistance to login CSRF.
7
+ */
8
+ export function startGoogleAuth(): void {
9
+ postNavigate(`${getAppConfig().apiBaseUrl}/auth/google_oauth2`);
10
+ }
11
+
12
+ /**
13
+ * Build and submit a hidden POST form, causing the browser to navigate to
14
+ * the target URL via a POST request. Used by OAuth start flows so OmniAuth
15
+ * sees a POST as it expects in 2.0+.
16
+ *
17
+ * CSRF tradeoff: the canonical defense (`omniauth-rails_csrf_protection` +
18
+ * an `authenticity_token` field) requires a Rails session, which this JSON
19
+ * API does not have. A session-less CSRF flow (mint one-time token via
20
+ * dedicated endpoint, include in form, validate server side) is the right
21
+ * long term fix; tracked as a follow on.
22
+ * The login-CSRF threat is bounded today by AUTHORIZED_EMAILS gating new
23
+ * sign ups (an attacker forcing a sign in still cannot create an account).
24
+ */
25
+ export function postNavigate(url: string): void {
26
+ const form = document.createElement("form");
27
+ form.method = "POST";
28
+ form.action = url;
29
+ form.style.display = "none";
30
+ document.body.appendChild(form);
31
+ form.submit();
32
+ }
@@ -0,0 +1,33 @@
1
+ import { peekAppConfig } from "../config";
2
+
3
+ /**
4
+ * Which paths a signed out visitor may load, read from the app's config.
5
+ *
6
+ * Two kinds. A **public route** is an exact path the shell renders bare:
7
+ * the landing at "/" and the OAuth callback. A **public page** is a pattern,
8
+ * a prefix ending in "/*", for pages that stand on their own signed out and
9
+ * render inside the public chrome (a brand bar with a sign in link): a
10
+ * person's public activity or recipe under "/u/<username>/...". Signed in,
11
+ * a public page renders inside the normal app shell like any other page.
12
+ */
13
+
14
+ // Before the app configures the shell nothing is public, so a guard that
15
+ // runs early holds the page rather than opening it.
16
+ const exact = (pathname: string) => (peekAppConfig()?.publicRoutes ?? []).includes(pathname);
17
+
18
+ const matchesPattern = (pathname: string, pattern: string) => {
19
+ if (!pattern.endsWith("/*")) return pathname === pattern;
20
+ const prefix = pattern.slice(0, -1); // keep the trailing slash: "/u/" never matches "/users"
21
+ return pathname.startsWith(prefix) && pathname.length > prefix.length;
22
+ };
23
+
24
+ /** Whether the path is a public page: one that renders in the public chrome signed out. */
25
+ export function isPublicPage(pathname: string): boolean {
26
+ const pages = peekAppConfig()?.publicPages ?? [];
27
+ return pages.some((pattern) => matchesPattern(pathname, pattern));
28
+ }
29
+
30
+ /** Whether a signed out visitor may load the path at all. */
31
+ export function isPublicRoute(pathname: string): boolean {
32
+ return exact(pathname) || isPublicPage(pathname);
33
+ }
@@ -0,0 +1,38 @@
1
+ import { MutationCache, QueryClient } from "@tanstack/react-query";
2
+ import { toast } from "sonner";
3
+ import type {} from "../types/query";
4
+
5
+ const mutationCache = new MutationCache({
6
+ onSuccess: (data, _variables, _context, mutation) => {
7
+ const toastConfig = mutation.options.meta?.toast;
8
+ if (!toastConfig?.success) return;
9
+ const title =
10
+ typeof toastConfig.success === "function" ? toastConfig.success(data) : toastConfig.success;
11
+ const description =
12
+ typeof toastConfig.successDescription === "function"
13
+ ? toastConfig.successDescription(data)
14
+ : toastConfig.successDescription;
15
+ toast.success(title, description ? { description } : undefined);
16
+ },
17
+ onError: (error, _variables, _context, mutation) => {
18
+ const toastConfig = mutation.options.meta?.toast;
19
+ if (!toastConfig?.error) return;
20
+ toast.error(toastConfig.error, { description: error.message });
21
+ },
22
+ });
23
+
24
+ /**
25
+ * TanStack Query client instance.
26
+ * Exported for use in:
27
+ * - QueryClientProvider (in __root.tsx)
28
+ * - Route loaders (queryClient.ensureQueryData)
29
+ */
30
+ export const queryClient = new QueryClient({
31
+ mutationCache,
32
+ defaultOptions: {
33
+ queries: {
34
+ staleTime: 1000 * 60 * 5, // 5 minutes
35
+ retry: 1,
36
+ },
37
+ },
38
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Check whether any of the given fields contain the search query.
3
+ * Accepts both string and string-array fields (e.g. tags).
4
+ */
5
+ export function matchesSearch(
6
+ query: string,
7
+ fields: (string | string[] | null | undefined)[],
8
+ ): boolean {
9
+ const lower = query.toLowerCase();
10
+ return fields.some((field) => {
11
+ if (!field) return false;
12
+ if (Array.isArray(field)) {
13
+ return field.some((item) => item.toLowerCase().includes(lower));
14
+ }
15
+ return field.toLowerCase().includes(lower);
16
+ });
17
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The session half of the api contract every app on this template shares:
3
+ * exchange an OAuth code for the token pair, and read the signed in user.
4
+ * The shell (UserContext, the OAuth callback) imports this directly and never
5
+ * the app's aggregate, so upstream code has no dependency on app resources.
6
+ */
7
+
8
+ import type { RegisteredUser, TokenResponse } from "../types";
9
+ import { apiFetch } from "./http";
10
+ import { toSnakeCase } from "./transformKeys";
11
+ import type { UnitPreferences } from "./units";
12
+ import { type ApiEntityEnvelope, unwrapEntity } from "./unwrap";
13
+
14
+ export const session = {
15
+ /** Exchange an OAuth code for the JWT pair. */
16
+ token: (code: string) =>
17
+ apiFetch<TokenResponse>("/v1/auth/token", {
18
+ method: "POST",
19
+ body: JSON.stringify({ code }),
20
+ }),
21
+
22
+ /** The signed in user. */
23
+ me: async () => {
24
+ const envelope = await apiFetch<ApiEntityEnvelope<RegisteredUser>>("/v1/users/me");
25
+ return unwrapEntity(envelope);
26
+ },
27
+
28
+ /** Save the person's unit preferences, a system per measure. */
29
+ updateUnits: async (units: UnitPreferences) => {
30
+ const envelope = await apiFetch<ApiEntityEnvelope<RegisteredUser>>("/v1/users/me", {
31
+ method: "PATCH",
32
+ body: JSON.stringify({ user: { units: toSnakeCase(units) } }),
33
+ });
34
+ return unwrapEntity(envelope);
35
+ },
36
+ };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Token refresh module for automatic JWT token refresh.
3
+ * Handles both proactive (before expiry) and reactive (on 401) refresh.
4
+ */
5
+
6
+ import { getAppConfig } from "../config";
7
+ import { DEFAULT_EXPIRY_BUFFER_MS, useAuthStore } from "../stores/authStore";
8
+ import type { RefreshResponse } from "../types";
9
+ import { transformKeys } from "./transformKeys";
10
+
11
+ /**
12
+ * Module level promise for deduplicating concurrent refresh requests.
13
+ * Only one refresh request will be in flight at a time.
14
+ */
15
+ let refreshPromise: Promise<RefreshResponse> | null = null;
16
+
17
+ /**
18
+ * Refresh the access token using the stored refresh token.
19
+ * Deduplicates concurrent calls to ensure only one refresh request is made.
20
+ *
21
+ * @returns The new token response with updated access and refresh tokens
22
+ * @throws Error if refresh fails. Only a rejected refresh token logs out; a network error or a 5xx keeps the session.
23
+ */
24
+ export async function refreshAccessToken(): Promise<RefreshResponse> {
25
+ // Return existing promise if refresh is already in progress
26
+ if (refreshPromise) {
27
+ return refreshPromise;
28
+ }
29
+
30
+ const { refreshToken, logout } = useAuthStore.getState();
31
+
32
+ if (!refreshToken) {
33
+ logout();
34
+ throw new Error("No refresh token available");
35
+ }
36
+
37
+ refreshPromise = (async () => {
38
+ const response = await fetch(`${getAppConfig().apiBaseUrl}/v1/auth/refresh`, {
39
+ method: "POST",
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ },
43
+ // The Rails API reads params[:refresh_token], so the body must be
44
+ // snake_case. This raw fetch does not run through apiFetch's
45
+ // toSnakeCase, so name the field explicitly here.
46
+ body: JSON.stringify({ refresh_token: refreshToken }),
47
+ });
48
+
49
+ if (!response.ok) {
50
+ // A rejected refresh token ends the session. A server error or a
51
+ // throttle says nothing about the token, so the session is kept.
52
+ if (response.status < 500 && response.status !== 408 && response.status !== 429) {
53
+ logout();
54
+ }
55
+ throw new Error(`Refresh failed: ${response.status}`);
56
+ }
57
+
58
+ const data = await response.json();
59
+ const result = transformKeys<RefreshResponse>(data);
60
+
61
+ // A sign out while the request was in flight must not be undone by its
62
+ // answer: only the session that asked may take the new tokens.
63
+ if (useAuthStore.getState().refreshToken !== refreshToken) {
64
+ throw new Error("Signed out during refresh");
65
+ }
66
+
67
+ // Update store with new tokens
68
+ useAuthStore.getState().setTokens(result.token, result.refreshToken, result.expiresIn);
69
+
70
+ return result;
71
+ })();
72
+
73
+ // Clear the promise after it settles for all consumers.
74
+ // The .catch() prevents unhandled rejection warnings since we're not awaiting this chain.
75
+ refreshPromise
76
+ .finally(() => {
77
+ refreshPromise = null;
78
+ })
79
+ .catch(() => {});
80
+
81
+ return refreshPromise;
82
+ }
83
+
84
+ /**
85
+ * Ensure the access token is valid before making an API call.
86
+ * Proactively refreshes the token if it is expiring soon.
87
+ *
88
+ * @param bufferMs Time before expiry to trigger refresh (default 5 minutes)
89
+ * @returns true if token is valid (or was refreshed), false if logout occurred
90
+ */
91
+ export async function ensureValidToken(bufferMs = DEFAULT_EXPIRY_BUFFER_MS): Promise<boolean> {
92
+ const { isAuthenticated, isTokenExpiringSoon } = useAuthStore.getState();
93
+
94
+ // Not authenticated, nothing to refresh
95
+ if (!isAuthenticated) {
96
+ return false;
97
+ }
98
+
99
+ // Token is still valid
100
+ if (!isTokenExpiringSoon(bufferMs)) {
101
+ return true;
102
+ }
103
+
104
+ // Token is expiring soon, attempt refresh
105
+ try {
106
+ await refreshAccessToken();
107
+ return true;
108
+ } catch {
109
+ // A rejected refresh token logged the user out. A network error or a
110
+ // server error did not: the access token still serves until it expires.
111
+ const state = useAuthStore.getState();
112
+ return state.isAuthenticated && !state.isTokenExpiringSoon(0);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Handle a 401 Unauthorized response by attempting to refresh the token.
118
+ * Used for reactive refresh when the server rejects an expired token.
119
+ *
120
+ * @returns true if refresh succeeded and request should be retried, false if logout occurred
121
+ */
122
+ export async function handleUnauthorized(): Promise<boolean> {
123
+ try {
124
+ await refreshAccessToken();
125
+ return true;
126
+ } catch {
127
+ // Refresh failed, user was logged out by refreshAccessToken
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Reset the refresh promise (for testing purposes).
134
+ */
135
+ export function resetRefreshState(): void {
136
+ refreshPromise = null;
137
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Utility functions for transforming API keys.
3
+ * Used to convert between snake_case (Rails API) and camelCase (TypeScript).
4
+ */
5
+
6
+ /**
7
+ * Convert a snake_case string to camelCase.
8
+ */
9
+ function snakeToCamel(str: string): string {
10
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
11
+ }
12
+
13
+ /**
14
+ * Convert a camelCase string to snake_case.
15
+ */
16
+ function camelToSnake(str: string): string {
17
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
18
+ }
19
+
20
+ /**
21
+ * Recursively convert all keys in an object from snake_case to camelCase.
22
+ * Works with nested objects and arrays.
23
+ * Use for API responses.
24
+ *
25
+ * @example
26
+ * transformKeys({ user_name: "John", created_at: "2024-01-01" })
27
+ * // Returns: { userName: "John", createdAt: "2024-01-01" }
28
+ */
29
+ export function transformKeys<T>(obj: unknown): T {
30
+ if (Array.isArray(obj)) {
31
+ return obj.map((item) => transformKeys(item)) as T;
32
+ }
33
+ if (obj !== null && typeof obj === "object") {
34
+ return Object.fromEntries(
35
+ Object.entries(obj).map(([key, value]) => [snakeToCamel(key), transformKeys(value)]),
36
+ ) as T;
37
+ }
38
+ return obj as T;
39
+ }
40
+
41
+ /**
42
+ * Recursively convert all keys in an object from camelCase to snake_case.
43
+ * Works with nested objects and arrays.
44
+ * Use for API request bodies.
45
+ *
46
+ * @example
47
+ * toSnakeCase({ userName: "John", createdAt: "2024-01-01" })
48
+ * // Returns: { user_name: "John", created_at: "2024-01-01" }
49
+ */
50
+ export function toSnakeCase<T>(obj: unknown): T {
51
+ if (Array.isArray(obj)) {
52
+ return obj.map((item) => toSnakeCase(item)) as T;
53
+ }
54
+ if (obj !== null && typeof obj === "object") {
55
+ return Object.fromEntries(
56
+ Object.entries(obj).map(([key, value]) => [camelToSnake(key), toSnakeCase(value)]),
57
+ ) as T;
58
+ }
59
+ return obj as T;
60
+ }