create-stackr 0.2.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 (274) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +642 -0
  3. package/bin/cli.js +12 -0
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +113 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config/dependencies.d.ts +82 -0
  9. package/dist/config/dependencies.d.ts.map +1 -0
  10. package/dist/config/dependencies.js +82 -0
  11. package/dist/config/dependencies.js.map +1 -0
  12. package/dist/config/presets.d.ts +3 -0
  13. package/dist/config/presets.d.ts.map +1 -0
  14. package/dist/config/presets.js +174 -0
  15. package/dist/config/presets.js.map +1 -0
  16. package/dist/generators/index.d.ts +40 -0
  17. package/dist/generators/index.d.ts.map +1 -0
  18. package/dist/generators/index.js +130 -0
  19. package/dist/generators/index.js.map +1 -0
  20. package/dist/generators/onboarding.d.ts +8 -0
  21. package/dist/generators/onboarding.d.ts.map +1 -0
  22. package/dist/generators/onboarding.js +141 -0
  23. package/dist/generators/onboarding.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +65 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/prompts/features.d.ts +14 -0
  29. package/dist/prompts/features.d.ts.map +1 -0
  30. package/dist/prompts/features.js +96 -0
  31. package/dist/prompts/features.js.map +1 -0
  32. package/dist/prompts/index.d.ts +3 -0
  33. package/dist/prompts/index.d.ts.map +1 -0
  34. package/dist/prompts/index.js +93 -0
  35. package/dist/prompts/index.js.map +1 -0
  36. package/dist/prompts/onboarding.d.ts +6 -0
  37. package/dist/prompts/onboarding.d.ts.map +1 -0
  38. package/dist/prompts/onboarding.js +37 -0
  39. package/dist/prompts/onboarding.js.map +1 -0
  40. package/dist/prompts/orm.d.ts +3 -0
  41. package/dist/prompts/orm.d.ts.map +1 -0
  42. package/dist/prompts/orm.js +23 -0
  43. package/dist/prompts/orm.js.map +1 -0
  44. package/dist/prompts/packageManager.d.ts +2 -0
  45. package/dist/prompts/packageManager.d.ts.map +1 -0
  46. package/dist/prompts/packageManager.js +18 -0
  47. package/dist/prompts/packageManager.js.map +1 -0
  48. package/dist/prompts/platform.d.ts +3 -0
  49. package/dist/prompts/platform.d.ts.map +1 -0
  50. package/dist/prompts/platform.js +21 -0
  51. package/dist/prompts/platform.js.map +1 -0
  52. package/dist/prompts/preset.d.ts +4 -0
  53. package/dist/prompts/preset.d.ts.map +1 -0
  54. package/dist/prompts/preset.js +165 -0
  55. package/dist/prompts/preset.js.map +1 -0
  56. package/dist/prompts/project.d.ts +2 -0
  57. package/dist/prompts/project.d.ts.map +1 -0
  58. package/dist/prompts/project.js +27 -0
  59. package/dist/prompts/project.js.map +1 -0
  60. package/dist/prompts/sdks.d.ts +2 -0
  61. package/dist/prompts/sdks.d.ts.map +1 -0
  62. package/dist/prompts/sdks.js +46 -0
  63. package/dist/prompts/sdks.js.map +1 -0
  64. package/dist/types/index.d.ts +77 -0
  65. package/dist/types/index.d.ts.map +1 -0
  66. package/dist/types/index.js +25 -0
  67. package/dist/types/index.js.map +1 -0
  68. package/dist/utils/cleanup.d.ts +5 -0
  69. package/dist/utils/cleanup.d.ts.map +1 -0
  70. package/dist/utils/cleanup.js +38 -0
  71. package/dist/utils/cleanup.js.map +1 -0
  72. package/dist/utils/copy.d.ts +10 -0
  73. package/dist/utils/copy.d.ts.map +1 -0
  74. package/dist/utils/copy.js +53 -0
  75. package/dist/utils/copy.js.map +1 -0
  76. package/dist/utils/errors.d.ts +33 -0
  77. package/dist/utils/errors.d.ts.map +1 -0
  78. package/dist/utils/errors.js +136 -0
  79. package/dist/utils/errors.js.map +1 -0
  80. package/dist/utils/git.d.ts +5 -0
  81. package/dist/utils/git.d.ts.map +1 -0
  82. package/dist/utils/git.js +33 -0
  83. package/dist/utils/git.js.map +1 -0
  84. package/dist/utils/logger.d.ts +9 -0
  85. package/dist/utils/logger.d.ts.map +1 -0
  86. package/dist/utils/logger.js +22 -0
  87. package/dist/utils/logger.js.map +1 -0
  88. package/dist/utils/package.d.ts +16 -0
  89. package/dist/utils/package.d.ts.map +1 -0
  90. package/dist/utils/package.js +86 -0
  91. package/dist/utils/package.js.map +1 -0
  92. package/dist/utils/system-validation.d.ts +9 -0
  93. package/dist/utils/system-validation.d.ts.map +1 -0
  94. package/dist/utils/system-validation.js +31 -0
  95. package/dist/utils/system-validation.js.map +1 -0
  96. package/dist/utils/template.d.ts +20 -0
  97. package/dist/utils/template.d.ts.map +1 -0
  98. package/dist/utils/template.js +234 -0
  99. package/dist/utils/template.js.map +1 -0
  100. package/dist/utils/validation.d.ts +8 -0
  101. package/dist/utils/validation.d.ts.map +1 -0
  102. package/dist/utils/validation.js +94 -0
  103. package/dist/utils/validation.js.map +1 -0
  104. package/package.json +96 -0
  105. package/templates/base/backend/.dockerignore.ejs +62 -0
  106. package/templates/base/backend/.env.example.ejs +116 -0
  107. package/templates/base/backend/Dockerfile.ejs +142 -0
  108. package/templates/base/backend/controllers/event-queue/index.ts +20 -0
  109. package/templates/base/backend/controllers/event-queue/workers/user.ts +39 -0
  110. package/templates/base/backend/controllers/rest-api/index.ts +48 -0
  111. package/templates/base/backend/controllers/rest-api/plugins/auth.ts +152 -0
  112. package/templates/base/backend/controllers/rest-api/plugins/config.ts +64 -0
  113. package/templates/base/backend/controllers/rest-api/plugins/error-handler.ts +118 -0
  114. package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +180 -0
  115. package/templates/base/backend/controllers/rest-api/routes/device-sessions.ts +197 -0
  116. package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +375 -0
  117. package/templates/base/backend/controllers/rest-api/server.ts.ejs +87 -0
  118. package/templates/base/backend/domain/device-session/repository.drizzle.ts +209 -0
  119. package/templates/base/backend/domain/device-session/repository.prisma.ts +248 -0
  120. package/templates/base/backend/domain/device-session/schema.ts +72 -0
  121. package/templates/base/backend/domain/session/repository.drizzle.ts +72 -0
  122. package/templates/base/backend/domain/session/repository.prisma.ts +72 -0
  123. package/templates/base/backend/domain/session/schema.ts +29 -0
  124. package/templates/base/backend/domain/user/repository.drizzle.ts +127 -0
  125. package/templates/base/backend/domain/user/repository.prisma.ts +115 -0
  126. package/templates/base/backend/domain/user/schema.ts +14 -0
  127. package/templates/base/backend/drizzle/schema.drizzle.ts +111 -0
  128. package/templates/base/backend/drizzle.config.drizzle.ts +13 -0
  129. package/templates/base/backend/lib/auth.drizzle.ts.ejs +104 -0
  130. package/templates/base/backend/lib/auth.prisma.ts.ejs +97 -0
  131. package/templates/base/backend/lib/constants.ts.ejs +29 -0
  132. package/templates/base/backend/package.json.ejs +50 -0
  133. package/templates/base/backend/prisma/schema.prisma.ejs +102 -0
  134. package/templates/base/backend/prisma.config.prisma.ts +12 -0
  135. package/templates/base/backend/tsconfig.json +39 -0
  136. package/templates/base/backend/utils/db.drizzle.ts +41 -0
  137. package/templates/base/backend/utils/db.prisma.ts +51 -0
  138. package/templates/base/backend/utils/email.ts.ejs +35 -0
  139. package/templates/base/backend/utils/errors.ts +348 -0
  140. package/templates/base/backend/utils/redis.ts.ejs +279 -0
  141. package/templates/base/mobile/.env.example.ejs +35 -0
  142. package/templates/base/mobile/.gitignore.ejs +167 -0
  143. package/templates/base/mobile/app/+not-found.tsx +85 -0
  144. package/templates/base/mobile/app/_layout.tsx.ejs +71 -0
  145. package/templates/base/mobile/app.json.ejs +88 -0
  146. package/templates/base/mobile/assets/images/adaptive-icon.png +0 -0
  147. package/templates/base/mobile/assets/images/favicon.png +0 -0
  148. package/templates/base/mobile/assets/images/icon.png +0 -0
  149. package/templates/base/mobile/assets/images/onboarding_page_1.png +0 -0
  150. package/templates/base/mobile/assets/images/onboarding_page_2.png +0 -0
  151. package/templates/base/mobile/assets/images/onboarding_page_3.png +0 -0
  152. package/templates/base/mobile/assets/images/paywall_image.png +0 -0
  153. package/templates/base/mobile/assets/images/splash.png +0 -0
  154. package/templates/base/mobile/eas.json.ejs +49 -0
  155. package/templates/base/mobile/metro.config.js +9 -0
  156. package/templates/base/mobile/package.json.ejs +53 -0
  157. package/templates/base/mobile/src/components/ui/Button.tsx +131 -0
  158. package/templates/base/mobile/src/components/ui/Card.tsx +68 -0
  159. package/templates/base/mobile/src/components/ui/IconSymbol.tsx +90 -0
  160. package/templates/base/mobile/src/components/ui/Input.tsx +142 -0
  161. package/templates/base/mobile/src/components/ui/LoadingSpinner.tsx +98 -0
  162. package/templates/base/mobile/src/components/ui/OnboardingLayout.tsx +356 -0
  163. package/templates/base/mobile/src/components/ui/PaywallLayout.tsx +311 -0
  164. package/templates/base/mobile/src/components/ui/Skeleton.tsx +58 -0
  165. package/templates/base/mobile/src/components/ui/index.ts +6 -0
  166. package/templates/base/mobile/src/constants/Theme.ts +163 -0
  167. package/templates/base/mobile/src/context/ThemeContext.tsx +157 -0
  168. package/templates/base/mobile/src/lib/auth-client.ts.ejs +51 -0
  169. package/templates/base/mobile/src/services/api.ts.ejs +71 -0
  170. package/templates/base/mobile/src/services/errorService.ts +179 -0
  171. package/templates/base/mobile/src/services/sdkInitializer.ts.ejs +36 -0
  172. package/templates/base/mobile/src/store/index.ts.ejs +18 -0
  173. package/templates/base/mobile/src/store/ui.store.ts +100 -0
  174. package/templates/base/mobile/src/utils/formatters.ts +105 -0
  175. package/templates/base/mobile/src/utils/logger.ts +73 -0
  176. package/templates/base/mobile/src/utils/responsive.ts +234 -0
  177. package/templates/base/mobile/tsconfig.json +32 -0
  178. package/templates/base/web/.env.example.ejs +26 -0
  179. package/templates/base/web/components.json +22 -0
  180. package/templates/base/web/eslint.config.mjs +18 -0
  181. package/templates/base/web/next.config.ts +7 -0
  182. package/templates/base/web/package.json.ejs +35 -0
  183. package/templates/base/web/postcss.config.mjs +7 -0
  184. package/templates/base/web/public/.gitkeep +0 -0
  185. package/templates/base/web/public/file.svg +1 -0
  186. package/templates/base/web/public/globe.svg +1 -0
  187. package/templates/base/web/public/next.svg +1 -0
  188. package/templates/base/web/public/vercel.svg +1 -0
  189. package/templates/base/web/public/window.svg +1 -0
  190. package/templates/base/web/src/app/favicon.ico +0 -0
  191. package/templates/base/web/src/app/globals.css +152 -0
  192. package/templates/base/web/src/app/layout.tsx.ejs +54 -0
  193. package/templates/base/web/src/app/page.tsx.ejs +92 -0
  194. package/templates/base/web/src/components/auth/auth-hydrator.tsx.ejs +19 -0
  195. package/templates/base/web/src/components/auth/protected-route.tsx.ejs +109 -0
  196. package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +56 -0
  197. package/templates/base/web/src/components/providers/theme-provider.tsx +17 -0
  198. package/templates/base/web/src/components/theme-toggle.tsx +34 -0
  199. package/templates/base/web/src/components/ui/button.tsx +62 -0
  200. package/templates/base/web/src/components/ui/card.tsx +92 -0
  201. package/templates/base/web/src/components/ui/input.tsx +21 -0
  202. package/templates/base/web/src/components/ui/label.tsx +24 -0
  203. package/templates/base/web/src/components/ui/skeleton.tsx +13 -0
  204. package/templates/base/web/src/components/ui/spinner.tsx +20 -0
  205. package/templates/base/web/src/hooks/use-device-session.ts.ejs +40 -0
  206. package/templates/base/web/src/hooks/use-session.ts.ejs +56 -0
  207. package/templates/base/web/src/lib/auth/actions.ts.ejs +334 -0
  208. package/templates/base/web/src/lib/auth/config.ts.ejs +65 -0
  209. package/templates/base/web/src/lib/auth/cookies.ts.ejs +74 -0
  210. package/templates/base/web/src/lib/auth/index.ts.ejs +40 -0
  211. package/templates/base/web/src/lib/auth/oauth.ts.ejs +72 -0
  212. package/templates/base/web/src/lib/auth/pkce.ts.ejs +48 -0
  213. package/templates/base/web/src/lib/auth/sessions.ts.ejs +135 -0
  214. package/templates/base/web/src/lib/auth/user-agent.ts.ejs +47 -0
  215. package/templates/base/web/src/lib/device/actions.ts.ejs +148 -0
  216. package/templates/base/web/src/lib/device/id.ts.ejs +74 -0
  217. package/templates/base/web/src/lib/utils.ts +6 -0
  218. package/templates/base/web/src/proxy.ts.ejs +66 -0
  219. package/templates/base/web/src/store/auth.store.ts.ejs +89 -0
  220. package/templates/base/web/src/store/deviceSession.store.ts.ejs +141 -0
  221. package/templates/base/web/tsconfig.json +34 -0
  222. package/templates/features/mobile/auth/app/(auth)/_layout.tsx +16 -0
  223. package/templates/features/mobile/auth/app/(auth)/login.tsx +86 -0
  224. package/templates/features/mobile/auth/app/(auth)/register.tsx +86 -0
  225. package/templates/features/mobile/auth/components/auth/LoginForm.tsx.ejs +349 -0
  226. package/templates/features/mobile/auth/components/auth/RegisterForm.tsx.ejs +407 -0
  227. package/templates/features/mobile/auth/components/auth/index.ts +2 -0
  228. package/templates/features/mobile/auth/hooks/index.ts.ejs +1 -0
  229. package/templates/features/mobile/auth/hooks/useAuth.ts.ejs +367 -0
  230. package/templates/features/mobile/auth/services/deviceSession.ts +370 -0
  231. package/templates/features/mobile/auth/store/deviceSession.store.ts +326 -0
  232. package/templates/features/mobile/onboarding/app/(onboarding)/_layout.tsx.ejs +11 -0
  233. package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +52 -0
  234. package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +52 -0
  235. package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +60 -0
  236. package/templates/features/mobile/paywall/app/paywall.tsx +550 -0
  237. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +26 -0
  238. package/templates/features/mobile/tabs/app/(tabs)/index.tsx +565 -0
  239. package/templates/features/web/.gitkeep +0 -0
  240. package/templates/features/web/auth/app/(app)/dashboard/dashboard-client.tsx.ejs +166 -0
  241. package/templates/features/web/auth/app/(app)/dashboard/page.tsx.ejs +24 -0
  242. package/templates/features/web/auth/app/(app)/layout.tsx.ejs +43 -0
  243. package/templates/features/web/auth/app/(app)/settings/sessions/page.tsx.ejs +29 -0
  244. package/templates/features/web/auth/app/(app)/settings/sessions/sessions-client.tsx.ejs +77 -0
  245. package/templates/features/web/auth/app/(auth)/forgot-password/page.tsx.ejs +127 -0
  246. package/templates/features/web/auth/app/(auth)/layout.tsx.ejs +32 -0
  247. package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +35 -0
  248. package/templates/features/web/auth/app/(auth)/register/page.tsx.ejs +19 -0
  249. package/templates/features/web/auth/app/(auth)/reset-password/page.tsx.ejs +40 -0
  250. package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +198 -0
  251. package/templates/features/web/auth/app/auth/callback/route.ts.ejs +152 -0
  252. package/templates/features/web/auth/components/auth/login-form.tsx.ejs +100 -0
  253. package/templates/features/web/auth/components/auth/oauth-buttons.tsx.ejs +126 -0
  254. package/templates/features/web/auth/components/auth/password-reset-form.tsx.ejs +103 -0
  255. package/templates/features/web/auth/components/auth/register-form.tsx.ejs +139 -0
  256. package/templates/features/web/auth/components/settings/session-card.tsx.ejs +132 -0
  257. package/templates/integrations/mobile/adjust/services/adjustService.ts.ejs +163 -0
  258. package/templates/integrations/mobile/adjust/store/adjust.store.ts +243 -0
  259. package/templates/integrations/mobile/att/services/attService.ts +84 -0
  260. package/templates/integrations/mobile/att/services/trackingPermissions.ts +208 -0
  261. package/templates/integrations/mobile/att/store/att.store.ts +162 -0
  262. package/templates/integrations/mobile/revenuecat/services/revenuecatService.ts.ejs +174 -0
  263. package/templates/integrations/mobile/revenuecat/store/revenuecat.store.ts +286 -0
  264. package/templates/integrations/mobile/scate/services/scateService.ts.ejs +85 -0
  265. package/templates/integrations/mobile/scate/store/scate.store.ts +125 -0
  266. package/templates/integrations/web/.gitkeep +0 -0
  267. package/templates/shared/.env.example.ejs +21 -0
  268. package/templates/shared/.gitignore.ejs +145 -0
  269. package/templates/shared/README.md.ejs +134 -0
  270. package/templates/shared/docker-compose.prod.yml.ejs +120 -0
  271. package/templates/shared/docker-compose.yml.ejs +129 -0
  272. package/templates/shared/scripts/docker-dev.sh.ejs +395 -0
  273. package/templates/shared/scripts/docker-prod.sh.ejs +542 -0
  274. package/templates/shared/scripts/setup.sh.ejs +979 -0
