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.
- package/LICENSE +21 -0
- package/README.md +642 -0
- package/bin/cli.js +12 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +113 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/dependencies.d.ts +82 -0
- package/dist/config/dependencies.d.ts.map +1 -0
- package/dist/config/dependencies.js +82 -0
- package/dist/config/dependencies.js.map +1 -0
- package/dist/config/presets.d.ts +3 -0
- package/dist/config/presets.d.ts.map +1 -0
- package/dist/config/presets.js +174 -0
- package/dist/config/presets.js.map +1 -0
- package/dist/generators/index.d.ts +40 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/index.js +130 -0
- package/dist/generators/index.js.map +1 -0
- package/dist/generators/onboarding.d.ts +8 -0
- package/dist/generators/onboarding.d.ts.map +1 -0
- package/dist/generators/onboarding.js +141 -0
- package/dist/generators/onboarding.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +65 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/features.d.ts +14 -0
- package/dist/prompts/features.d.ts.map +1 -0
- package/dist/prompts/features.js +96 -0
- package/dist/prompts/features.js.map +1 -0
- package/dist/prompts/index.d.ts +3 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +93 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/onboarding.d.ts +6 -0
- package/dist/prompts/onboarding.d.ts.map +1 -0
- package/dist/prompts/onboarding.js +37 -0
- package/dist/prompts/onboarding.js.map +1 -0
- package/dist/prompts/orm.d.ts +3 -0
- package/dist/prompts/orm.d.ts.map +1 -0
- package/dist/prompts/orm.js +23 -0
- package/dist/prompts/orm.js.map +1 -0
- package/dist/prompts/packageManager.d.ts +2 -0
- package/dist/prompts/packageManager.d.ts.map +1 -0
- package/dist/prompts/packageManager.js +18 -0
- package/dist/prompts/packageManager.js.map +1 -0
- package/dist/prompts/platform.d.ts +3 -0
- package/dist/prompts/platform.d.ts.map +1 -0
- package/dist/prompts/platform.js +21 -0
- package/dist/prompts/platform.js.map +1 -0
- package/dist/prompts/preset.d.ts +4 -0
- package/dist/prompts/preset.d.ts.map +1 -0
- package/dist/prompts/preset.js +165 -0
- package/dist/prompts/preset.js.map +1 -0
- package/dist/prompts/project.d.ts +2 -0
- package/dist/prompts/project.d.ts.map +1 -0
- package/dist/prompts/project.js +27 -0
- package/dist/prompts/project.js.map +1 -0
- package/dist/prompts/sdks.d.ts +2 -0
- package/dist/prompts/sdks.d.ts.map +1 -0
- package/dist/prompts/sdks.js +46 -0
- package/dist/prompts/sdks.js.map +1 -0
- package/dist/types/index.d.ts +77 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +25 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cleanup.d.ts +5 -0
- package/dist/utils/cleanup.d.ts.map +1 -0
- package/dist/utils/cleanup.js +38 -0
- package/dist/utils/cleanup.js.map +1 -0
- package/dist/utils/copy.d.ts +10 -0
- package/dist/utils/copy.d.ts.map +1 -0
- package/dist/utils/copy.js +53 -0
- package/dist/utils/copy.js.map +1 -0
- package/dist/utils/errors.d.ts +33 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +136 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/git.d.ts +5 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +33 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +22 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/package.d.ts +16 -0
- package/dist/utils/package.d.ts.map +1 -0
- package/dist/utils/package.js +86 -0
- package/dist/utils/package.js.map +1 -0
- package/dist/utils/system-validation.d.ts +9 -0
- package/dist/utils/system-validation.d.ts.map +1 -0
- package/dist/utils/system-validation.js +31 -0
- package/dist/utils/system-validation.js.map +1 -0
- package/dist/utils/template.d.ts +20 -0
- package/dist/utils/template.d.ts.map +1 -0
- package/dist/utils/template.js +234 -0
- package/dist/utils/template.js.map +1 -0
- package/dist/utils/validation.d.ts +8 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +94 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +96 -0
- package/templates/base/backend/.dockerignore.ejs +62 -0
- package/templates/base/backend/.env.example.ejs +116 -0
- package/templates/base/backend/Dockerfile.ejs +142 -0
- package/templates/base/backend/controllers/event-queue/index.ts +20 -0
- package/templates/base/backend/controllers/event-queue/workers/user.ts +39 -0
- package/templates/base/backend/controllers/rest-api/index.ts +48 -0
- package/templates/base/backend/controllers/rest-api/plugins/auth.ts +152 -0
- package/templates/base/backend/controllers/rest-api/plugins/config.ts +64 -0
- package/templates/base/backend/controllers/rest-api/plugins/error-handler.ts +118 -0
- package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +180 -0
- package/templates/base/backend/controllers/rest-api/routes/device-sessions.ts +197 -0
- package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +375 -0
- package/templates/base/backend/controllers/rest-api/server.ts.ejs +87 -0
- package/templates/base/backend/domain/device-session/repository.drizzle.ts +209 -0
- package/templates/base/backend/domain/device-session/repository.prisma.ts +248 -0
- package/templates/base/backend/domain/device-session/schema.ts +72 -0
- package/templates/base/backend/domain/session/repository.drizzle.ts +72 -0
- package/templates/base/backend/domain/session/repository.prisma.ts +72 -0
- package/templates/base/backend/domain/session/schema.ts +29 -0
- package/templates/base/backend/domain/user/repository.drizzle.ts +127 -0
- package/templates/base/backend/domain/user/repository.prisma.ts +115 -0
- package/templates/base/backend/domain/user/schema.ts +14 -0
- package/templates/base/backend/drizzle/schema.drizzle.ts +111 -0
- package/templates/base/backend/drizzle.config.drizzle.ts +13 -0
- package/templates/base/backend/lib/auth.drizzle.ts.ejs +104 -0
- package/templates/base/backend/lib/auth.prisma.ts.ejs +97 -0
- package/templates/base/backend/lib/constants.ts.ejs +29 -0
- package/templates/base/backend/package.json.ejs +50 -0
- package/templates/base/backend/prisma/schema.prisma.ejs +102 -0
- package/templates/base/backend/prisma.config.prisma.ts +12 -0
- package/templates/base/backend/tsconfig.json +39 -0
- package/templates/base/backend/utils/db.drizzle.ts +41 -0
- package/templates/base/backend/utils/db.prisma.ts +51 -0
- package/templates/base/backend/utils/email.ts.ejs +35 -0
- package/templates/base/backend/utils/errors.ts +348 -0
- package/templates/base/backend/utils/redis.ts.ejs +279 -0
- package/templates/base/mobile/.env.example.ejs +35 -0
- package/templates/base/mobile/.gitignore.ejs +167 -0
- package/templates/base/mobile/app/+not-found.tsx +85 -0
- package/templates/base/mobile/app/_layout.tsx.ejs +71 -0
- package/templates/base/mobile/app.json.ejs +88 -0
- package/templates/base/mobile/assets/images/adaptive-icon.png +0 -0
- package/templates/base/mobile/assets/images/favicon.png +0 -0
- package/templates/base/mobile/assets/images/icon.png +0 -0
- package/templates/base/mobile/assets/images/onboarding_page_1.png +0 -0
- package/templates/base/mobile/assets/images/onboarding_page_2.png +0 -0
- package/templates/base/mobile/assets/images/onboarding_page_3.png +0 -0
- package/templates/base/mobile/assets/images/paywall_image.png +0 -0
- package/templates/base/mobile/assets/images/splash.png +0 -0
- package/templates/base/mobile/eas.json.ejs +49 -0
- package/templates/base/mobile/metro.config.js +9 -0
- package/templates/base/mobile/package.json.ejs +53 -0
- package/templates/base/mobile/src/components/ui/Button.tsx +131 -0
- package/templates/base/mobile/src/components/ui/Card.tsx +68 -0
- package/templates/base/mobile/src/components/ui/IconSymbol.tsx +90 -0
- package/templates/base/mobile/src/components/ui/Input.tsx +142 -0
- package/templates/base/mobile/src/components/ui/LoadingSpinner.tsx +98 -0
- package/templates/base/mobile/src/components/ui/OnboardingLayout.tsx +356 -0
- package/templates/base/mobile/src/components/ui/PaywallLayout.tsx +311 -0
- package/templates/base/mobile/src/components/ui/Skeleton.tsx +58 -0
- package/templates/base/mobile/src/components/ui/index.ts +6 -0
- package/templates/base/mobile/src/constants/Theme.ts +163 -0
- package/templates/base/mobile/src/context/ThemeContext.tsx +157 -0
- package/templates/base/mobile/src/lib/auth-client.ts.ejs +51 -0
- package/templates/base/mobile/src/services/api.ts.ejs +71 -0
- package/templates/base/mobile/src/services/errorService.ts +179 -0
- package/templates/base/mobile/src/services/sdkInitializer.ts.ejs +36 -0
- package/templates/base/mobile/src/store/index.ts.ejs +18 -0
- package/templates/base/mobile/src/store/ui.store.ts +100 -0
- package/templates/base/mobile/src/utils/formatters.ts +105 -0
- package/templates/base/mobile/src/utils/logger.ts +73 -0
- package/templates/base/mobile/src/utils/responsive.ts +234 -0
- package/templates/base/mobile/tsconfig.json +32 -0
- package/templates/base/web/.env.example.ejs +26 -0
- package/templates/base/web/components.json +22 -0
- package/templates/base/web/eslint.config.mjs +18 -0
- package/templates/base/web/next.config.ts +7 -0
- package/templates/base/web/package.json.ejs +35 -0
- package/templates/base/web/postcss.config.mjs +7 -0
- package/templates/base/web/public/.gitkeep +0 -0
- package/templates/base/web/public/file.svg +1 -0
- package/templates/base/web/public/globe.svg +1 -0
- package/templates/base/web/public/next.svg +1 -0
- package/templates/base/web/public/vercel.svg +1 -0
- package/templates/base/web/public/window.svg +1 -0
- package/templates/base/web/src/app/favicon.ico +0 -0
- package/templates/base/web/src/app/globals.css +152 -0
- package/templates/base/web/src/app/layout.tsx.ejs +54 -0
- package/templates/base/web/src/app/page.tsx.ejs +92 -0
- package/templates/base/web/src/components/auth/auth-hydrator.tsx.ejs +19 -0
- package/templates/base/web/src/components/auth/protected-route.tsx.ejs +109 -0
- package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +56 -0
- package/templates/base/web/src/components/providers/theme-provider.tsx +17 -0
- package/templates/base/web/src/components/theme-toggle.tsx +34 -0
- package/templates/base/web/src/components/ui/button.tsx +62 -0
- package/templates/base/web/src/components/ui/card.tsx +92 -0
- package/templates/base/web/src/components/ui/input.tsx +21 -0
- package/templates/base/web/src/components/ui/label.tsx +24 -0
- package/templates/base/web/src/components/ui/skeleton.tsx +13 -0
- package/templates/base/web/src/components/ui/spinner.tsx +20 -0
- package/templates/base/web/src/hooks/use-device-session.ts.ejs +40 -0
- package/templates/base/web/src/hooks/use-session.ts.ejs +56 -0
- package/templates/base/web/src/lib/auth/actions.ts.ejs +334 -0
- package/templates/base/web/src/lib/auth/config.ts.ejs +65 -0
- package/templates/base/web/src/lib/auth/cookies.ts.ejs +74 -0
- package/templates/base/web/src/lib/auth/index.ts.ejs +40 -0
- package/templates/base/web/src/lib/auth/oauth.ts.ejs +72 -0
- package/templates/base/web/src/lib/auth/pkce.ts.ejs +48 -0
- package/templates/base/web/src/lib/auth/sessions.ts.ejs +135 -0
- package/templates/base/web/src/lib/auth/user-agent.ts.ejs +47 -0
- package/templates/base/web/src/lib/device/actions.ts.ejs +148 -0
- package/templates/base/web/src/lib/device/id.ts.ejs +74 -0
- package/templates/base/web/src/lib/utils.ts +6 -0
- package/templates/base/web/src/proxy.ts.ejs +66 -0
- package/templates/base/web/src/store/auth.store.ts.ejs +89 -0
- package/templates/base/web/src/store/deviceSession.store.ts.ejs +141 -0
- package/templates/base/web/tsconfig.json +34 -0
- package/templates/features/mobile/auth/app/(auth)/_layout.tsx +16 -0
- package/templates/features/mobile/auth/app/(auth)/login.tsx +86 -0
- package/templates/features/mobile/auth/app/(auth)/register.tsx +86 -0
- package/templates/features/mobile/auth/components/auth/LoginForm.tsx.ejs +349 -0
- package/templates/features/mobile/auth/components/auth/RegisterForm.tsx.ejs +407 -0
- package/templates/features/mobile/auth/components/auth/index.ts +2 -0
- package/templates/features/mobile/auth/hooks/index.ts.ejs +1 -0
- package/templates/features/mobile/auth/hooks/useAuth.ts.ejs +367 -0
- package/templates/features/mobile/auth/services/deviceSession.ts +370 -0
- package/templates/features/mobile/auth/store/deviceSession.store.ts +326 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/_layout.tsx.ejs +11 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +52 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +52 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +60 -0
- package/templates/features/mobile/paywall/app/paywall.tsx +550 -0
- package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +26 -0
- package/templates/features/mobile/tabs/app/(tabs)/index.tsx +565 -0
- package/templates/features/web/.gitkeep +0 -0
- package/templates/features/web/auth/app/(app)/dashboard/dashboard-client.tsx.ejs +166 -0
- package/templates/features/web/auth/app/(app)/dashboard/page.tsx.ejs +24 -0
- package/templates/features/web/auth/app/(app)/layout.tsx.ejs +43 -0
- package/templates/features/web/auth/app/(app)/settings/sessions/page.tsx.ejs +29 -0
- package/templates/features/web/auth/app/(app)/settings/sessions/sessions-client.tsx.ejs +77 -0
- package/templates/features/web/auth/app/(auth)/forgot-password/page.tsx.ejs +127 -0
- package/templates/features/web/auth/app/(auth)/layout.tsx.ejs +32 -0
- package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +35 -0
- package/templates/features/web/auth/app/(auth)/register/page.tsx.ejs +19 -0
- package/templates/features/web/auth/app/(auth)/reset-password/page.tsx.ejs +40 -0
- package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +198 -0
- package/templates/features/web/auth/app/auth/callback/route.ts.ejs +152 -0
- package/templates/features/web/auth/components/auth/login-form.tsx.ejs +100 -0
- package/templates/features/web/auth/components/auth/oauth-buttons.tsx.ejs +126 -0
- package/templates/features/web/auth/components/auth/password-reset-form.tsx.ejs +103 -0
- package/templates/features/web/auth/components/auth/register-form.tsx.ejs +139 -0
- package/templates/features/web/auth/components/settings/session-card.tsx.ejs +132 -0
- package/templates/integrations/mobile/adjust/services/adjustService.ts.ejs +163 -0
- package/templates/integrations/mobile/adjust/store/adjust.store.ts +243 -0
- package/templates/integrations/mobile/att/services/attService.ts +84 -0
- package/templates/integrations/mobile/att/services/trackingPermissions.ts +208 -0
- package/templates/integrations/mobile/att/store/att.store.ts +162 -0
- package/templates/integrations/mobile/revenuecat/services/revenuecatService.ts.ejs +174 -0
- package/templates/integrations/mobile/revenuecat/store/revenuecat.store.ts +286 -0
- package/templates/integrations/mobile/scate/services/scateService.ts.ejs +85 -0
- package/templates/integrations/mobile/scate/store/scate.store.ts +125 -0
- package/templates/integrations/web/.gitkeep +0 -0
- package/templates/shared/.env.example.ejs +21 -0
- package/templates/shared/.gitignore.ejs +145 -0
- package/templates/shared/README.md.ejs +134 -0
- package/templates/shared/docker-compose.prod.yml.ejs +120 -0
- package/templates/shared/docker-compose.yml.ejs +129 -0
- package/templates/shared/scripts/docker-dev.sh.ejs +395 -0
- package/templates/shared/scripts/docker-prod.sh.ejs +542 -0
- 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,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
|
+
);
|