@@ -0,0 +1,135 @@
1
+ 'use server';
2
+
3
+ import { revalidatePath } from 'next/cache';
4
+ import { getSessionToken } from './cookies';
5
+ import { buildAuthHeaders } from './actions';
6
+ import { AUTH_CONFIG } from './config';
7
+
8
+ export interface SessionInfo {
9
+ id: string;
10
+ userId: string;
11
+ token: string;
12
+ expiresAt: string;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ ipAddress?: string;
16
+ userAgent?: string;
17
+ }
18
+
19
+ /**
20
+ * List all active sessions for the current user
21
+ */
22
+ export async function listSessions(): Promise<{
23
+ sessions: SessionInfo[];
24
+ }> {
25
+ const sessionToken = await getSessionToken();
26
+
27
+ if (!sessionToken) {
28
+ return { sessions: [] };
29
+ }
30
+
31
+ try {
32
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/list-sessions`, {
33
+ method: 'GET',
34
+ headers: await buildAuthHeaders(),
35
+ cache: 'no-store',
36
+ });
37
+
38
+ if (!response.ok) {
39
+ console.error('Failed to list sessions:', response.status);
40
+ return { sessions: [] };
41
+ }
42
+
43
+ const data = await response.json();
44
+
45
+ // Better Auth returns sessions array
46
+ return {
47
+ sessions: data.sessions || data || [],
48
+ };
49
+ } catch (error) {
50
+ console.error('List sessions error:', error);
51
+ return { sessions: [] };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Revoke a specific session by its ID
57
+ * Uses BFF endpoint that looks up the token and calls Better Auth natively
58
+ */
59
+ export async function revokeSession(sessionId: string): Promise<{
60
+ success: boolean;
61
+ error?: string;
62
+ }> {
63
+ const sessionToken = await getSessionToken();
64
+
65
+ if (!sessionToken) {
66
+ return { success: false, error: 'Not authenticated' };
67
+ }
68
+
69
+ try {
70
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/revoke-session-by-id`, {
71
+ method: 'POST',
72
+ headers: await buildAuthHeaders(),
73
+ body: JSON.stringify({ sessionId }),
74
+ cache: 'no-store',
75
+ });
76
+
77
+ const responseData = await response.json().catch(() => ({}));
78
+
79
+ if (!response.ok) {
80
+ return {
81
+ success: false,
82
+ error: responseData.message || responseData.error || 'Failed to revoke session',
83
+ };
84
+ }
85
+
86
+ // Revalidate the sessions page to reflect the change
87
+ revalidatePath('/settings/sessions');
88
+
89
+ return { success: true };
90
+ } catch (error) {
91
+ console.error('Revoke session error:', error);
92
+ return { success: false, error: 'An error occurred' };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Revoke all other sessions (keep current session active)
98
+ */
99
+ export async function revokeOtherSessions(): Promise<{
100
+ success: boolean;
101
+ revokedCount?: number;
102
+ error?: string;
103
+ }> {
104
+ const sessionToken = await getSessionToken();
105
+
106
+ if (!sessionToken) {
107
+ return { success: false, error: 'Not authenticated' };
108
+ }
109
+
110
+ try {
111
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/revoke-other-sessions`, {
112
+ method: 'POST',
113
+ headers: await buildAuthHeaders(),
114
+ cache: 'no-store',
115
+ });
116
+
117
+ if (!response.ok) {
118
+ const error = await response.json().catch(() => ({}));
119
+ return {
120
+ success: false,
121
+ error: error.message || 'Failed to revoke sessions',
122
+ };
123
+ }
124
+
125
+ const data = await response.json();
126
+
127
+ // Revalidate the sessions page to reflect the change
128
+ revalidatePath('/settings/sessions');
129
+
130
+ return { success: true, revokedCount: data.revokedCount || 0 };
131
+ } catch (error) {
132
+ console.error('Revoke other sessions error:', error);
133
+ return { success: false, error: 'An error occurred' };
134
+ }
135
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Parse user agent string to get browser and OS info
3
+ */
4
+ export function parseUserAgent(userAgent?: string): {
5
+ browser: string;
6
+ os: string;
7
+ device: string;
8
+ } {
9
+ if (!userAgent) {
10
+ return { browser: 'Unknown', os: 'Unknown', device: 'Unknown' };
11
+ }
12
+
13
+ // Simple user agent parsing
14
+ let browser = 'Unknown';
15
+ let os = 'Unknown';
16
+ let device = 'Desktop';
17
+
18
+ // Detect browser
19
+ if (userAgent.includes('Firefox')) {
20
+ browser = 'Firefox';
21
+ } else if (userAgent.includes('Edg')) {
22
+ browser = 'Edge';
23
+ } else if (userAgent.includes('Chrome')) {
24
+ browser = 'Chrome';
25
+ } else if (userAgent.includes('Safari')) {
26
+ browser = 'Safari';
27
+ } else if (userAgent.includes('Opera') || userAgent.includes('OPR')) {
28
+ browser = 'Opera';
29
+ }
30
+
31
+ // Detect OS
32
+ if (userAgent.includes('Windows')) {
33
+ os = 'Windows';
34
+ } else if (userAgent.includes('Mac OS')) {
35
+ os = 'macOS';
36
+ } else if (userAgent.includes('Linux')) {
37
+ os = 'Linux';
38
+ } else if (userAgent.includes('Android')) {
39
+ os = 'Android';
40
+ device = 'Mobile';
41
+ } else if (userAgent.includes('iPhone') || userAgent.includes('iPad')) {
42
+ os = 'iOS';
43
+ device = userAgent.includes('iPad') ? 'Tablet' : 'Mobile';
44
+ }
45
+
46
+ return { browser, os, device };
47
+ }
@@ -0,0 +1,148 @@
1
+ 'use server';
2
+
3
+ import {
4
+ setDeviceSessionCookie,
5
+ getDeviceSessionToken,
6
+ clearDeviceSessionCookie,
7
+ } from '../auth/cookies';
8
+ import { AUTH_CONFIG } from '../auth/config';
9
+
10
+ export interface DeviceSession {
11
+ id: string;
12
+ deviceId: string;
13
+ sessionToken: string;
14
+ createdAt: string;
15
+ lastActiveAt: string;
16
+ migrated: boolean;
17
+ migratedToUserId: string | null;
18
+ preferredCurrency: string;
19
+ }
20
+
21
+ /**
22
+ * Create a new device session
23
+ *
24
+ * Called on first visit when no device session exists.
25
+ * The device ID should be generated client-side and stored in localStorage.
26
+ */
27
+ export async function createDeviceSession(deviceId: string): Promise<{
28
+ success: boolean;
29
+ session?: DeviceSession;
30
+ error?: string;
31
+ }> {
32
+ try {
33
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/device-sessions`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ deviceId }),
37
+ cache: 'no-store',
38
+ });
39
+
40
+ if (!response.ok) {
41
+ const error = await response.json().catch(() => ({}));
42
+ return {
43
+ success: false,
44
+ error: error.message || 'Failed to create device session',
45
+ };
46
+ }
47
+
48
+ const data = await response.json();
49
+
50
+ // Set device session cookie
51
+ await setDeviceSessionCookie(data.sessionToken);
52
+
53
+ return { success: true, session: data.session };
54
+ } catch (error) {
55
+ console.error('Create device session error:', error);
56
+ return { success: false, error: 'An error occurred' };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Validate existing device session
62
+ *
63
+ * Called on page load to check if the existing device session is still valid.
64
+ */
65
+ export async function validateDeviceSession(): Promise<{
66
+ valid: boolean;
67
+ session?: DeviceSession;
68
+ }> {
69
+ const token = await getDeviceSessionToken();
70
+
71
+ if (!token) {
72
+ return { valid: false };
73
+ }
74
+
75
+ try {
76
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/device-sessions/validate`, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ sessionToken: token }),
80
+ cache: 'no-store',
81
+ });
82
+
83
+ if (!response.ok) {
84
+ await clearDeviceSessionCookie();
85
+ return { valid: false };
86
+ }
87
+
88
+ const data = await response.json();
89
+ return { valid: true, session: data.session };
90
+ } catch (error) {
91
+ console.error('Validate device session error:', error);
92
+ return { valid: false };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Update device session activity (heartbeat)
98
+ *
99
+ * Called periodically while the user is active to prevent session expiry.
100
+ * This is a fire-and-forget operation - errors are logged but not thrown.
101
+ */
102
+ export async function updateDeviceActivity(): Promise<void> {
103
+ const token = await getDeviceSessionToken();
104
+
105
+ if (!token) return;
106
+
107
+ try {
108
+ await fetch(`${AUTH_CONFIG.backendUrl}/device-sessions/activity`, {
109
+ method: 'PUT',
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ },
113
+ body: JSON.stringify({ sessionToken: token }),
114
+ cache: 'no-store',
115
+ });
116
+ } catch (error) {
117
+ // Silently fail - heartbeat is non-critical
118
+ console.error('Device activity update error:', error);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get device session info
124
+ */
125
+ export async function getDeviceSessionInfo(): Promise<DeviceSession | null> {
126
+ const token = await getDeviceSessionToken();
127
+
128
+ if (!token) return null;
129
+
130
+ try {
131
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/device-sessions/info`, {
132
+ method: 'GET',
133
+ headers: {
134
+ 'X-Device-Session-Token': token,
135
+ },
136
+ cache: 'no-store',
137
+ });
138
+
139
+ if (!response.ok) {
140
+ return null;
141
+ }
142
+
143
+ return response.json();
144
+ } catch (error) {
145
+ console.error('Get device session info error:', error);
146
+ return null;
147
+ }
148
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Device ID utilities for web
3
+ *
4
+ * Generates and stores a unique device identifier in localStorage.
5
+ * This follows the same pattern as mobile but adapted for web.
6
+ *
7
+ * The device ID is used to create anonymous device sessions that persist
8
+ * across browser sessions until the user creates an account.
9
+ */
10
+
11
+ const DEVICE_ID_KEY = 'device_id';
12
+
13
+ /**
14
+ * Generate a unique device ID
15
+ *
16
+ * Format: web-{timestamp_base36}-{random_base36}
17
+ * Example: web-lq2abc5-k8m4n2p1x3
18
+ */
19
+ function generateDeviceId(): string {
20
+ const timestamp = Date.now().toString(36);
21
+ const random = Math.random().toString(36).substring(2, 15);
22
+ return `web-${timestamp}-${random}`;
23
+ }
24
+
25
+ /**
26
+ * Get or create device ID
27
+ *
28
+ * Stores in localStorage for persistence across browser sessions.
29
+ * Returns a temporary ID on the server (will be replaced on client hydration).
30
+ */
31
+ export function getOrCreateDeviceId(): string {
32
+ if (typeof window === 'undefined') {
33
+ // Server-side: return temporary ID
34
+ // This will be replaced when the client hydrates
35
+ return `temp-${Date.now()}`;
36
+ }
37
+
38
+ let deviceId = localStorage.getItem(DEVICE_ID_KEY);
39
+
40
+ if (!deviceId) {
41
+ deviceId = generateDeviceId();
42
+ localStorage.setItem(DEVICE_ID_KEY, deviceId);
43
+ }
44
+
45
+ return deviceId;
46
+ }
47
+
48
+ /**
49
+ * Check if device ID exists in localStorage
50
+ */
51
+ export function hasDeviceId(): boolean {
52
+ if (typeof window === 'undefined') return false;
53
+ return localStorage.getItem(DEVICE_ID_KEY) !== null;
54
+ }
55
+
56
+ /**
57
+ * Clear device ID from localStorage
58
+ *
59
+ * Useful for testing or when user wants to reset their device identity.
60
+ */
61
+ export function clearDeviceId(): void {
62
+ if (typeof window === 'undefined') return;
63
+ localStorage.removeItem(DEVICE_ID_KEY);
64
+ }
65
+
66
+ /**
67
+ * Get existing device ID (does not create new one)
68
+ *
69
+ * Returns null if no device ID exists.
70
+ */
71
+ export function getDeviceId(): string | null {
72
+ if (typeof window === 'undefined') return null;
73
+ return localStorage.getItem(DEVICE_ID_KEY);
74
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,66 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+ import { COOKIE_NAMES } from "./lib/auth/config";
4
+
5
+ /**
6
+ * Authentication proxy (Next.js 16+)
7
+ *
8
+ * Protects routes by checking for session cookie presence.
9
+ * Note: This only checks cookie existence, not validity.
10
+ * Full session validation happens in server components/actions.
11
+ *
12
+ * Routes:
13
+ * - Protected routes: Redirect to /login if no session
14
+ * - Auth routes: Redirect to /dashboard if already authenticated
15
+ * - Public routes: Pass through
16
+ */
17
+
18
+ // Routes that require authentication
19
+ const protectedRoutes = ["/dashboard", "/settings", "/profile"];
20
+
21
+ // Routes that should redirect to dashboard if already authenticated
22
+ const authRoutes = ["/login", "/register", "/forgot-password"];
23
+
24
+ // Routes that are always public
25
+ const publicRoutes = ["/", "/auth/callback", "/reset-password", "/verify-email"];
26
+
27
+ export function proxy(request: NextRequest) {
28
+ const { pathname } = request.nextUrl;
29
+ const sessionToken = request.cookies.get(COOKIE_NAMES.SESSION)?.value;
30
+
31
+ // Check if current path matches any protected routes
32
+ const isProtectedRoute = protectedRoutes.some(
33
+ (route) => pathname === route || pathname.startsWith(`${route}/`)
34
+ );
35
+
36
+ // Check if current path matches any auth routes
37
+ const isAuthRoute = authRoutes.some(
38
+ (route) => pathname === route || pathname.startsWith(`${route}/`)
39
+ );
40
+
41
+ // Redirect unauthenticated users from protected routes
42
+ if (isProtectedRoute && !sessionToken) {
43
+ const loginUrl = new URL("/login", request.url);
44
+ loginUrl.searchParams.set("redirect", pathname);
45
+ return NextResponse.redirect(loginUrl);
46
+ }
47
+
48
+ // Redirect authenticated users from auth routes to dashboard
49
+ if (isAuthRoute && sessionToken) {
50
+ return NextResponse.redirect(new URL("/dashboard", request.url));
51
+ }
52
+
53
+ return NextResponse.next();
54
+ }
55
+
56
+ export const config = {
57
+ /*
58
+ * Match all routes except:
59
+ * - API routes (/api/*)
60
+ * - Static files (/_next/static/*, /favicon.ico, etc.)
61
+ * - Public assets (/images/*, /fonts/*, etc.)
62
+ */
63
+ matcher: [
64
+ "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
65
+ ],
66
+ };
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import { create } from "zustand";
4
+ import { useShallow } from "zustand/react/shallow";
5
+ import { signOut as signOutAction, type AuthSession } from "@/lib/auth/actions";
6
+
7
+ interface AuthState {
8
+ // State
9
+ session: AuthSession | null;
10
+ isLoading: boolean;
11
+ error: string | null;
12
+ _isHydrated: boolean;
13
+
14
+ // Actions
15
+ setSession: (session: AuthSession | null) => void;
16
+ signOut: () => Promise<void>;
17
+ hydrate: (initialSession: AuthSession | null) => void;
18
+ clearError: () => void;
19
+
20
+ // Called when a server action returns auth error (401)
21
+ handleAuthError: () => void;
22
+ }
23
+
24
+ export const useAuthStore = create<AuthState>((set, get) => ({
25
+ session: null,
26
+ isLoading: true,
27
+ error: null,
28
+ _isHydrated: false,
29
+
30
+ // Hydrate from RSC (runs once on initial load)
31
+ hydrate: (initialSession) => {
32
+ if (get()._isHydrated) return;
33
+ set({ session: initialSession, isLoading: false, _isHydrated: true });
34
+ },
35
+
36
+ // Update session (after sign-in, profile update, etc.)
37
+ setSession: (session) => set({ session, error: null }),
38
+
39
+ // Sign out
40
+ signOut: async () => {
41
+ try {
42
+ set({ isLoading: true, error: null });
43
+ await signOutAction();
44
+ set({ session: null });
45
+ } catch (error) {
46
+ set({ error: error instanceof Error ? error.message : "Sign out failed" });
47
+ } finally {
48
+ set({ isLoading: false });
49
+ }
50
+ },
51
+
52
+ // Called when any server action returns 401/auth error
53
+ handleAuthError: () => {
54
+ set({ session: null, error: "Session expired" });
55
+ },
56
+
57
+ clearError: () => set({ error: null }),
58
+ }));
59
+
60
+ // ============================================================================
61
+ // Selector Hooks (using useShallow for React 19 compatibility)
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Session data and status - use for reading auth state
66
+ */
67
+ export const useAuth = () =>
68
+ useAuthStore(
69
+ useShallow((s) => ({
70
+ session: s.session,
71
+ user: s.session?.user ?? null,
72
+ isLoading: s.isLoading,
73
+ isAuthenticated: !!s.session?.user,
74
+ error: s.error,
75
+ }))
76
+ );
77
+
78
+ /**
79
+ * Auth actions - use for triggering auth operations
80
+ */
81
+ export const useAuthActions = () =>
82
+ useAuthStore(
83
+ useShallow((s) => ({
84
+ setSession: s.setSession,
85
+ signOut: s.signOut,
86
+ handleAuthError: s.handleAuthError,
87
+ clearError: s.clearError,
88
+ }))
89
+ );
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ import { create } from "zustand";
4
+ import { useShallow } from "zustand/react/shallow";
5
+ import { getOrCreateDeviceId, getDeviceId } from "@/lib/device/id";
6
+ import {
7
+ createDeviceSession,
8
+ validateDeviceSession,
9
+ updateDeviceActivity,
10
+ type DeviceSession,
11
+ } from "@/lib/device/actions";
12
+
13
+ interface DeviceSessionState {
14
+ // State
15
+ deviceSession: DeviceSession | null;
16
+ deviceId: string | null;
17
+ isLoading: boolean;
18
+ isInitialized: boolean;
19
+ error: string | null;
20
+
21
+ // Actions
22
+ initializeSession: () => Promise<void>;
23
+ refreshSession: () => Promise<void>;
24
+ sendHeartbeat: () => Promise<void>;
25
+ setDeviceSession: (session: DeviceSession | null) => void;
26
+ clearError: () => void;
27
+ }
28
+
29
+ export const useDeviceSessionStore = create<DeviceSessionState>((set, get) => ({
30
+ deviceSession: null,
31
+ deviceId: null,
32
+ isLoading: true,
33
+ isInitialized: false,
34
+ error: null,
35
+
36
+ // Initialize device session (called once on mount)
37
+ initializeSession: async () => {
38
+ if (get().isInitialized) return;
39
+
40
+ try {
41
+ set({ isLoading: true, error: null });
42
+
43
+ // Get or create device ID from localStorage
44
+ const id = getOrCreateDeviceId();
45
+ set({ deviceId: id });
46
+
47
+ // Skip initialization with temp IDs (server-side rendering)
48
+ if (id.startsWith("temp-")) {
49
+ set({ isLoading: false, isInitialized: true });
50
+ return;
51
+ }
52
+
53
+ // Try to validate existing session
54
+ const { valid, session } = await validateDeviceSession();
55
+
56
+ if (valid && session) {
57
+ set({ deviceSession: session });
58
+ } else {
59
+ // Create new device session
60
+ const result = await createDeviceSession(id);
61
+ if (result.success && result.session) {
62
+ set({ deviceSession: result.session });
63
+ } else if (result.error) {
64
+ set({ error: result.error });
65
+ }
66
+ }
67
+ } catch (error) {
68
+ console.error("Device session initialization error:", error);
69
+ set({ error: error instanceof Error ? error.message : "Initialization failed" });
70
+ } finally {
71
+ set({ isLoading: false, isInitialized: true });
72
+ }
73
+ },
74
+
75
+ // Refresh session (re-validate or create new)
76
+ refreshSession: async () => {
77
+ const deviceId = get().deviceId || getDeviceId();
78
+ if (!deviceId || deviceId.startsWith("temp-")) return;
79
+
80
+ try {
81
+ set({ error: null });
82
+ const { valid, session } = await validateDeviceSession();
83
+
84
+ if (valid && session) {
85
+ set({ deviceSession: session });
86
+ } else {
87
+ const result = await createDeviceSession(deviceId);
88
+ if (result.success && result.session) {
89
+ set({ deviceSession: result.session });
90
+ }
91
+ }
92
+ } catch (error) {
93
+ console.error("Device session refresh error:", error);
94
+ }
95
+ },
96
+
97
+ // Heartbeat - fire and forget
98
+ sendHeartbeat: async () => {
99
+ if (!get().deviceSession) return;
100
+ try {
101
+ await updateDeviceActivity();
102
+ } catch (error) {
103
+ // Silently fail - heartbeat is non-critical
104
+ console.error("Device activity update error:", error);
105
+ }
106
+ },
107
+
108
+ setDeviceSession: (deviceSession) => set({ deviceSession }),
109
+ clearError: () => set({ error: null }),
110
+ }));
111
+
112
+ // ============================================================================
113
+ // Selector Hooks (using useShallow for React 19 compatibility)
114
+ // ============================================================================
115
+
116
+ /**
117
+ * Device session data and status
118
+ */
119
+ export const useDeviceSession = () =>
120
+ useDeviceSessionStore(
121
+ useShallow((s) => ({
122
+ deviceSession: s.deviceSession,
123
+ deviceId: s.deviceId,
124
+ isLoading: s.isLoading,
125
+ hasSession: !!s.deviceSession,
126
+ error: s.error,
127
+ }))
128
+ );
129
+
130
+ /**
131
+ * Device session actions
132
+ */
133
+ export const useDeviceSessionActions = () =>
134
+ useDeviceSessionStore(
135
+ useShallow((s) => ({
136
+ initializeSession: s.initializeSession,
137
+ refreshSession: s.refreshSession,
138
+ sendHeartbeat: s.sendHeartbeat,
139
+ clearError: s.clearError,
140
+ }))
141
+ );